Merge pull request #1744 from dotCooCoo/feat/symbol-instance-art-bounds

Symbols: instances of imported symbols are measured by their art
This commit is contained in:
BFlat authored and GitHub committed 2026-10-14 05:06:05 +07:00
commit bb70aa8359
42 files changed
+2729 -205

No files matched your search

+21
View File
@@ -651,6 +651,27 @@ fn inserts_become_symbol_instances_with_their_transform() {
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
}
#[test]
fn an_insert_is_measured_clicked_and_marqueed_by_its_blocks_art() {
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let [inst] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
// The line, 20 units up from the insertion point: a box with no width at x 100. The 20 × 20
// square placed by the insert's transform would be a 60 × 40 box from x 70 to 130.
let b = doc.bounds_of(&[inst.id], false).unwrap();
assert!(close(b, Rect::new(100.0, h - 70.0, 100.0, h - 50.0), 1e-9), "{b:?}");
// A click 25 pt right of the line, inside that square's box, selects nothing; one on the line
// selects the insert.
let hit = |x: f64| vectorcraft_doc::hit::hit_test(&doc, Point::new(x, h - 60.0), vectorcraft_doc::hit::HitOptions::default()).map(|h| h.leaf);
assert_eq!(hit(125.0), None);
assert_eq!(hit(100.0), Some(inst.id));
// A marquee beside the line leaves it out; one across it takes it.
let marquee = |r: Rect| vectorcraft_doc::hit::marquee(&doc, r, None, false);
assert!(marquee(Rect::new(115.0, h - 65.0, 125.0, h - 55.0)).is_empty());
assert_eq!(marquee(Rect::new(90.0, h - 62.0, 110.0, h - 58.0)), vec![inst.id]);
}
#[test]
fn by_block_colours_make_a_symbol_per_look() {
// The block's line takes the insert's colour: red and green make two symbols; a third red
+6 -2
View File
@@ -8,7 +8,7 @@ use std::io::Cursor;
use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
@@ -158,6 +158,9 @@ struct LayerFlags {
pub(crate) struct Scene<'a> {
doc: &'a Document,
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
/// crops to the region ([`SymbolBoxes::quick`]).
symbols: SymbolBoxes<'a>,
o: &'a DxfOptions,
/// Document space → drawing units (y up, origin at the region's bottom-left).
to_dxf: Affine,
@@ -192,6 +195,7 @@ impl<'a> Scene<'a> {
let r = o.region;
Self {
doc,
symbols: doc.symbol_boxes().quick(),
o,
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
k,
@@ -271,7 +275,7 @@ impl<'a> Scene<'a> {
return;
}
if self.o.crop && !layer {
match n.visual_bounds() {
match n.visual_bounds_with(&self.symbols) {
Some(b) if !overlaps(b, self.o.region) => return,
None if !n.is_container() => return,
_ => {}
+11
View File
@@ -442,3 +442,14 @@ fn crop_leaves_out_art_outside_the_region() {
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
#[test]
fn crop_keeps_an_instance_whose_art_reaches_the_region() {
// The symbol's red rectangle, (100, 50) to (140, 90), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let o = DxfOptions { crop: true, ..opts(&d) };
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
+72 -37
View File
@@ -5,7 +5,7 @@ use std::collections::HashSet;
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{Point, Rect};
use crate::{Document, Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitKind {
@@ -95,6 +95,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
}
let mut tops = Vec::new();
let mut seen = HashSet::new();
let symbols = doc.symbol_boxes();
for layer in doc.layers.iter().rev() {
// Malformed files may contain non-layer roots; let the general traversal handle them.
if !layer.is_layer() {
@@ -103,7 +104,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
if !objects_at_layer(layer, p, opt, &mut tops, &mut seen) {
if !objects_at_layer(layer, p, opt, &symbols, &mut tops, &mut seen) {
// An envelope in Edit Contents may expose objects *within* a top-level group.
// The repeated traversal handles that mixed hierarchy precisely.
return objects_at_repeated(doc, p, opt, scope);
@@ -114,19 +115,20 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
/// inspected once (only until its first hit). `false` asks the caller to use the general
/// algorithm for an envelope whose contents are currently being edited.
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
/// algorithm for an envelope whose contents are currently being edited. Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
let Some(children) = layer.children() else { return true };
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return true;
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
for (i, c) in children.iter().enumerate().rev() {
if !may_hit(layer, i, c, p, opt, true) || seen.contains(&c.id) {
if !may_hit(layer, i, c, p, opt, symbols, true) || seen.contains(&c.id) {
continue;
}
if c.is_layer() {
if !objects_at_layer(c, p, opt, tops, seen) {
if !objects_at_layer(c, p, opt, symbols, tops, seen) {
return false;
}
continue;
@@ -143,9 +145,9 @@ fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec<Node
let skip = |id| seen.contains(&id);
let mut chain = Vec::new();
let hit = match &c.kind {
NodeKind::Group { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some(),
NodeKind::Blend { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt).is_some(),
_ => hit_leaf(c, p, opt).is_some(),
NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
_ => hit_leaf(c, p, opt, symbols).is_some(),
};
if hit && seen.insert(c.id) {
tops.push(c.id);
@@ -167,13 +169,15 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
tops
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
let symbols = doc.symbol_boxes();
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if editable_only
@@ -182,12 +186,12 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
&& let Some(mut chain) = doc.ancestry(scope)
{
if isolated.is_container() {
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
}
// A single object isolated (a path, an image…): only it hits, among its siblings.
chain.pop();
let parent = chain.last().and_then(|id| doc.node(*id))?;
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
return hit_children(parent, p, opt, &symbols, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
@@ -200,7 +204,7 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain, editable_only) {
if let Some(h) = hit_children(layer, p, opt, &symbols, skip, &mut chain, editable_only) {
return Some(finish(h));
}
chain.pop();
@@ -226,7 +230,7 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editable_only: bool) -> bool {
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
return false;
@@ -236,13 +240,16 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editabl
return false;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c) || c.extras().shaper.is_some() && source_isolated(c) || c.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
fn hit_children(
parent: &Node,
p: Point,
opt: HitOptions,
symbols: &SymbolBoxes,
skip: &dyn Fn(NodeId) -> bool,
chain: &mut Vec<NodeId>,
editable_only: bool,
@@ -256,21 +263,21 @@ fn hit_children(
return None;
}
for (i, c) in children.iter().enumerate().rev() {
if !may_hit(parent, i, c, p, opt, editable_only) || skip(c.id) {
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
continue;
}
chain.push(c.id);
let hit = match &c.kind {
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain, editable_only),
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain, editable_only),
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
};
if hit.is_some() {
return hit;
@@ -290,7 +297,7 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -320,8 +327,8 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
| NodeKind::PlacedDocument(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
if stroke_contains(&bp, 0.0, opt.tol, p) {
@@ -356,14 +363,15 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
}
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt)),
_ => hit_leaf(n, p, opt),
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
@@ -394,38 +402,44 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
out
}
fn touches(n: &Node, r: Rect) -> bool {
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
children.iter().any(|c| c.visible && touches(c, r))
children.iter().any(|c| c.visible && touches(c, s, r))
}
_ => n.geometric_bounds().is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.origin())),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
}),
}
}
fn collect_leaves(n: &Node, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
if c.visible && !c.locked {
collect_leaves(c, r, out);
collect_leaves(c, s, r, out);
}
}
}
_ => {
if touches(n, r) {
if touches(n, s, r) {
out.push(n.id);
}
}
}
}
let symbols = doc.symbol_boxes();
for t in tops {
if !t.visible || t.locked {
continue;
}
if leaves {
collect_leaves(t, r, &mut out);
} else if touches(t, r) {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
out.push(t.id);
}
}
@@ -807,6 +821,27 @@ world",
}
}
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
/// one instance and 100 pt lower by an instance in a group.
#[test]
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance =
|id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
let opt = HitOptions::default();
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
let p = Point::new(x, y);
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
}
}
/// Edit Contents can expose descendants of a top-level group: preserve the original
/// selection order by falling back to the general hit traversal.
#[test]
+30 -13
View File
@@ -43,6 +43,8 @@ pub mod shaper;
pub mod slices;
pub mod style_libs;
pub mod swatches;
mod symbols;
pub use symbols::SymbolBoxes;
pub mod text;
pub mod trace;
pub mod variables;
@@ -1207,33 +1209,36 @@ impl Document {
_ => LAYER_COLORS[0].1,
}
}
/// Union of the geometric bounds of `ids`.
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
/// instances measured by their art ([`Node::geometric_bounds_with`]).
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
ids.iter()
.filter_map(|id| self.node(*id))
.fold(None, |acc, n| vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds() } else { n.geometric_bounds() }))
let symbols = self.symbol_boxes();
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
})
}
/// Bounds of all art.
/// Visual bounds of all art, with symbol instances measured by their art.
pub fn art_bounds(&self) -> Option<Rect> {
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds()))
let symbols = self.symbol_boxes();
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
}
/// Artboard index containing point `p` (topmost = last).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it. Locked and hidden objects and layers stay put unless
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
/// Artboard).
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
/// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display ›
/// Move Locked and Hidden Artwork with Artboard).
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
// Art drawn flush with the artboard can end up a rounding error outside it (a background
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
let rect = rect.inflate(1e-6, 1e-6);
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() {
collect(c, rect, all, out);
} else if let Some(b) = c.geometric_bounds()
collect(c, s, rect, all, out);
} else if let Some(b) = c.geometric_bounds_with(s)
// Inside it, edges included: art flush with the right or bottom edge is on it too
// (`Rect::contains` leaves those out, #890).
&& b.x0 >= rect.x0
@@ -1245,9 +1250,10 @@ impl Document {
}
}
}
let symbols = self.symbol_boxes();
let mut art = vec![];
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
collect(l, rect, locked_and_hidden, &mut art);
collect(l, &symbols, rect, locked_and_hidden, &mut art);
}
art
}
@@ -1562,6 +1568,17 @@ mod tests {
}
}
#[test]
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
+15 -3
View File
@@ -1705,11 +1705,23 @@ pub fn subtree_has_live(n: &Node) -> bool {
any
}
/// Largest stroke outset in a subtree (for visual bounds of envelopes).
pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the
/// outsets in the art of the symbols its instances show count too
/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made
/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad
/// does.
pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxes>) -> f64 {
let mut o = 0.0f64;
for n in nodes {
n.walk(&mut |c| o = o.max(c.appearance.outset()));
n.walk(&mut |c| {
o = o.max(c.appearance.outset());
if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind)
&& let Some(art) = s.art(symbol)
{
let reach = if s.art_strokes() { art.reach } else { 0.0 };
o = o.max(reach + s.brush_pad(symbol));
}
});
}
o
}
+87 -21
View File
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
use crate::pattern::RepeatSpec;
use crate::symbols::{SYMBOL_SQUARE, SymbolBoxes};
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
@@ -1004,52 +1005,101 @@ impl Node {
}
/// A quick box round everything the object can paint, for culling clicks: paths and compound
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
/// ([`Appearance::outset`]) instead of measuring along their shape.
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
/// measured as in [`Node::visual_bounds`].
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
/// grown by the farthest a stroke in the art can reach.
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
symbols.quickly(|| self.reach(Some(symbols)))
}
/// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given.
pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
self.geometric(symbols).map(|b| b.inflate(o, o))
}
_ => self.visual_bounds(),
_ => self.visual(symbols),
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A
/// symbol instance is measured as its transform applied to the 20 × 20 square around the origin
/// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures
/// its art).
pub fn geometric_bounds(&self) -> Option<Rect> {
self.geometric(None)
}
/// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to
/// its symbol's art box from `symbols`. An instance is measured as the square when its symbol
/// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and
/// when symbols nest 32 deep in its art.
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.geometric(Some(symbols))
}
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
/// is given.
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
let union = |nodes: &[Arc<Node>]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols)));
match &self.kind {
NodeKind::Path { path, .. } => path.bounds(),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric_bounds())
children.first().and_then(|c| c.geometric(symbols))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
NodeKind::Text(t) => self.projected(t.bounds()),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
NodeKind::Blend { children, spec } => {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
NodeKind::SymbolInstance { symbol, xf } => {
let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric);
self.projected(Some(xf.transform_rect_bbox(art)))
}
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
/// ([`Node::visual_bounds_with`] measures its art).
pub fn visual_bounds(&self) -> Option<Rect> {
self.visual(None)
}
/// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from
/// `symbols`, the strokes in it included. An instance that only moves its art is measured as
/// the art's visual box moved. Any other instance is measured as its art transformed by it with
/// the strokes at their own weight, as the renderer draws it, and never smaller than its
/// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as
/// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of
/// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In
/// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's
/// geometric box placed by its transform, grown by its own strokes.
pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.visual(Some(symbols))
}
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
/// given.
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
let clip = children.first()?;
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
clip.visual_bounds()
clip.visual(symbols)
} else {
clip.geometric_bounds()
clip.geometric(symbols)
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
@@ -1058,25 +1108,41 @@ impl Node {
children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)))
.map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
let o = crate::live::max_outset(children);
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
let o = crate::live::max_outset(children, symbols);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
}
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
let o = crate::live::max_outset(content);
self.geometric_bounds().map(|b| b.inflate(o, o))
let o = crate::live::max_outset(content, symbols);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let b = self.geometric_bounds()?;
let b = self.geometric(symbols)?;
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
}
NodeKind::SymbolInstance { symbol, xf } => {
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
Some((s, art)) => {
let placed = xf.transform_rect_bbox(art.geometric);
match s.placed_visual(symbol, *xf) {
Some(v) => v.union(placed),
None => placed.inflate(art.reach, art.reach),
}
}
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
};
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
_ => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
self.geometric(symbols).map(|b| b.inflate(o, o))
}
}
}
+26 -8
View File
@@ -5,7 +5,7 @@
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
/// Angles closer than this (degrees) are the same.
const EPS_DEG: f64 = 1e-6;
@@ -86,11 +86,12 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
impl Node {
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it.
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
/// without changing it, symbol instances measured by their art from `symbols`
/// ([`Node::geometric_bounds_with`]).
pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: false } => {
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
// The group's own strokes paint around its members (as in `visual_bounds`).
let o = if visual { self.appearance.outset() } else { 0.0 };
b.map(|b| b.inflate(o, o))
@@ -98,12 +99,12 @@ impl Node {
NodeKind::Group { children, clip: true } => {
let clip = children.first()?;
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
clip.bounds_in(a, visual && stroked)
clip.bounds_in(symbols, a, visual && stroked)
}
_ => {
let mut n = self.clone();
n.transform(a, false);
if visual { n.visual_bounds() } else { n.geometric_bounds() }
if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) }
}
}
}
@@ -117,14 +118,16 @@ impl Document {
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
}
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
/// geometric bounds, or `visual` with their strokes.
/// geometric bounds, or `visual` with their strokes, symbol instances measured by their art
/// ([`Document::bounds_of`]).
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
let angle = self.bbox_angle(ids);
if angle == 0.0 {
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
let symbols = self.symbol_boxes();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?;
Some(OrientedBox { angle, rect })
}
}
@@ -192,4 +195,19 @@ mod tests {
d.move_node(id, Some(g), 0).unwrap();
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
}
#[test]
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
let b = d.oriented_bounds(&[id], false).unwrap();
assert!(close(b.angle, 30.0));
assert!(close(b.rect.width(), 40.0) && close(b.rect.height(), 10.0), "the bar, not the 20 × 20 square: {b:?}");
assert!(b.center().distance(c) < 1e-9);
}
}
+6 -2
View File
@@ -516,8 +516,12 @@ impl RepeatSpec {
}
/// Geometric bounds of all instances.
pub fn bounds(&self) -> Option<Rect> {
let b = self.source_bounds()?;
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
self.copies_bounds(self.source_bounds()?)
}
/// The bounds of all instances of a source measuring `source`, each placed by its transform
/// ([`Self::transforms`]).
pub(crate) fn copies_bounds(&self, source: Rect) -> Option<Rect> {
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
+565
View File
@@ -0,0 +1,565 @@
//! Symbol instances measured by their symbol's art: [`SymbolBoxes`] looks each symbol's art up in
//! the document and measures it once, and an instance's bounds are its art's box placed by the
//! instance's transform ([`Node::geometric_bounds_with`]).
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use vectorcraft_geom::{Affine, Rect};
use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
/// The box a symbol instance is measured by when its symbol's art has no box
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
/// that symbols made in the app keep their art in.
pub(crate) const SYMBOL_SQUARE: Rect = Rect::new(-SYMBOL_HALF, -SYMBOL_HALF, SYMBOL_HALF, SYMBOL_HALF);
/// Instances of a symbol whose art holds symbols nested this many deep are measured by
/// [`SYMBOL_SQUARE`] instead of their art.
const MAX_DEPTH: usize = 32;
/// Most effect reaches one set of boxes measures for transforms that place the art of symbols
/// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]).
const MAX_REACHES: usize = 4096;
/// A symbol's art measured in symbol space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ArtBox {
/// The art's geometric bounds.
pub(crate) geometric: Rect,
/// The art's visual bounds (its strokes included), never smaller than `geometric`.
pub(crate) visual: Rect,
/// The farthest any stroke in the art can paint from its path ([`crate::Appearance::outset`]).
pub(crate) reach: f64,
/// Whether the art, or the art of a symbol an instance in it shows, has a visible effect.
pub(crate) effects: bool,
/// The widest stroke of the objects with a visible brushed stroke in the art, or in the art
/// of a symbol an instance in it shows: `None` when no object there has one.
pub(crate) brush: Option<f64>,
}
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
/// [`Node::visual_bounds_with`] and [`Node::reach_bounds_with`] measure symbol instances by. Each
/// symbol's art is measured the first time an instance of it is measured; make a new one after the
/// symbols change.
pub struct SymbolBoxes<'a> {
doc: &'a Document,
/// The art boxes measured so far, by symbol name (`None` for a symbol whose instances are
/// measured by [`SYMBOL_SQUARE`]).
measured: RefCell<HashMap<String, Option<ArtBox>>>,
/// How deep symbols nest in the art of each symbol checked so far, by symbol name
/// ([`Self::depth`]).
depths: RefCell<HashMap<String, Option<usize>>>,
/// Set while symbol art or a click's box is measured, and always in boxes made by
/// [`Self::quick`]: instances are then measured by their art boxes alone
/// ([`Self::placed_visual`] returns `None`).
quick: Cell<bool>,
/// Set in boxes made by [`Self::with_brush_art`], and cleared while symbol art is measured
/// ([`Self::brush_pad`]).
brush_art: Cell<bool>,
/// Cleared in boxes made by [`Self::without_art_strokes`], and set while symbol art is
/// measured ([`Self::art_strokes`]).
art_strokes: Cell<bool>,
/// The effect reaches measured so far, by symbol name and the bits of the linear part of the
/// transform that places the art ([`Self::effects_reach`]).
reaches: RefCell<HashMap<(String, [u64; 4]), f64>>,
/// How many of them are of symbols whose art holds instances.
holders: Cell<usize>,
/// The farthest effect reach measured so far for each symbol.
farthest: RefCell<HashMap<String, f64>>,
/// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`]
/// ([`Self::unplaced_reach`]).
unplaced: RefCell<HashMap<String, f64>>,
}
impl Document {
/// [`SymbolBoxes`] for this document's symbols, none measured yet.
pub fn symbol_boxes(&self) -> SymbolBoxes<'_> {
SymbolBoxes {
doc: self,
measured: RefCell::default(),
depths: RefCell::default(),
quick: Cell::new(false),
brush_art: Cell::new(false),
art_strokes: Cell::new(true),
reaches: RefCell::default(),
holders: Cell::new(0),
farthest: RefCell::default(),
unplaced: RefCell::default(),
}
}
}
impl<'a> SymbolBoxes<'a> {
/// The document whose symbols these are.
pub fn document(&self) -> &'a Document {
self.doc
}
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
/// art box alone. An instance that scales or turns stroked art then measures a little larger
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it.
pub fn quick(self) -> Self {
self.quick.set(true);
self
}
/// Makes these boxes grow the visual box of each symbol instance whose art holds brushed
/// strokes by as far as their brush art can paint past it ([`Self::brush_pad`]). The visual
/// boxes of the groups, layers and live objects that hold such an instance grow with it.
pub fn with_brush_art(self) -> Self {
self.brush_art.set(true);
self
}
/// Makes these boxes measure the visual box of each symbol instance without the strokes in
/// its art: the art's geometric box placed by the instance's transform, grown by the
/// instance's own strokes. The strokes in the art keep their weight when the instance scales.
/// The visual boxes of the groups, layers and live objects that hold an instance do not include
/// those strokes either.
pub fn without_art_strokes(self) -> Self {
self.art_strokes.set(false);
self
}
/// Whether instances are measured with the strokes in their art: false in boxes made by
/// [`Self::without_art_strokes`], except while symbol art is measured.
pub(crate) fn art_strokes(&self) -> bool {
self.art_strokes.get()
}
/// The art box of the symbol named `name`, with the instances in the art measured by their own
/// art boxes. Returns `None` when [`Self::depth`] is `None` and when the art has no finite
/// bounds: instances of the symbol are then measured by [`SYMBOL_SQUARE`].
pub(crate) fn art(&self, name: &str) -> Option<ArtBox> {
if let Some(b) = self.measured.borrow().get(name) {
return *b;
}
let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten();
self.measured.borrow_mut().insert(name.to_string(), b);
b
}
/// Runs `measure` on the art of the symbol named `name`. Returns `None` when [`Self::depth`]
/// is `None`, without running it.
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
self.depth(name)?;
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
Some(measure(&sym.art))
}
/// The geometric box of the art of the symbol named `name` in symbol space, with the instances
/// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of
/// the symbol are then measured by [`SYMBOL_SQUARE`].
pub fn art_bounds(&self, name: &str) -> Option<Rect> {
self.art(name).map(|a| a.geometric)
}
/// Whether the art of the symbol named `name`, or of a symbol an instance in it shows, has a
/// visible effect (raster or geometry, the object's own or a fill's or stroke's). False when
/// [`Self::art`] is `None`.
pub fn has_effects(&self, name: &str) -> bool {
self.art(name).is_some_and(|a| a.effects)
}
/// How far the effects in the art of the symbol named `name` paint past that art when a
/// transform with the linear part `linear` places it, as `measure` measures it for a linear
/// part. `measure` runs once for each symbol and linear part. For symbols whose art holds
/// instances, it runs [`MAX_REACHES`] times at most for these boxes. After that, a linear part
/// not measured yet takes the reach with no transform, times √2 and the larger of 1 and the
/// linear part's Frobenius norm, or the farthest reach measured for the symbol when that is
/// farther; `measure` runs once more for each of those symbols whose reach with no transform
/// was not measured yet. The estimate holds for effects that paint a fixed distance past the
/// art, or a distance in proportion to its size.
pub fn effects_reach(&self, name: &str, linear: [f64; 4], measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
let key = (name.to_string(), linear.map(f64::to_bits));
if let Some(r) = self.reaches.borrow().get(&key) {
return *r;
}
let holder = self.depth(name) != Some(0);
if holder && self.holders.get() >= MAX_REACHES {
let norm = linear.iter().map(|v| v * v).sum::<f64>().sqrt();
let estimate = self.unplaced_reach(name, measure) * std::f64::consts::SQRT_2 * norm.max(1.0);
return self.farthest.borrow().get(name).map_or(estimate, |f| f.max(estimate));
}
if holder {
self.holders.set(self.holders.get() + 1);
}
let r = measure(linear);
self.reaches.borrow_mut().insert(key, r);
let mut farthest = self.farthest.borrow_mut();
let f = farthest.entry(name.to_string()).or_insert(r);
*f = f.max(r);
r
}
/// [`Self::effects_reach`] with no transform, measured once for each symbol, also past
/// [`MAX_REACHES`].
fn unplaced_reach(&self, name: &str, measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
const IDENTITY: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
if let Some(r) = self.reaches.borrow().get(&(name.to_string(), IDENTITY.map(f64::to_bits))) {
return *r;
}
if let Some(r) = self.unplaced.borrow().get(name) {
return *r;
}
let r = measure(IDENTITY);
self.unplaced.borrow_mut().insert(name.to_string(), r);
r
}
/// The widest stroke of the objects with a visible brushed stroke in the art of the symbol
/// named `name`, or in the art of a symbol an instance in it shows. `None` when no object there
/// has one, and when [`Self::art`] is `None`.
pub fn brush_width(&self, name: &str) -> Option<f64> {
self.art(name)?.brush
}
/// How far the visual box of an instance of the symbol named `name` grows in boxes made by
/// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that
/// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path
/// (`brush_fx::cull_bounds` in vectorcraft-render). 0 in other boxes, while symbol art is
/// measured, and when the art has no brushed stroke.
pub(crate) fn brush_pad(&self, name: &str) -> f64 {
if !self.brush_art.get() {
return 0.0;
}
self.brush_width(name).map_or(0.0, |w| 16.0 * w.max(1.0) + 8.0)
}
/// How deep symbols nest in the art of the symbol named `name`: 0 when the art holds no
/// instance of a symbol in the document, 1 when the symbols its instances show hold none, and
/// so on. Returns `None` when the document has no such symbol, when the symbol or a symbol
/// nested in its art holds an instance of itself at any depth, and when symbols nest
/// [`MAX_DEPTH`] deep: instances of the symbol are then measured by [`SYMBOL_SQUARE`]. The
/// answer depends only on the document's symbols.
fn depth(&self, name: &str) -> Option<usize> {
if let Some(d) = self.depths.borrow().get(name) {
return *d;
}
let mut learned = HashMap::new();
let d = self.find_depth(name, &mut learned);
let mut depths = self.depths.borrow_mut();
depths.extend(learned);
depths.insert(name.to_string(), d);
d
}
/// Finds [`Self::depth`] for the symbol named `name` by walking the symbols its art's instances
/// show, depth first, without recursing. `learned` collects what the walk finds out about the
/// other symbols it reaches: the depth of each one it walks to the end, and `None` for those on
/// the way down to a symbol that holds an instance of itself or nests too deep.
fn find_depth(&self, name: &str, learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
let art = |n: &str| self.doc.symbols.iter().find(|s| s.name == n).map(|s| &s.art);
// The symbols on the way down from `name`: each with the symbols its art shows that are
// still to be walked, and the deepest nesting found under it so far.
let mut path: Vec<(String, Vec<String>, usize)> = vec![(name.to_string(), shown(self.doc, art(name)?), 0)];
loop {
let (_, todo, _) = path.last_mut()?;
let Some(next) = todo.pop() else {
// Everything under it is walked.
let (done, _, d) = path.pop()?;
if d >= MAX_DEPTH {
learned.insert(done, None);
return give_up(&path, learned);
}
learned.insert(done, Some(d));
let Some((_, _, up)) = path.last_mut() else { return Some(d) };
*up = (*up).max(d + 1);
continue;
};
let known = learned.get(&next).copied().or_else(|| self.depths.borrow().get(&next).copied());
match known {
Some(Some(d)) => {
if let Some((_, _, deepest)) = path.last_mut() {
*deepest = (*deepest).max(d + 1);
}
}
// It holds an instance of itself or nests too deep, and so does every symbol on
// the way down to it.
Some(None) => return give_up(&path, learned),
// A symbol that holds an instance of itself.
None if path.iter().any(|(n, ..)| *n == next) => return give_up(&path, learned),
// Symbols nested MAX_DEPTH deep under `name`.
None if path.len() >= MAX_DEPTH => return None,
None => {
if let Some(a) = art(&next) {
let todo = shown(self.doc, a);
path.push((next, todo, 0));
}
}
}
}
}
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
/// its strokes at their own weight, the instances in it measured by their art boxes. `None`
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
/// click's box is measured.
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
if self.quick.get() {
return None;
}
self.measure_art(name, |art| {
let mut art = (**art).clone();
art.transform(xf, false);
self.in_art(|| art.visual(Some(self)))
})
.flatten()
}
/// Runs `f` with instances measured by their art boxes alone.
pub(crate) fn quickly<R>(&self, f: impl FnOnce() -> R) -> R {
let was = self.quick.replace(true);
let r = f();
self.quick.set(was);
r
}
/// Runs `f` as symbol art is measured: instances measured by their art boxes alone, with the
/// strokes in their art, and without [`Self::brush_pad`], which the instance that shows the
/// art adds once.
fn in_art<R>(&self, f: impl FnOnce() -> R) -> R {
let pad = self.brush_art.replace(false);
let strokes = self.art_strokes.replace(true);
let r = self.quickly(f);
self.brush_art.set(pad);
self.art_strokes.set(strokes);
r
}
}
/// Records every symbol on `path` in `learned` as one whose instances are measured by
/// [`SYMBOL_SQUARE`]. Returns `None`.
fn give_up(path: &[(String, Vec<String>, usize)], learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
for (n, ..) in path {
learned.insert(n.clone(), None);
}
None
}
/// The names of the symbols of `doc` that the instances in `art` show, each once.
fn shown(doc: &Document, art: &Node) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = vec![];
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& seen.insert(symbol.as_str())
&& doc.symbols.iter().any(|s| s.name == *symbol)
{
names.push(symbol.clone());
}
});
names
}
/// Measures symbol art `art`, with the instances in it measured by `symbols`.
fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
let geometric = art.geometric(Some(symbols)).filter(finite)?;
let visual = art.visual(Some(symbols)).filter(finite).map_or(geometric, |v| v.union(geometric));
let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols));
let mut effects = false;
let mut brush: Option<f64> = None;
art.walk(&mut |n| {
let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter()));
effects |= own.any(|e| e.visible);
let strokes = || n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None });
if strokes().any(|s| s.visible && s.brush.is_some()) {
brush = Some(strokes().fold(brush.unwrap_or(0.0), |w, s| w.max(s.width)));
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
effects |= symbols.has_effects(symbol);
if let Some(w) = symbols.brush_width(symbol) {
brush = Some(brush.map_or(w, |b| b.max(w)));
}
}
});
Some(ArtBox { geometric, visual, reach, effects, brush })
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::shapes;
use super::*;
use crate::{Appearance, Symbol};
/// A rectangle filled black, with a `stroke` pt black stroke (0: none).
fn rect(d: &mut Document, r: Rect, stroke: f64) -> Node {
let paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None };
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), paint, stroke))
}
fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Node {
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
}
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
}
#[test]
fn an_instance_measures_as_its_art_placed_by_its_transform() {
let mut d = Document::new(200.0, 200.0);
let bar = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 10.0), 2.0);
symbol(&mut d, "Bar", vec![bar]);
let moved = instance(&mut d, "Bar", Affine::translate((20.0, 30.0)));
let scaled = instance(&mut d, "Bar", Affine::scale(3.0));
let s = d.symbol_boxes();
assert_eq!(moved.geometric_bounds_with(&s), Some(Rect::new(20.0, 30.0, 40.0, 40.0)));
// Without the document: the 20 × 20 square around the origin, moved.
assert_eq!(moved.geometric_bounds(), Some(Rect::new(10.0, 20.0, 30.0, 40.0)));
// The 2 pt stroke paints 1 pt around the bar.
assert_eq!(moved.visual_bounds_with(&s), Some(Rect::new(19.0, 29.0, 41.0, 41.0)));
assert_eq!(moved.reach_bounds_with(&s), moved.visual_bounds_with(&s));
// Scaled 3 times: the geometry scales, the stroke keeps its weight.
assert_eq!(scaled.geometric_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0)));
assert_eq!(scaled.visual_bounds_with(&s), Some(Rect::new(-1.0, -1.0, 61.0, 31.0)));
// A click's box: the geometric box grown by the farthest the stroke can reach (a mitered
// corner up to its miter limit).
let reach = d.symbols[0].art.children().unwrap()[0].appearance.outset();
assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach)));
}
#[test]
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
let mut d = Document::new(200.0, 200.0);
let missing = instance(&mut d, "Nowhere", Affine::translate((50.0, 50.0)));
assert_eq!(missing.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
// A symbol whose art holds an instance of itself 100 pt to the right, and a symbol whose
// art holds an instance of that one.
let square = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0);
let inner = instance(&mut d, "Loop", Affine::translate((100.0, 0.0)));
symbol(&mut d, "Loop", vec![square.clone(), inner]);
let looped = instance(&mut d, "Loop", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![square, looped]);
let outer = instance(&mut d, "Loop", Affine::IDENTITY);
let doubled = instance(&mut d, "Loop", Affine::scale(2.0));
let holder = instance(&mut d, "Holder", Affine::translate((50.0, 50.0)));
let s = d.symbol_boxes();
assert_eq!(outer.geometric_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
assert_eq!(outer.visual_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
assert_eq!(doubled.visual_bounds_with(&s), Some(Rect::new(-20.0, -20.0, 20.0, 20.0)));
assert_eq!(holder.geometric_bounds_with(&s), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
}
#[test]
fn nested_instances_measure_by_their_own_symbols_art() {
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
symbol(&mut d, "Dot", vec![dot]);
let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0)));
let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0)));
symbol(&mut d, "Pair", vec![right, below]);
let pair = instance(&mut d, "Pair", Affine::translate((100.0, 100.0)));
assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0)));
}
#[test]
fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() {
// A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to
// the right: symbols nest 39 deep in Link 0's art and 31 deep in Link 8's.
let mut d = Document::new(200.0, 200.0);
for i in 0..40 {
let unit = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
let next = instance(&mut d, &format!("Link {}", i + 1), Affine::translate((1.0, 0.0)));
symbol(&mut d, &format!("Link {i}"), if i < 39 { vec![unit, next] } else { vec![unit] });
}
let link0 = instance(&mut d, "Link 0", Affine::IDENTITY);
let link7 = instance(&mut d, "Link 7", Affine::IDENTITY);
let link8 = instance(&mut d, "Link 8", Affine::IDENTITY);
let (square, chain) = (Some(Rect::new(-10.0, -10.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 32.0, 1.0)));
let s = d.symbol_boxes();
assert_eq!(link0.geometric_bounds_with(&s), square);
assert_eq!(link8.geometric_bounds_with(&s), chain);
assert_eq!(link7.geometric_bounds_with(&s), square);
let s = d.symbol_boxes();
assert_eq!(link8.geometric_bounds_with(&s), chain);
assert_eq!(link0.geometric_bounds_with(&s), square);
}
#[test]
fn brushed_strokes_are_found_in_the_art_and_in_nested_symbols_art() {
let mut d = Document::new(200.0, 200.0);
let mut line = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 3.0);
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
symbol(&mut d, "Brushed", vec![line]);
let plain = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 5.0);
let inner = instance(&mut d, "Brushed", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![plain.clone(), inner]);
symbol(&mut d, "Plain", vec![plain]);
let s = d.symbol_boxes();
assert_eq!(s.brush_width("Brushed"), Some(3.0));
assert_eq!(s.brush_width("Holder"), Some(3.0), "the 5 pt stroke has no brush");
assert_eq!(s.brush_width("Plain"), None);
}
#[test]
fn boxes_with_brush_art_grow_instances_of_brushed_art_once() {
let mut d = Document::new(200.0, 200.0);
// A line from (0, 40) to (0, 60) with a 1 pt brushed stroke, and a symbol whose art holds an
// instance of it.
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
symbol(&mut d, "Nib", vec![line]);
let inner = instance(&mut d, "Nib", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![inner]);
let nib = instance(&mut d, "Nib", Affine::translate((-5.0, 0.0)));
let holder = instance(&mut d, "Holder", Affine::translate((-5.0, 0.0)));
let group = Node::group(d.alloc_id(), vec![Arc::new(nib.clone())]);
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(crate::RepeatSpec::mirror(vec![Arc::new(nib.clone())], 90.0, 10.0)));
let (plain, brushed) = (d.symbol_boxes(), d.symbol_boxes().with_brush_art());
// 16 × 1 + 8 for the 1 pt stroke.
for n in [&nib, &holder, &group, &repeat] {
let b = n.visual_bounds_with(&plain).unwrap();
assert_eq!(n.visual_bounds_with(&brushed), Some(b.inflate(24.0, 24.0)), "{}", n.kind_label());
}
assert_eq!(nib.geometric_bounds_with(&brushed), nib.geometric_bounds_with(&plain));
}
#[test]
fn effect_reaches_are_measured_once_for_each_linear_part_and_up_to_a_limit() {
// `Leaf` holds a square; `Holder` and `Other` each hold an instance of `Leaf`.
let mut d = Document::new(10.0, 10.0);
let square = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
symbol(&mut d, "Leaf", vec![square]);
for name in ["Holder", "Other"] {
let leaf = instance(&mut d, "Leaf", Affine::IDENTITY);
symbol(&mut d, name, vec![leaf]);
}
let s = d.symbol_boxes();
let runs = &Cell::new(0);
// A measure that finds `reach` for every linear part.
let measure = |reach: f64| {
move |_: [f64; 4]| {
runs.set(runs.get() + 1);
reach
}
};
let unscaled = [1.0, 0.0, 0.0, 1.0];
assert_eq!(s.effects_reach("Holder", unscaled, &measure(3.0)), 3.0);
assert_eq!(s.effects_reach("Holder", unscaled, &measure(5.0)), 3.0, "measured once");
assert_eq!(runs.get(), 1);
for i in 1..MAX_REACHES {
s.effects_reach("Holder", [1.0, 0.0, 0.0, 1.0 + i as f64], &measure(1.0));
}
assert_eq!(runs.get(), MAX_REACHES);
// Past the limit: the reach with no transform, times √2 and the larger of 1 and the
// Frobenius norm (√8 for a scale of 2, √2 with no transform, √0.5 for a scale of 0.5). That
// reach is measured once more for a symbol not measured before.
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(near(s.effects_reach("Holder", [2.0, 0.0, 0.0, 2.0], &measure(9.0)), 12.0));
assert!(near(s.effects_reach("Other", unscaled, &measure(9.0)), 18.0));
assert!(near(s.effects_reach("Other", [0.5, 0.0, 0.0, 0.5], &measure(7.0)), 9.0 * std::f64::consts::SQRT_2));
assert_eq!(runs.get(), MAX_REACHES + 1);
// A symbol whose art holds no instance is measured past the limit too.
assert_eq!(s.effects_reach("Leaf", [2.0, 0.0, 0.0, 2.0], &measure(7.0)), 7.0);
assert_eq!(runs.get(), MAX_REACHES + 2);
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let n = doc.node(id).ok_or(EngineError::NoNode(id))?;
opts.compound_member = doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| matches!(p.kind, vectorcraft_doc::NodeKind::Compound { .. }));
if summary { Ok(inspect::node_summary_opts(n, opts)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
}
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
+19 -9
View File
@@ -5,7 +5,7 @@ use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes};
use crate::DocState;
@@ -92,21 +92,29 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping
/// path among them. An artboard's file then holds its own art, not a whole document of artboards.
/// bounds clip them), as DXF's crop leaves them. Symbol instances are measured by their art, and
/// the box of one whose art holds brushed strokes grows by as far as their brush art can paint
/// ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol instance also stays when what
/// the canvas paints for it reaches `rect` ([`vectorcraft_render::painted_bounds`]: the effects in
/// the symbols' art and its own effects). A clipping layer keeps every object, its clipping path
/// among them. An artboard's file then holds its own art, not a whole document of artboards.
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
fn kept(n: &Arc<Node>, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
fn kept(n: &Arc<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
let mut layer = Node::clone(n);
let children = layer.children_mut()?;
*children = children.iter().filter_map(|c| kept(c, rect)).collect();
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
return Some(Arc::new(layer));
}
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
n.visual_bounds().is_none_or(over).then(|| n.clone())
let mut holds_instance = false;
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
}
let symbols = doc.symbol_boxes().with_brush_art();
let mut d = doc.clone();
d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect();
d.layers = doc.layers.iter().filter_map(|l| kept(l, doc, &symbols, rect)).collect();
d
}
@@ -152,10 +160,12 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
}
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
/// named `name`: their visual bounds (`None` when they have none).
/// named `name`: their visual bounds, symbol instances measured by their art (`None` when they
/// have none).
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?;
let symbols = doc.symbol_boxes();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))?;
let mut d = single_artboard(doc, bounds, name);
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
+349 -10
View File
@@ -1406,9 +1406,17 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let g = frame.rect;
// With Use Preview Bounds the values measure the visual box, whose margins around the
// geometric box (left, top, right, bottom) scale with the strokes or stay.
let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g };
let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1];
// geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of
// `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their
// weight when an instance scales, so the rest of the margins stay.
let (v, vs) = if s.prefs.use_preview_bounds {
(d.oriented_bounds(&ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
} else {
(g, g)
};
let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1];
let (m, all) = (margins(vs), margins(v));
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(v, refi);
let mut w = f64_or(p, "width", v.width());
@@ -1420,20 +1428,33 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
w = v.width() * h / v.height();
}
}
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`
// (exact for proportional scales, which scale the visual box alike).
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
// exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the
// scales where it misses.
let strokes = bool_or(p, "strokes", s.prefs.scale_strokes);
let ratio = |size: f64, geo: f64, margin: f64| if geo > 1e-9 { (size - margin) / geo } else { 1.0 };
let f = |sx: f64, sy: f64| if strokes { (sx * sy).abs().sqrt() } else { 1.0 };
let (lx, ly) = (ratio(w, v.width(), 0.0), ratio(h, v.height(), 0.0));
// The strokes' scale `f0`: the size left after the margins in symbols' art, which stay, over
// `vs`, whose margins scale.
let (lx, ly) = (ratio(w - fixed[0] - fixed[2], vs.width(), 0.0), ratio(h - fixed[1] - fixed[3], vs.height(), 0.0));
let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 });
let sx = ratio(w, g.width(), (m[0] + m[2]) * f0);
let sy = ratio(h, g.height(), (m[1] + m[3]) * f0);
// When a size of 0 or more is smaller than the margins and some of them stay, the geometry
// gets no width or height: the visual box is then as small as it can be. The margins in
// symbols' art stay, and so does every margin when strokes do not scale.
let fit = |size: f64, geo: f64, scaling: f64, kept: f64| {
let s = ratio(size, geo, scaling * f0 + kept);
let stays = if strokes { kept } else { scaling + kept };
if s < 0.0 && size >= 0.0 && stays > 0.0 { 0.0 } else { s }
};
let sx = fit(w, g.width(), m[0] + m[2], fixed[0] + fixed[2]);
let sy = fit(h, g.height(), m[1] + m[3], fixed[1] + fixed[3]);
let scale = about(rp, Affine::scale_non_uniform(sx, sy));
// Move the new visual box's reference point to x, y (default: where it was): the geometric
// box's reference point moves with the art, the margin from it to the visual one by `f`.
// box's reference point moves with the art, the margin from it to `vs`'s by `f`, and the
// margin from that one to the visual box's stays.
let gp = vectorcraft_geom::reference_point(g, refi);
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
let sp = vectorcraft_geom::reference_point(vs, refi);
let new_rp = scale * gp + (sp - gp) * f(sx, sy) + (rp - sp);
// x, y place the reference point on the page.
let page = frame.to_doc() * rp;
// When every value given is the current one, the transform is the identity. Computing it can
@@ -1443,9 +1464,327 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
// The scales and the point above can miss the size or the reference point when the margins of
// the objects differ, the members of groups and the objects in symbols' art included, and with
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
// by one ([`Spans::measure`]), and the scales solved for.
let solved = if s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0 {
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
} else {
None
};
let (scale, new_rp) = solved.map_or((scale, new_rp), |(sx, sy, at)| (about(rp, Affine::scale_non_uniform(sx, sy)), at));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
}
/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with
/// symbol instances measured without the strokes in their art
/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]).
fn scaling_box(d: &Document, ids: &[NodeId], frame: &OrientedBox) -> Option<Rect> {
let symbols = d.symbol_boxes().without_art_strokes();
let to_local = frame.to_doc().inverse();
ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| {
let b = if frame.angle == 0.0 { n.visual_bounds_with(&symbols) } else { n.bounds_in(&symbols, to_local, true) };
vectorcraft_geom::union_opt(acc, b)
})
}
/// One axis of an object that [`set_bounds`] sizes, square to its frame: the geometric box runs
/// from `lo` to `hi`, and the visual box reaches past each end (before, after) by a margin that
/// scales with the strokes and one that stays.
#[derive(Clone, Copy, Debug)]
struct Span {
lo: f64,
hi: f64,
scaling: [f64; 2],
kept: [f64; 2],
}
impl Span {
/// The ends of the visual box after a scale by `s` about `c`, with the margins that scale
/// grown by `k`. A negative scale turns the box over, and its margins with it.
fn scaled(self, c: f64, s: f64, k: f64) -> (f64, f64) {
let ([sb, sa], [kb, ka]) = (self.scaling, self.kept);
let (before, after) = (sb * k + kb, sa * k + ka);
let (a, b) = (c + s * (self.lo - c), c + s * (self.hi - c));
if s < 0.0 { (b - after, a + before) } else { (a - before, b + after) }
}
}
/// The objects that [`set_bounds`] sizes, measured one by one square to its frame.
struct Spans {
x: Vec<Span>,
y: Vec<Span>,
/// Whether strokes scale. The margins that scale grow by the square root of the product of the
/// two scales; with strokes that do not scale, every margin stays.
strokes: bool,
}
impl Spans {
/// The objects of `ids` that have a box, measured one by one as [`Document::oriented_bounds`]
/// measures them square to `frame`, each plain group member by member and each symbol instance
/// that [`open`] opens object by object in its art. Past [`MAX_SPANS`] parts, each object of
/// `ids` is measured as one. The margins of the visual box without the strokes in symbols' art
/// ([`scaling_box`]) scale with the strokes, and the rest stay. With `strokes` false, every
/// margin stays.
fn measure(d: &Document, ids: &[NodeId], frame: &OrientedBox, strokes: bool) -> Self {
let (symbols, without, art) = (d.symbol_boxes(), d.symbol_boxes().without_art_strokes(), d.symbol_boxes().quick());
let to_local = frame.to_doc().inverse();
let measure = |n: &Node, s: &vectorcraft_doc::SymbolBoxes, visual: bool| match (frame.angle == 0.0, visual) {
(true, false) => n.geometric_bounds_with(s),
(true, true) => n.visual_bounds_with(s),
(false, _) => n.bounds_in(s, to_local, visual),
};
let roots: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect();
let mut parts = vec![];
if !roots.iter().all(|n| open(n, 0.0, d, &symbols, to_local, &mut parts)) {
parts = roots.iter().map(|n| Part::Object(n, 0.0)).collect();
}
let mut out = Self { x: vec![], y: vec![], strokes };
for part in parts {
let (g, v, vs) = match part {
Part::Object(n, grow) => {
let Some(g) = measure(n, &symbols, false) else { continue };
let v = measure(n, &symbols, true).unwrap_or(g).inflate(grow, grow);
let vs = if strokes { measure(n, &without, true).unwrap_or(g).inflate(grow, grow) } else { g };
(g, v, vs)
}
// Measured as the instance's own box measures its art (`Node::visual_bounds_with`):
// the strokes at their own weight, and the instances in it by their art boxes.
Part::Art(n, xf, grow, kept) => {
let mut placed = n.clone();
placed.transform(xf, false);
let Some(g) = placed.geometric_bounds_with(&art) else { continue };
let v = placed.visual_bounds_with(&art).map_or(g, |v| v.union(g)).inflate(kept + grow, kept + grow);
let vs = if strokes { g.inflate(grow, grow) } else { g };
(g, v, vs)
}
};
out.x.push(Span { lo: g.x0, hi: g.x1, scaling: [g.x0 - vs.x0, vs.x1 - g.x1], kept: [vs.x0 - v.x0, v.x1 - vs.x1] });
out.y.push(Span { lo: g.y0, hi: g.y1, scaling: [g.y0 - vs.y0, vs.y1 - g.y1], kept: [vs.y0 - v.y0, v.y1 - vs.y1] });
}
out
}
/// The visual box after a scale by `sx`, `sy` about `c`.
fn visual(&self, c: Point, sx: f64, sy: f64) -> Option<Rect> {
let k = if self.strokes { (sx * sy).abs().sqrt() } else { 1.0 };
let boxes = self.x.iter().zip(&self.y).map(|(x, y)| {
let ((x0, x1), (y0, y1)) = (x.scaled(c.x, sx, k), y.scaled(c.y, sy, k));
Rect::new(x0, y0, x1, y1)
});
boxes.reduce(|a, b| a.union(b))
}
/// The scales along x and y that give the visual box the size `size` with a scale about `c`,
/// or come as near to it as the margins that stay allow ([`Self::solve`]), and the point that
/// goes to the reference point's place: point `refi` of the visual box they give. Returns
/// `None`, which keeps `closed` (the scales and the point of [`set_bounds`]'s closed form), when
/// the scales of `closed` put point `refi` at the point of `closed` and either give the size
/// `size` or are the scales [`Self::solve`] finds, to within rounding (1e-12 of the largest
/// value compared, or of 1).
fn refit(&self, c: Point, refi: usize, size: [f64; 2], closed: (f64, f64, Point)) -> Option<(f64, f64, Point)> {
let ([w, h], (sx, sy, at)) = (size, closed);
let b = self.visual(c, sx, sy)?;
let p = vectorcraft_geom::reference_point(b, refi);
let tolerance = 1e-12 * [b.x0, b.y0, b.x1, b.y1, w, h, at.x, at.y].iter().fold(1.0, |m: f64, v| m.max(v.abs()));
let near = |a: f64, b: f64| (a - b).abs() <= tolerance;
let placed = near(p.x, at.x) && near(p.y, at.y);
if placed && near(b.width(), w) && near(b.height(), h) {
return None;
}
let (tx, ty) = self.solve(w, h);
let same = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0);
if placed && same(tx, sx) && same(ty, sy) {
return None;
}
let b = self.visual(c, tx, ty)?;
Some((tx, ty, vectorcraft_geom::reference_point(b, refi)))
}
/// The scales of 0 or more along x and y that give the visual box the size `w` × `h`, each
/// found by [`scale_across`]. With strokes that scale, the margins that scale grow by `k`,
/// found where it is the square root of the product of the two scales.
fn solve(&self, w: f64, h: f64) -> (f64, f64) {
let at = |k: f64| (scale_across(&self.x, k, w), scale_across(&self.y, k, h));
if !self.strokes {
return at(1.0);
}
// The scales get smaller as `k` grows, so the square root of their product minus `k` is at
// least 0 at `k` 0 and at most 0 at `k` equal to that root. Bisection finds the `k` between
// the two where it is 0.
let (sx, sy) = at(0.0);
let (mut lo, mut hi) = (0.0, (sx * sy).sqrt());
if hi.is_nan() || hi <= 0.0 {
return (sx, sy);
}
for _ in 0..200 {
let mid = lo + (hi - lo) / 2.0;
if mid <= lo || mid >= hi {
break;
}
let (sx, sy) = at(mid);
if (sx * sy).sqrt() > mid {
lo = mid;
} else {
hi = mid;
}
}
let miss = |k: f64| {
let (sx, sy) = at(k);
((sx * sy).sqrt() - k).abs()
};
at(if miss(lo) < miss(hi) { lo } else { hi })
}
}
/// Most parts [`Spans::measure`] opens the objects it sizes into: past it, each object is one part.
const MAX_SPANS: usize = 4096;
/// What [`Spans::measure`] measures as one span.
enum Part<'a> {
/// An object of the selection, or a member of a plain group in it, and the outset of the
/// strokes of the plain groups around it, which scale with its own.
Object(&'a Node, f64),
/// An object in the art of a symbol instance and the transform that places it square to the
/// frame. The outset of the strokes of the instance and of the plain groups around the instance
/// scales with the strokes; the outset of the strokes of the plain groups around the object in
/// the art stays, as the strokes in the art do.
Art(&'a Node, Affine, f64, f64),
}
/// Adds the parts that [`Spans::measure`] measures `n` by to `parts`, `grow` being the outset of
/// the strokes of the plain groups around it ([`Part::Object`]). A plain group ([`opens`]) adds the
/// parts of each member. A symbol instance without effects or an opacity mask of its own adds the
/// objects of its art, with plain groups there opened too ([`Part::Art`]), when its symbol's art
/// measures it, it is not in perspective, and its transform keeps the art square to the frame (a
/// turned or slanted instance is measured by the box around its turned art box). Anything else is
/// one part. Returns false when the parts pass [`MAX_SPANS`].
fn open<'a>(n: &'a Node, grow: f64, d: &'a Document, symbols: &vectorcraft_doc::SymbolBoxes, to_local: Affine, parts: &mut Vec<Part<'a>>) -> bool {
if opens(n) {
match &n.kind {
NodeKind::Group { children, .. } => {
let grow = grow + n.appearance.outset();
return children.iter().all(|c| open(c, grow, d, symbols, to_local, parts));
}
NodeKind::SymbolInstance { symbol, xf } => {
let xf = to_local * *xf;
if n.projection().is_none()
&& square(xf)
&& symbols.art_bounds(symbol).is_some()
&& let Some(art) = d.symbols.iter().find(|s| s.name == *symbol)
{
return open_art(&art.art, xf, grow + n.appearance.outset(), 0.0, parts);
}
}
_ => {}
}
}
parts.push(Part::Object(n, grow));
parts.len() <= MAX_SPANS
}
/// [`open`] for `n` in symbol art that `xf` places square to the frame: a plain group adds the parts
/// of each member, and anything else, a symbol instance included, is one part. `kept` is the outset
/// of the strokes of the plain groups around `n` in the art, and `grow` that of the instance and the
/// plain groups around it.
fn open_art<'a>(n: &'a Node, xf: Affine, grow: f64, kept: f64, parts: &mut Vec<Part<'a>>) -> bool {
if let NodeKind::Group { children, .. } = &n.kind
&& opens(n)
{
let kept = kept + n.appearance.outset();
return children.iter().all(|c| open_art(c, xf, grow, kept, parts));
}
parts.push(Part::Art(n, xf, grow, kept));
parts.len() <= MAX_SPANS
}
/// Whether [`open`] opens `n`: a group without a clip, a Shaper construction, effects or an opacity
/// mask of its own, or a symbol instance without effects or an opacity mask of its own.
fn opens(n: &Node) -> bool {
let visible = |effects: &[vectorcraft_doc::Effect]| effects.iter().any(|e| e.visible);
let effects = visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible(i.effects()));
let kind = match n.kind {
NodeKind::Group { clip, .. } => !clip && n.extras().shaper.is_none(),
NodeKind::SymbolInstance { .. } => true,
_ => false,
};
kind && !effects && n.extras().mask.is_none()
}
/// Whether `a` takes boxes square to the frame to boxes square to it: it scales, mirrors, turns by a
/// quarter turn and moves, and nothing else, to within rounding.
fn square(a: Affine) -> bool {
let [m0, m1, m2, m3, ..] = a.as_coeffs();
let tolerance = 1e-9 * [m0, m1, m2, m3].iter().fold(0.0, |m: f64, v| m.max(v.abs()));
m1.abs() <= tolerance && m2.abs() <= tolerance || m0.abs() <= tolerance && m3.abs() <= tolerance
}
/// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`,
/// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across
/// together, as the closed form in [`set_bounds`] does, and 0 when the spans reach farther than
/// `size` at the scale 0.
fn scale_across(spans: &[Span], k: f64, size: f64) -> f64 {
let o = spans.iter().map(|s| s.lo).fold(f64::INFINITY, f64::min);
let across = spans.iter().map(|s| s.hi).fold(f64::NEG_INFINITY, f64::max) - o;
// How far the spans reach together after a scale by `s` about `o`, and how fast that changes
// with `s`: the far geometric end minus the near one, of the objects that reach farthest at the
// two ends.
let reach = |s: f64| {
let (mut start, mut end, mut slope_start, mut slope_end) = (f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.0);
for sp in spans {
let (a, b) = sp.scaled(o, s, k);
if a < start {
(start, slope_start) = (a, sp.lo - o);
}
if b > end {
(end, slope_end) = (b, sp.hi - o);
}
}
(end - start, slope_end - slope_start)
};
if across.is_nan() || across <= 1e-9 {
return 1.0;
}
let least = reach(0.0).0;
if least.is_nan() || least > size {
return 0.0;
}
// `reach` is convex in `s` and at most `size` at 0, so it equals `size` at one largest scale.
// Newton's method, started at a larger scale, reaches it one linear piece of `reach` at a
// time; bisection finishes the search when a step makes no progress.
let (mut lo, mut hi) = (0.0, size / across);
for _ in 0..64 {
if reach(hi).0 >= size {
break;
}
hi = 2.0 * hi + 1.0;
}
for _ in 0..64 {
let (r, slope) = reach(hi);
if r <= size {
return hi;
}
let next = hi - (r - size) / slope;
if next.is_nan() || next <= lo || next >= hi {
break;
}
hi = next;
}
for _ in 0..64 {
let mid = lo + (hi - lo) / 2.0;
if mid <= lo || mid >= hi {
break;
}
if reach(mid).0 <= size {
lo = mid;
} else {
hi = mid;
}
}
if size - reach(lo).0 <= reach(hi).0 - size { lo } else { hi }
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no live shape selected, the document and its undo history stay as they are.
+1 -1
View File
@@ -173,7 +173,7 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
// What it paints (its members' effects included), and how far its fills' and strokes' raster
// effects reach beyond that.
let b = vectorcraft_render::painted_bounds(whole)?;
let b = vectorcraft_render::painted_bounds(out, whole)?;
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
let b = b.inflate(reach, reach);
+10 -8
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, WidthProfile};
use crate::Session;
@@ -26,20 +26,21 @@ pub struct SummaryOpts {
pub compound_member: bool,
}
/// Compact tree summary of a node (for `document.inspect`): the whole subtree.
pub fn node_summary(n: &Node) -> Value {
node_summary_opts(n, SummaryOpts::default())
/// Compact tree summary of a node (for `document.inspect`): the whole subtree, symbol instances
/// measured by their art from `symbols`.
pub fn node_summary(n: &Node, symbols: &SymbolBoxes) -> Value {
node_summary_opts(n, SummaryOpts::default(), symbols)
}
/// The same summary with [`SummaryOpts`] applied.
pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> Value {
let mut v = json!({
"id": n.id.0,
"name": n.display_name(),
"kind": n.kind_label(),
"visible": n.visible,
"locked": n.locked,
"bounds": rect_json(n.geometric_bounds()),
"bounds": rect_json(n.geometric_bounds_with(symbols)),
});
if n.opacity < 1.0 {
v["opacity"] = json!(n.opacity);
@@ -114,7 +115,7 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
child_limit: opts.child_limit,
compound_member: matches!(n.kind, NodeKind::Compound { .. }),
};
v["children"] = Value::Array(ch.iter().rev().take(take_n).map(|c| node_summary_opts(c, next)).collect());
v["children"] = Value::Array(ch.iter().rev().take(take_n).map(|c| node_summary_opts(c, next, symbols)).collect());
}
if take_n < total || v.get("children").is_none() {
v["childCount"] = json!(total);
@@ -132,6 +133,7 @@ pub fn document(s: &Session) -> Value {
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
let Some(st) = s.active() else { return Value::Null };
let d = &st.doc;
let symbols = d.symbol_boxes();
json!({
"title": st.title(),
"path": st.path,
@@ -141,7 +143,7 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
"colorMode": format!("{:?}", d.color_mode),
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
// Top of the stack first, like the Layers panel.
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts)).collect::<Vec<_>>(),
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts, &symbols)).collect::<Vec<_>>(),
"currentLayer": st.active_layer.map(|l| l.0),
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
+2
View File
@@ -1949,6 +1949,8 @@ mod tests_swatches;
#[cfg(test)]
mod tests_swatchlib;
#[cfg(test)]
mod tests_symbolbounds;
#[cfg(test)]
mod tests_targeting;
#[cfg(test)]
mod tests_textcombos;
+242
View File
@@ -98,6 +98,248 @@ fn the_w_field_sets_the_visual_width() {
assert!((geometric(&s, id).x0 - 5.0).abs() < 1e-9);
}
#[test]
fn a_size_below_what_a_kept_stroke_adds_gives_the_path_no_width_or_height() {
// The 10 pt stroke adds 10 pt to the width and the height. With strokes kept, a width or height
// of 0 or more below 10 pt gives the path no width or height, and the visual box is the
// stroke's 10 pt: the path does not turn over. A size of 10 pt or more scales the path to it,
// and a negative size turns the path over.
let mut wrong = vec![];
for (key, size, path, visual) in [
("width", 5.0, 0.0, 10.0),
("width", 0.0, 0.0, 10.0),
("width", 10.0, 0.0, 10.0),
("width", 30.0, 20.0, 30.0),
("width", -10.0, 20.0, 30.0),
("height", 5.0, 0.0, 10.0),
("height", 0.0, 0.0, 10.0),
("height", 10.0, 0.0, 10.0),
("height", 30.0, 20.0, 30.0),
("height", -10.0, 20.0, 30.0),
] {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 10.0);
preview(&mut s, true);
run(&mut s, "object.setBounds", json!({key: size, "reference": 0, "strokes": false}));
let (g, v) = (geometric(&s, id), s.transform_bounds(&[id]).unwrap());
let (g, v) = if key == "width" { (g.width(), v.width()) } else { (g.height(), v.height()) };
if (g - path).abs() > 1e-9 || (v - visual).abs() > 1e-9 {
wrong.push(format!("{key} {size}: path {g}, visual box {v}"));
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
#[test]
fn set_bounds_gives_one_path_sizes_out_of_proportion_when_strokes_scale() {
// The 100 × 50 rectangle with a 10 pt stroke: its visual box runs from (95, 95) to (205, 155).
// The stroke scales by the square root of the product of the two scales, so the width it adds
// depends on the height too. Set from the top-left corner, the visual box is 220 × 60, and
// 5 × 1000 with the path the same way round.
let mut wrong = vec![];
for (width, height) in [(220.0, 60.0), (5.0, 1000.0)] {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 10.0);
preview(&mut s, true);
run(&mut s, "object.setBounds", json!({"width": width, "height": height, "reference": 0, "strokes": true}));
let v = s.transform_bounds(&[id]).unwrap();
let Some(vectorcraft_doc::LiveShape::Rectangle { xf, .. }) = s.doc().unwrap().doc.node(id).unwrap().live_shape() else {
panic!("the rectangle")
};
if !close(v, Rect::new(95.0, 95.0, 95.0 + width, 95.0 + height)) || xf.as_coeffs()[0] <= 0.0 {
wrong.push(format!("{width} × {height}: {v:?}, {xf:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
fn close(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-9)
}
/// Two rectangles 50 pt high at y 100, both selected with Use Preview Bounds on: one from x 0 to
/// 10 with a 2 pt stroke, which sets the left edge of the paths, and one from x 3 to 10 with a
/// 10 pt stroke, which sets every edge of the visual box, (-2, 95) to (15, 155).
fn thin_and_thick() -> (Session, [NodeId; 2]) {
let mut s = session();
let ids = [rect(&mut s, 0.0, 10.0, 2.0), rect(&mut s, 3.0, 7.0, 10.0)];
preview(&mut s, true);
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
assert!(close(s.transform_bounds(&ids).unwrap(), Rect::new(-2.0, 95.0, 15.0, 155.0)));
(s, ids)
}
#[test]
fn set_bounds_measures_the_margins_on_each_side_object_by_object() {
// Set to 37 wide from the left edge with strokes kept, the paths scale by 3.1, and the left edge
// of the visual box stays at -2.
let (mut s, ids) = thin_and_thick();
run(&mut s, "object.setBounds", json!({"width": 37, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&ids).unwrap();
assert!(close(v, Rect::new(-2.0, 95.0, 35.0, 155.0)), "{v:?}");
assert!(close(geometric(&s, ids[0]), Rect::new(-1.0, 100.0, 30.0, 150.0)), "{:?}", geometric(&s, ids[0]));
assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 150.0)), "{:?}", geometric(&s, ids[1]));
}
#[test]
fn set_bounds_scales_both_ways_at_once_when_strokes_scale_and_the_sizes_are_out_of_proportion() {
// Each stroke scales by the square root of the product of the two scales, so the width it
// adds depends on the height too. The visual box is 40 × 100.
let (mut s, ids) = thin_and_thick();
run(&mut s, "object.setBounds", json!({"width": 40, "height": 100, "reference": 0, "strokes": true}));
let v = s.transform_bounds(&ids).unwrap();
assert!(close(v, Rect::new(-2.0, 95.0, 38.0, 195.0)), "{v:?}");
}
#[test]
fn set_bounds_measures_paths_turned_together_square_to_their_box() {
// Turned 30° together, the paths are measured square to their turned box: it is 37 wide, and
// its top-left corner stays where it was.
let (mut s, ids) = thin_and_thick();
run(&mut s, "object.rotate", json!({"angle": 30}));
let before = s.transform_box(&ids).unwrap();
run(&mut s, "object.setBounds", json!({"width": 37, "reference": 0, "strokes": false}));
let after = s.transform_box(&ids).unwrap();
assert!((after.angle - 30.0).abs() < 1e-9 && (after.rect.width() - 37.0).abs() < 1e-9, "{after:?}");
assert!((after.rect.height() - 60.0).abs() < 1e-9, "{after:?}");
assert!(after.reference_point(0).distance(before.reference_point(0)) < 1e-9, "{before:?} {after:?}");
}
#[test]
fn a_size_below_what_the_kept_strokes_of_several_paths_add_gives_them_no_width() {
// With no width, the paths have a visual box 10 pt wide, the width of the thick stroke. A width
// of 0 or more below 10 gives both paths no width, and the left edge of the visual box stays at
// -2. A negative width turns the paths over, as it does one path.
let mut wrong = vec![];
for (width, paths, visual) in [
(8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)),
(0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)),
(-37.0, [Rect::new(-44.0, 100.0, 0.0, 150.0), Rect::new(-44.0, 100.0, -13.2, 150.0)], Rect::new(-49.0, 95.0, 1.0, 155.0)),
] {
let (mut s, ids) = thin_and_thick();
run(&mut s, "object.setBounds", json!({"width": width, "reference": 0, "strokes": false}));
let (g, v) = (ids.map(|id| geometric(&s, id)), s.transform_bounds(&ids).unwrap());
if !close(g[0], paths[0]) || !close(g[1], paths[1]) || !close(v, visual) {
wrong.push(format!("width {width}: paths {g:?}, visual box {v:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
#[test]
fn set_bounds_leaves_the_height_of_lines_with_no_height() {
// Horizontal lines at y 100 with square caps, from x 0 to 10 with a 2 pt stroke and from x 3 to
// 10 with a 10 pt stroke: the visual box is (-2, 95) to (15, 105). They have no height to
// scale, so the height stays 10; the width becomes 37 from the left edge.
let mut s = session();
let mut ids = vec![];
for (x, weight) in [(0.0, 2.0), (3.0, 10.0)] {
ids.push(NodeId(run(&mut s, "shape.line", json!({"x1": x, "y1": 100, "x2": 10, "y2": 100}))["id"].as_u64().unwrap()));
run(&mut s, "stroke.set", json!({"weight": weight, "cap": "square"}));
}
preview(&mut s, true);
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
assert!(close(s.transform_bounds(&ids).unwrap(), Rect::new(-2.0, 95.0, 15.0, 105.0)), "{:?}", s.transform_bounds(&ids));
run(&mut s, "object.setBounds", json!({"width": 37, "height": 30, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&ids).unwrap();
assert!(close(v, Rect::new(-2.0, 95.0, 35.0, 105.0)), "{v:?}");
assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1]));
}
/// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it
/// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the
/// selected objects.
fn grouped(nested: bool, plain: bool) -> (Session, Vec<NodeId>) {
let (mut s, [thin, thick]) = thin_and_thick();
let mut members = vec![thin.0, thick.0];
if nested {
run(&mut s, "select.set", json!({"ids": [thick.0]}));
members[1] = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap();
}
run(&mut s, "select.set", json!({"ids": members}));
let mut ids = vec![NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap())];
if plain {
ids.push(rect(&mut s, 100.0, 10.0, 0.0));
}
run(&mut s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
(s, ids)
}
#[test]
fn set_bounds_measures_the_members_of_groups_one_by_one() {
// With the unstroked rectangle, the visual box runs from (-2, 95) to (110, 155). Set to 224 wide
// from its left edge, it is 224 wide and its left edge stays at -2, with strokes kept or
// scaling, and with the thick rectangle in a group of its own. The group alone, (-2, 95) to
// (15, 155), set to 37 wide is 37 wide.
let mut wrong = vec![];
for (nested, plain, width, strokes) in
[(false, true, 224.0, false), (false, true, 224.0, true), (true, true, 224.0, false), (false, false, 37.0, false)]
{
let (mut s, ids) = grouped(nested, plain);
run(&mut s, "object.setBounds", json!({"width": width, "reference": 0, "strokes": strokes}));
let v = s.transform_bounds(&ids).unwrap();
if !close(v, Rect::new(-2.0, 95.0, width - 2.0, 155.0)) {
wrong.push(format!("nested {nested}, plain {plain}, strokes {strokes}: {v:?}"));
}
}
// The group with a 1 pt stroke of its own, which reaches 5 pt at its miter limit of 10, around
// each member: the visual box runs from (-7, 90) to (110, 160). Set to 224 wide from its left
// edge, it is 224 wide and its left edge stays at -7, with strokes kept or scaling.
for strokes in [false, true] {
let (mut s, _) = thin_and_thick();
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addStroke", json!({"ids": [group.0]}));
let ids = [group, rect(&mut s, 100.0, 10.0, 0.0)];
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
assert!(close(s.transform_bounds(&ids).unwrap(), Rect::new(-7.0, 90.0, 110.0, 160.0)), "{:?}", s.transform_bounds(&ids));
run(&mut s, "object.setBounds", json!({"width": 224, "reference": 0, "strokes": strokes}));
let v = s.transform_bounds(&ids).unwrap();
if !close(v, Rect::new(-7.0, 90.0, 217.0, 160.0)) {
wrong.push(format!("the group with a stroke, strokes {strokes}: {v:?}"));
}
}
// A group of a rectangle from x 0 to 100 with a 2 pt stroke and one from x 40 to 60 with a 10 pt
// stroke: its visual box, (-1, 95) to (101, 155), set to 11 wide is 11 wide.
let mut s = session();
let ids = [rect(&mut s, 0.0, 100.0, 2.0), rect(&mut s, 40.0, 20.0, 10.0)];
preview(&mut s, true);
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "object.setBounds", json!({"width": 11, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[group]).unwrap();
if !close(v, Rect::new(-1.0, 95.0, 10.0, 155.0)) {
wrong.push(format!("the 0 to 100 and 40 to 60 group: {v:?}"));
}
assert!(wrong.is_empty(), "{wrong:?}");
}
#[test]
fn set_bounds_measures_clip_groups_and_groups_with_effects_as_one_object() {
// A clip group is measured by its clipping path, a rectangle from x 0 to 10, though its content
// with a 10 pt stroke reaches past it: with the unstroked rectangle from x 100 to 110, set to
// 224 wide from the left edge, the visual box runs from x 0 to 224.
let mut s = session();
let content = rect(&mut s, 3.0, 17.0, 10.0);
let path = rect(&mut s, 0.0, 10.0, 0.0);
run(&mut s, "select.set", json!({"ids": [content.0, path.0]}));
let clip = NodeId(run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap());
let plain = rect(&mut s, 100.0, 10.0, 0.0);
preview(&mut s, true);
run(&mut s, "select.set", json!({"ids": [clip.0, plain.0]}));
run(&mut s, "object.setBounds", json!({"width": 224, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[clip, plain]).unwrap();
assert!(close(v, Rect::new(0.0, 100.0, 224.0, 150.0)), "the clip group: {v:?}");
// A group with a drop shadow of its own is measured as one object, as before. Its 2 pt margin on
// the left is measured from the group's left edge, though the stroke of the thick rectangle at
// x 3 paints it, so the visual box is 223 wide and its left edge moves to -1.
let (mut s, ids) = grouped(false, true);
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [ids[0].0]}));
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
run(&mut s, "object.setBounds", json!({"width": 224, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&ids).unwrap();
assert!(close(v, Rect::new(-1.0, 95.0, 222.0, 155.0)), "the group with a shadow: {v:?}");
}
#[test]
fn the_bounding_box_handles_sit_on_the_preview_bounds() {
// Drag from the visual right edge (10 pt outside the path) 100 pt to the right.
+415
View File
@@ -0,0 +1,415 @@
//! Symbol instances measured by their symbol's art: Align, the Transform panel and the bounding
//! box, Use Preview Bounds, Fit to Artwork Bounds, EPS export of the art, an artboard's SVG, Export
//! Selection and the raster effect images of exports.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symbol};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use super::*;
/// The symbol of the SVG import tests: a circle of radius 4 around (5, 5) with a 2 pt stroke, and
/// an instance of it at x 80.
const DOT: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Dot"><circle cx="5" cy="5" r="4" stroke="#000" stroke-width="2"/></symbol></defs><use href="#Dot" x="80" fill="red"/></svg>"##;
/// A symbol whose art, a red rectangle from (100, 60) to (140, 80), lies outside the 20 × 20
/// square around the origin, and an instance of it.
const FAR: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Far"><rect x="100" y="60" width="40" height="20" fill="#f00"/></symbol></defs><use href="#Far"/></svg>"##;
/// The same symbol with its instance moved by (-50, -50): the art, (50, 10) to (90, 30), lies on
/// the artboard, and the 20 × 20 square, (-60, -60) to (-40, -40), lies off it.
const FAR_MOVED: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Far"><rect x="100" y="60" width="40" height="20" fill="#f00"/></symbol></defs><use href="#Far" x="-50" y="-50"/></svg>"##;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// `svg` opened, and its symbol instance.
fn open(svg: &str) -> (Session, NodeId) {
let mut s = Session::new();
run(&mut s, "document.open", json!({"name": "symbols.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}));
let mut found = None;
s.doc().unwrap().doc.walk(|n| {
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
found = Some(n.id);
}
});
(s, found.expect("a symbol instance"))
}
fn close(a: Option<Rect>, b: Rect) -> bool {
a.is_some_and(|a| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-9))
}
#[test]
fn align_left_puts_the_dot_instances_circle_on_the_rectangles_edge() {
let (mut s, dot) = open(DOT);
// The Transform panel and the bounding box measure the circle, (81, 1) to (89, 9), not the
// 20 × 20 square, (70, -10) to (90, 10).
assert!(close(s.transform_bounds(&[dot]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{:?}", s.transform_bounds(&[dot]));
assert!(close(s.transform_box(&[dot]).map(|b| b.rect), Rect::new(81.0, 1.0, 89.0, 9.0)));
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 40, "width": 20, "height": 10}));
run(&mut s, "select.all", json!({}));
run(&mut s, "object.align", json!({"horizontal": "left"}));
let NodeKind::SymbolInstance { xf, .. } = &s.doc().unwrap().doc.node(dot).unwrap().kind else { panic!("the instance") };
// The circle's left edge, 1 pt right of where the instance draws the art's (0, 0), is at x 0.
assert!(((*xf * Point::ZERO).x + 1.0).abs() < 1e-9, "{xf:?}");
}
#[test]
fn document_inspect_reports_the_instances_art_bounds() {
let (mut s, dot) = open(DOT);
let rect = |v: &Value| {
Some(Rect::new(v["x"].as_f64()?, v["y"].as_f64()?, v["x"].as_f64()? + v["width"].as_f64()?, v["y"].as_f64()? + v["height"].as_f64()?))
};
let node = run(&mut s, "document.node", json!({"id": dot.0, "summary": true}));
assert!(close(rect(&node["bounds"]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{node}");
run(&mut s, "select.all", json!({}));
let doc = run(&mut s, "document.inspect", json!({}));
assert!(close(rect(&doc["selectionBounds"]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{doc}");
let layer = &doc["layers"][0];
assert!(close(rect(&layer["children"][0]["bounds"]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{layer}");
}
#[test]
fn use_preview_bounds_takes_in_the_strokes_inside_the_symbol() {
let (mut s, dot) = open(DOT);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
// The 2 pt stroke paints 1 pt around the circle.
assert!(close(s.transform_bounds(&[dot]), Rect::new(80.0, 0.0, 90.0, 10.0)), "{:?}", s.transform_bounds(&[dot]));
}
#[test]
fn set_bounds_with_use_preview_bounds_keeps_the_strokes_in_the_symbol_at_their_weight() {
// The circle's visual box, (80, 0) to (90, 10), set to 20 × 20 around its center and from its
// top-left corner: the stroke still paints 1 pt around the circle, whether strokes scale or not.
for strokes in [true, false] {
for (reference, want) in [(4, Rect::new(75.0, -5.0, 95.0, 15.0)), (0, Rect::new(80.0, 0.0, 100.0, 20.0))] {
let (mut s, dot) = open(DOT);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [dot.0]}));
run(&mut s, "object.setBounds", json!({"width": 20, "height": 20, "strokes": strokes, "reference": reference}));
let b = s.transform_bounds(&[dot]);
assert!(close(b, want), "strokes {strokes}, reference {reference}: {b:?}");
}
}
// A width of 2 pt, what the stroke adds, or less, with strokes that scale: the circle gets no
// width, and the visual box is the stroke's 2 pt. The instance does not turn over.
for width in [2.0, 1.0, 0.0] {
let (mut s, dot) = open(DOT);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [dot.0]}));
run(&mut s, "object.setBounds", json!({"width": width, "strokes": true}));
let NodeKind::SymbolInstance { xf, .. } = &s.doc().unwrap().doc.node(dot).unwrap().kind else { panic!("the instance") };
assert_eq!(xf.as_coeffs()[0], 0.0, "width {width}: {xf:?}");
let b = s.transform_bounds(&[dot]).unwrap();
assert!((b.width() - 2.0).abs() < 1e-9, "width {width}: {b:?}");
}
// An instance with a 2 pt stroke of its own, which scales, around art with a 2 pt stroke, which
// keeps its weight: doubled, the visual box is twice as wide and high.
let mut d = Document::new(200.0, 120.0);
let stroked = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 2.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)), stroked);
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((50.0, 50.0)) });
*instance.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let before = s.transform_bounds(&[id]).unwrap();
run(&mut s, "object.setBounds", json!({"width": 2.0 * before.width(), "height": 2.0 * before.height(), "strokes": true}));
let after = s.transform_bounds(&[id]).unwrap();
assert!((after.width() - 2.0 * before.width()).abs() < 1e-9 && (after.height() - 2.0 * before.height()).abs() < 1e-9, "{before:?} {after:?}");
// A symbol of an 8 × 8 square with a 2 pt round-joined stroke, and an instance of it at
// (50, 50) as the one copy of a one-by-one grid repeat. With strokes that scale,
// `object.setBounds` sets the repeat's visual box, (49, 49) to (59, 59), to 20 × 20 around its
// center, (44, 44) to (64, 64), and the stroke in the symbol keeps its weight.
let mut d = Document::new(200.0, 120.0);
let mut round = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
round.stroke_mut().unwrap().join = vectorcraft_doc::LineJoin::Round;
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)), round);
d.symbols.push(Symbol { name: "Ring".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Ring".into(), xf: Affine::translate((50.0, 50.0)) });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let repeat = NodeId(run(&mut s, "object.repeat.grid", json!({"rows": 1, "cols": 1}))["id"].as_u64().expect("the repeat"));
run(&mut s, "select.set", json!({"ids": [repeat.0]}));
assert!(close(s.transform_bounds(&[repeat]), Rect::new(49.0, 49.0, 59.0, 59.0)), "{:?}", s.transform_bounds(&[repeat]));
run(&mut s, "object.setBounds", json!({"width": 20, "height": 20, "strokes": true}));
let b = s.transform_bounds(&[repeat]);
assert!(close(b, Rect::new(44.0, 44.0, 64.0, 64.0)), "the repeat: {b:?}");
}
#[test]
fn set_bounds_sizes_an_instance_and_a_path_whose_strokes_differ() {
// An instance at (50, 50) of a 10 × 10 square with a 10 pt stroke, which keeps its weight, and a
// path of the same square there with a 2 pt stroke, which scales. Setting their visual box,
// (45, 45) to (65, 65), to 40 × 40 around its center gives (35, 35) to (75, 75): the instance's
// stroke still sets every edge.
let mut d = Document::new(200.0, 120.0);
let square = |d: &mut Document, at: f64, weight: f64| {
let stroked = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight);
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(at, at, at + 10.0, at + 10.0)), stroked)
};
let art = square(&mut d, 0.0, 10.0);
d.symbols.push(Symbol { name: "Thick".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Thick".into(), xf: Affine::translate((50.0, 50.0)) });
let path = square(&mut d, 50.0, 2.0);
let (layer, ids) = (d.layers[0].id, [instance.id, path.id]);
d.insert(Some(layer), 0, instance).unwrap();
d.insert(Some(layer), 1, path).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
assert!(close(s.transform_bounds(&ids), Rect::new(45.0, 45.0, 65.0, 65.0)), "{:?}", s.transform_bounds(&ids));
run(&mut s, "object.setBounds", json!({"width": 40, "height": 40, "strokes": true}));
let b = s.transform_bounds(&ids);
assert!(close(b, Rect::new(35.0, 35.0, 75.0, 75.0)), "{b:?}");
}
#[test]
fn set_bounds_measures_the_objects_in_an_instances_art_one_by_one() {
// A symbol whose art is a group of a rectangle from x 0 to 10 with a 2 pt stroke and one from
// x 3 to 10 with a 10 pt stroke, both 50 pt high, and an instance of it 100 pt down, in a group
// when `grouped`, selected with an unstroked rectangle from x 100 to 110 when `plain`. The
// instance's visual box runs from (-2, 95) to (15, 155). With `own`, the art's group and the
// instance each have a 1 pt stroke of their own, which reaches 5 pt at its miter limit of 10.
let selection = |grouped: bool, plain: bool, own: bool| {
let mut d = Document::new(400.0, 400.0);
let stroked = |d: &mut Document, x0: f64, weight: f64| {
Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(x0, 0.0, 10.0, 50.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight),
)
};
let mut art = Node::group(d.alloc_id(), vec![Arc::new(stroked(&mut d, 0.0, 2.0)), Arc::new(stroked(&mut d, 3.0, 10.0))]);
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mixed".into(), xf: Affine::translate((0.0, 100.0)) });
if own {
*art.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
*instance.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
}
d.symbols.push(Symbol { name: "Mixed".into(), art: Arc::new(art) });
let mut roots = vec![if grouped { Node::group(d.alloc_id(), vec![Arc::new(instance)]) } else { instance }];
if plain {
let filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
roots.push(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 110.0, 150.0)), filled));
}
let (layer, ids): (NodeId, Vec<NodeId>) = (d.layers[0].id, roots.iter().map(|n| n.id).collect());
for (i, n) in roots.into_iter().enumerate() {
d.insert(Some(layer), i, n).unwrap();
}
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
(s, ids)
};
// With the unstroked rectangle, the visual box runs from (-2, 95) to (110, 155). Set to 224 wide
// from its left edge, it is 224 wide and its left edge stays at -2, with strokes kept or
// scaling, and with the instance in a group. The instance alone set to 37 wide is 37 wide. With
// the strokes of their own, which paint around each rectangle, the visual box runs from
// (-12, 85) to (110, 165): set to 224 wide, it is 224 wide and its left edge stays at -12.
let mut wrong = vec![];
for (grouped, plain, own, width, strokes) in [
(false, true, false, 224.0, false),
(false, true, false, 224.0, true),
(true, true, false, 224.0, false),
(false, false, false, 37.0, false),
(false, true, true, 224.0, false),
(false, true, true, 224.0, true),
] {
let (mut s, ids) = selection(grouped, plain, own);
let (left, top, bottom) = if own { (-12.0, 85.0, 165.0) } else { (-2.0, 95.0, 155.0) };
let before = s.transform_bounds(&ids);
if !close(before, Rect::new(left, top, if plain { 110.0 } else { 15.0 }, bottom)) {
wrong.push(format!("grouped {grouped}, plain {plain}, own {own}, before: {before:?}"));
}
run(&mut s, "object.setBounds", json!({"width": width, "reference": 0, "strokes": strokes}));
let b = s.transform_bounds(&ids);
if !close(b, Rect::new(left, top, left + width, bottom)) {
wrong.push(format!("grouped {grouped}, plain {plain}, own {own}, strokes {strokes}: {b:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
#[test]
fn set_bounds_measures_an_instance_as_one_object_past_4096_objects_in_its_art() {
// An instance 100 pt down of a symbol whose art is a rectangle from x 0 to 10 with a 2 pt
// stroke, one from x 3 to 10 with a 10 pt stroke, both 50 pt high, and unstroked 1 × 1 squares
// inside them: 4,096 objects, then 4,097. Its visual box runs from (-2, 95) to (15, 155). Set to
// 37 wide from the left edge, it is 37 wide with 4,096 objects. With 4,097 it is measured as one
// object: its 2 pt margin on the left is measured from its left edge, though the stroke of the
// thick rectangle at x 3 paints it, so it is 36 wide and its left edge moves to -1.
for (objects, want) in [(4096, Rect::new(-2.0, 95.0, 35.0, 155.0)), (4097, Rect::new(-1.0, 95.0, 35.0, 155.0))] {
let mut d = Document::new(400.0, 400.0);
let stroked = |d: &mut Document, x0: f64, weight: f64| {
Arc::new(Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(x0, 0.0, 10.0, 50.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight),
))
};
let mut art = vec![stroked(&mut d, 0.0, 2.0), stroked(&mut d, 3.0, 10.0)];
for i in 2..objects {
let (x, y) = (f64::from(4 + i % 5), f64::from(1 + i % 47));
let square = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
art.push(Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)), square)));
}
let art = Node::group(d.alloc_id(), art);
d.symbols.push(Symbol { name: "Many".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Many".into(), xf: Affine::translate((0.0, 100.0)) });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.setBounds", json!({"width": 37, "reference": 0, "strokes": false}));
let b = s.transform_bounds(&[id]);
assert!(close(b, want), "{objects} objects: {b:?}");
}
}
#[test]
fn set_bounds_measures_a_turned_instance_by_the_box_around_its_turned_art_box() {
// A symbol whose art is a filled circle of radius 10 around the origin, and an instance of it
// turned 30° at (100, 100) whose bounding box stands square to the page: the box around the
// art's 20 × 20 box turned 30°, 27.32 wide. Set to 40 wide from its left edge, it is 40 wide.
let mut d = Document::new(400.0, 400.0);
let filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let circle = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(-10.0, -10.0, 10.0, 10.0)), filled);
d.symbols.push(Symbol { name: "Disc".into(), art: Arc::new(circle) });
let xf = Affine::translate((100.0, 100.0)) * Affine::rotate(30f64.to_radians());
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Disc".into(), xf });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let before = s.transform_bounds(&[id]).unwrap();
let turned = 20.0 * (30f64.to_radians().cos() + 30f64.to_radians().sin());
assert!((before.width() - turned).abs() < 1e-9, "{before:?}");
run(&mut s, "object.setBounds", json!({"width": 40, "reference": 0}));
let after = s.transform_bounds(&[id]).unwrap();
assert!((after.width() - 40.0).abs() < 1e-9 && (after.x0 - before.x0).abs() < 1e-9, "{before:?} {after:?}");
}
#[test]
fn fit_to_artwork_bounds_fits_the_instances_art() {
let (mut s, _) = open(DOT);
run(&mut s, "artboard.fitToArt", json!({}));
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!(close(Some(board), Rect::new(80.0, 0.0, 90.0, 10.0)), "{board:?}");
}
#[test]
fn eps_of_the_art_bounds_and_writes_an_instances_art() {
let (mut s, _) = open(FAR);
let r = run(&mut s, "document.export", json!({"format": "eps", "previewFormat": "none", "cmykPostScript": false}));
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let ps = String::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0.to_vec()).unwrap();
let bbox = ps.lines().find_map(|l| l.strip_prefix("%%BoundingBox:")).map(str::trim);
// The art, 60 to 80 down from the artboard's top, is 40 to 60 up from its bottom (120 high).
assert_eq!(bbox, Some("100 40 140 60"), "{ps}");
assert!(ps.contains("1 0 0 rg"), "the art is written: {ps}");
}
#[test]
fn an_artboards_svg_keeps_an_instance_whose_art_is_on_it() {
let (mut s, _) = open(FAR_MOVED);
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "artboard": 0}));
let text = svg["text"].as_str().unwrap();
assert!(text.contains("<use"), "{text}");
}
#[test]
fn an_artboards_svg_keeps_an_instance_whose_brush_art_is_on_it() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt stroke painted by a 20 pt round
// nib. Drawn from (-5, 0), the line lies left of the artboard, and the nib paints 10 pt either
// side of it, onto the artboard.
let mut d = Document::new(200.0, 120.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
d.symbols.push(Symbol { name: "Nib".into(), art: Arc::new(line) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-5.0, 0.0)) })).unwrap();
let mut s = Session::new();
s.add_document(d, None);
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "artboard": 0}));
let text = svg["text"].as_str().unwrap();
assert!(text.contains("<use"), "{text}");
}
#[test]
fn an_artboards_svg_keeps_an_instance_whose_shadow_is_on_it() {
// The symbol's art, a black square from (-1, -1) to (1, 1), drawn from (-5, 20): left of the
// artboard. A drop shadow 8 pt to the right, on the art or on the instance, reaches onto it.
for in_art in [true, false] {
let mut d = Document::new(200.0, 120.0);
let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({"x": 8, "y": 0, "blur": 0}), visible: true };
let square = shapes::rectangle(Rect::new(-1.0, -1.0, 1.0, 1.0));
let mut art = Node::path(d.alloc_id(), square, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) });
if in_art {
art.appearance.effects.push(shadow);
} else {
instance.appearance.effects.push(shadow);
}
d.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "artboard": 0}));
let text = svg["text"].as_str().unwrap();
assert!(text.contains("<use"), "the shadow on the art: {in_art}; {text}");
}
}
#[test]
fn export_selection_crops_to_the_instances_art() {
let (mut s, _) = open(FAR);
run(&mut s, "select.all", json!({}));
let v = run(&mut s, "document.exportSelection", json!({"format": "svg"}));
let bounds: Vec<f64> = v["bounds"].as_array().unwrap().iter().filter_map(Value::as_f64).collect();
// x, y, width and height: the art, not the 20 × 20 square from (-10, -10).
assert_eq!(bounds, [100.0, 60.0, 40.0, 20.0], "{v}");
}
#[test]
fn a_groups_shadow_image_covers_the_art_of_the_instance_in_it() {
let (mut s, far) = open(FAR);
run(&mut s, "select.set", json!({"ids": [far.0]}));
let group = run(&mut s, "object.group", json!({}))["id"].as_u64().expect("the group");
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [group]}));
let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap();
let mut frame = None;
flat.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
frame = Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))));
}
});
let frame = frame.expect("the shadow's image");
assert!(frame.x0 <= 100.0 && frame.y0 <= 60.0 && frame.x1 >= 140.0 && frame.y1 >= 80.0, "{frame:?}");
}
+8 -2
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use kurbo::Shape;
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, ColorMode, Document, Effect, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject,
AppearanceItem, ColorMode, Document, Effect, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject,
};
use vectorcraft_effects::stroke::{self, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
@@ -94,6 +94,11 @@ fn overlaps(a: Rect, b: Rect) -> bool {
pub(crate) struct Scene<'a> {
doc: &'a Document,
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
/// leaves out objects outside the region ([`SymbolBoxes::quick`]). The box of an instance whose
/// art holds brushed strokes grows by as far as their brush art can paint
/// ([`SymbolBoxes::with_brush_art`]).
symbols: SymbolBoxes<'a>,
o: &'a EpsOptions,
/// RGB and Lab colours are written as CMYK.
cmyk: bool,
@@ -114,6 +119,7 @@ impl<'a> Scene<'a> {
pub fn new(doc: &'a Document, o: &'a EpsOptions) -> Self {
Self {
doc,
symbols: doc.symbol_boxes().quick().with_brush_art(),
o,
cmyk: o.cmyk || doc.color_mode == ColorMode::Cmyk,
cms: vectorcraft_color::cms::active(),
@@ -254,7 +260,7 @@ impl<'a> Scene<'a> {
if let NodeKind::Layer { template: true, .. } = n.kind {
return;
}
match n.visual_bounds() {
match n.visual_bounds_with(&self.symbols) {
Some(b) if !force && !overlaps(b.inflate(1.0, 1.0), self.o.region) => return,
None if !n.is_container() => return,
_ => {}
+30
View File
@@ -440,6 +440,36 @@ fn clipping_groups_clip_and_hidden_or_template_art_is_left_out() {
assert!(!page.contains("0 0 1 rg"), "hidden and template art: {page}");
}
#[test]
fn an_instance_whose_art_reaches_the_region_is_written() {
// The symbol's red rectangle, (100, 100) to (140, 140), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let page = page_of(&d, &opts(&d));
assert!(page.contains("1 0 0 rg"), "{page}");
}
#[test]
fn an_instance_whose_brush_art_reaches_the_region_is_written() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
// round nib. Drawn from (-5, 0), the line lies left of the artboard, and the nib paints 10 pt
// either side of it, onto the artboard.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-5.0, 0.0)) })]);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut art = path(&mut d, shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0);
art.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(art) });
let page = page_of(&d, &opts(&d));
assert!(page.contains("1 0 0 rg"), "{page}");
}
#[test]
fn level_preview_and_overprint_ids_read_back() {
for l in Level::ALL {
+17 -3
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use vectorcraft_color::freeform::{painted_box, spread_scale};
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeLayer, SymbolBoxes};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
@@ -100,6 +100,9 @@ fn q(v: f32) -> u8 {
pub(crate) struct Scene<'a> {
doc: &'a Document,
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
/// leaves out objects outside the region ([`SymbolBoxes::quick`]).
symbols: SymbolBoxes<'a>,
region: Rect,
/// The format clips (EMF).
clips: bool,
@@ -116,7 +119,18 @@ pub(crate) struct Scene<'a> {
impl<'a> Scene<'a> {
pub fn new(doc: &'a Document, region: Rect, clips: bool) -> Self {
Self { doc, region, clips, ops: vec![], warnings: vec![], images: HashMap::new(), brushes: None, opacity: 1.0, nest: 0 }
Self {
doc,
symbols: doc.symbol_boxes().quick(),
region,
clips,
ops: vec![],
warnings: vec![],
images: HashMap::new(),
brushes: None,
opacity: 1.0,
nest: 0,
}
}
/// The operations (clipped to the region when the format clips) and the warnings.
@@ -148,7 +162,7 @@ impl<'a> Scene<'a> {
return;
}
if !n.is_layer() {
match n.visual_bounds() {
match n.visual_bounds_with(&self.symbols) {
Some(b) if !overlaps(b, self.region) => return,
None if !n.is_container() => return,
_ => {}
+12
View File
@@ -328,3 +328,15 @@ fn hidden_and_offboard_art_is_left_out() {
assert!(!k.contains(&62) && !k.contains(&39));
assert!(export(&d, Rect::new(0.0, 0.0, 0.0, 10.0), Kind::Emf).is_err(), "an empty region");
}
#[test]
fn an_instance_whose_art_reaches_the_artboard_is_written() {
// The symbol's red rectangle, (100, 100) to (140, 140), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let inst = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) });
let mut d = doc_with(100.0, 100.0, vec![inst]);
let art = path(shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), red(), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let k = kinds(&emf_records(&emf(&d).bytes));
assert!(k.contains(&39), "a brush fills the art: {k:?}");
}
+9 -3
View File
@@ -17,7 +17,7 @@ use krilla::surface::Surface;
use krilla::text::{Glyph, GlyphId, KrillaGlyph};
use kurbo::{PathEl, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject};
use vectorcraft_effects::stroke::{self, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use vectorcraft_text::TextLayout;
@@ -432,6 +432,11 @@ fn date_time(t: i64) -> krilla::metadata::DateTime {
pub(crate) struct Exporter<'a> {
doc: &'a Document,
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
/// leaves out objects outside the page ([`SymbolBoxes::quick`]). The box of an instance whose
/// art holds brushed strokes grows by as far as their brush art can paint
/// ([`SymbolBoxes::with_brush_art`]).
symbols: SymbolBoxes<'a>,
warnings: Vec<String>,
images: HashMap<String, Option<Image>>,
/// The brush library, parsed when the first brushed stroke is written.
@@ -484,6 +489,7 @@ impl<'a> Exporter<'a> {
pub(crate) fn new(doc: &'a Document, set: &'a crate::PdfSettings) -> Self {
Self {
doc,
symbols: doc.symbol_boxes().quick().with_brush_art(),
warnings: vec![],
images: HashMap::new(),
brushes: None,
@@ -907,7 +913,7 @@ impl Exporter<'_> {
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
let elements: Vec<_> = Node::knockout_elements(children)
.into_iter()
.filter(|c| c.visual_bounds().is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
.filter(|c| c.visual_bounds_with(&self.symbols).is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
.collect();
let Some((first, rest)) = elements.split_first() else { return };
for c in rest.iter().rev() {
@@ -971,7 +977,7 @@ impl Exporter<'_> {
{
return;
}
match n.visual_bounds() {
match n.visual_bounds_with(&self.symbols) {
Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return,
None if !n.is_container() => return,
_ => {}
+12 -8
View File
@@ -10,7 +10,7 @@ use kurbo::{Affine, Rect};
use serde::Serialize;
use vectorcraft_doc::marks::outset;
use vectorcraft_doc::range::parse_range;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
use super::{Emulsion, Orientation, PrintArtboards, PrintScaling, PrintSettings};
use crate::PdfError;
@@ -63,8 +63,9 @@ pub struct TileGrid {
}
/// Calls `f` with the bounds of each object of `n` that prints: visible, not on a template
/// layer and not a guide (layers are already picked by the Print Layers option).
fn printed(n: &Node, f: &mut impl FnMut(Rect)) {
/// layer and not a guide (layers are already picked by the Print Layers option). Symbol
/// instances are measured by their art from `symbols`.
fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) {
if !n.visible {
return;
}
@@ -72,31 +73,34 @@ fn printed(n: &Node, f: &mut impl FnMut(Rect)) {
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => {}
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children {
printed(c, f);
printed(c, symbols, f);
}
}
_ => {
if let Some(b) = n.visual_bounds() {
if let Some(b) = n.visual_bounds_with(symbols) {
f(b);
}
}
}
}
/// Is there art that prints in `r`?
/// Is there art that prints in `r`? The box of a symbol instance whose art holds brushed strokes
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]).
fn has_art(doc: &Document, r: Rect) -> bool {
let symbols = doc.symbol_boxes().with_brush_art();
let mut any = false;
for l in &doc.layers {
printed(l, &mut |b| any |= b.intersect(r).area() > 0.0 || r.contains_rect(b));
printed(l, &symbols, &mut |b| any |= b.intersect(r).area() > 0.0 || r.contains_rect(b));
}
any
}
/// The bounds of all the art that prints.
fn art_bounds(doc: &Document) -> Option<Rect> {
let symbols = doc.symbol_boxes();
let mut all: Option<Rect> = None;
for l in &doc.layers {
printed(l, &mut |b| all = Some(all.map_or(b, |a| a.union(b))));
printed(l, &symbols, &mut |b| all = Some(all.map_or(b, |a| a.union(b))));
}
all
}
+48
View File
@@ -133,6 +133,54 @@ fn roundtrip_rect_and_ellipse_bounds() {
assert!(close(out.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0), 1e-6));
}
#[test]
fn an_instance_whose_art_reaches_the_page_is_written() {
// The symbol's red rectangle, (100, 50) to (140, 90), drawn from (-50, -50): on the page, while
// the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc(200.0, 100.0);
let red = rect_node(Rect::new(100.0, 50.0, 140.0, 90.0), Color::rgb(1.0, 0.0, 0.0));
let far = vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(red) };
d.symbols.push(far.clone());
let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) });
add(&mut d, instance.clone());
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1);
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(50.0, 0.0, 90.0, 40.0), 0.01), "{:?}", l[0].geometric_bounds());
// An element of a knockout group, beside a blue rectangle.
let mut d = doc(200.0, 100.0);
d.symbols.push(far);
let blue = rect_node(Rect::new(150.0, 10.0, 170.0, 30.0), Color::rgb(0.0, 0.0, 1.0));
let mut group = Node::group(NodeId(0), vec![Arc::new(instance), Arc::new(blue)]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
let fills: Vec<[u8; 4]> = leaves(&roundtrip(&d)).iter().filter_map(|n| Some(n.appearance.fill_paint().color()?.to_rgba8(1.0))).collect();
assert!(fills.contains(&[255, 0, 0, 255]) && fills.contains(&[0, 0, 255, 255]), "{fills:?}");
}
#[test]
fn an_instance_whose_brush_art_reaches_the_page_is_written() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt stroke painted by a 20 pt round
// nib. Drawn from (-5, 0), the line lies left of the page, and the nib paints 10 pt either side
// of it, onto the page.
let mut d = doc(200.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(NodeId(0), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(line) });
add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-5.0, 0.0)) }));
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1, "the nib's art");
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds());
}
#[test]
fn roundtrip_colours_preserved() {
let mut d = doc(300.0, 100.0);
+45
View File
@@ -179,6 +179,51 @@ fn artboard_range_skip_blank_and_ignore() {
assert!(preview(&doc(100.0, 100.0, &[]), &settings(json!({"artboards": "ignore"}))).is_err(), "no art");
}
#[test]
fn a_symbol_instance_prints_by_its_art() {
// The symbol's red rectangle, (100, 100) to (140, 140), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc(100.0, 100.0, &[]);
let red = Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), red);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
let xf = vectorcraft_geom::Affine::translate((-50.0, -50.0));
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf })).unwrap();
// Skip Blank Artboards keeps the artboard.
assert_eq!(preview(&d, &settings(json!({"skipBlank": true}))).unwrap().sheets.len(), 1);
// Ignoring artboards, the page holds the art.
let p = pages(&job(&d, json!({"artboards": "ignore"})).bytes);
assert_eq!(p.len(), 1);
let printed: Vec<_> = p[0].1.iter().map(|(r, paint)| (r.width().round(), r.height().round(), rgb8(paint))).collect();
assert_eq!(printed, [(40.0, 40.0, [255, 0, 0])], "{p:?}");
}
#[test]
fn a_symbol_instances_brush_art_keeps_its_artboard_printed() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
// round nib. Drawn from (-5, 0), the line lies left of the artboard, and the nib paints 10 pt
// either side of it, onto the artboard.
let mut d = doc(100.0, 100.0, &[]);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut stroke = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let art = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(art) });
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
let xf = vectorcraft_geom::Affine::translate((-5.0, 0.0));
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Nib".into(), xf })).unwrap();
// Skip Blank Artboards keeps the artboard, and its page holds the nib's art.
let p = pages(&job(&d, json!({"skipBlank": true})).bytes);
assert_eq!(p.len(), 1);
assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}");
}
#[test]
fn copies_collate_and_reverse() {
let mut d =
+90 -9
View File
@@ -10,7 +10,7 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use vectorcraft_brush::Brush;
use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, SymbolBoxes};
use vectorcraft_geom::{BezPath, Rect};
use vello_cpu::RenderContext;
@@ -70,17 +70,28 @@ impl BrushCache {
}
}
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
let b = crate::fx::cull_bounds(n)?;
if !vectorcraft_brush::has_brush(n) {
return Some(b);
}
let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max);
let pad = 16.0 * w + 8.0;
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs), symbol
/// instances measured by their art from `symbols`, with the brushed strokes in that art.
pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = crate::fx::cull_bounds(symbols, n)?;
let pad = pad(symbols, n);
Some(b.inflate(pad, pad))
}
/// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art
/// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it
/// has a visible brushed stroke and of the objects in that art that have one
/// ([`SymbolBoxes::brush_width`]), and 0 when neither has one.
pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 {
let own = vectorcraft_brush::has_brush(n)
.then(|| n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max));
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.brush_width(symbol).map(|w| w.max(1.0)),
_ => None,
};
own.into_iter().chain(art).reduce(f64::max).map_or(0.0, |w| 16.0 * w + 8.0)
}
pub use vectorcraft_brush::instance_art;
impl Renderer {
@@ -198,4 +209,74 @@ mod tests {
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(50, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_is_culled_by_the_brushed_strokes_in_its_art() {
// The art: a line from (0, 40) to (0, 60) with a 1 pt stroke painted by a 20 pt round nib,
// drawn from x -6. The line lies left of the view, and the nib paints 10 pt either side of it.
let mut d = Document::new(100.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let art = Node::path(vectorcraft_doc::NodeId(0), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), ap);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: std::sync::Arc::new(art) });
let id = d.alloc_id();
let inst = Node::new(id, vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-6.0, 0.0)) });
let l = d.layers[0].id;
d.insert(Some(l), 0, inst).unwrap();
let img = render(&mut Renderer::new(), &d);
assert!(dark(img.pixel(1, 50)), "{:?}", img.pixel(1, 50));
}
#[test]
fn a_repeat_is_culled_by_the_brush_art_of_what_it_holds() {
// A one-by-one grid repeat of the line of the test above drawn from x -8, then of an
// instance of a symbol of that line: the nib paints 10 pt either side of the line, into
// the view.
let line = |d: &mut Document| {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), ap)
};
for instance in [false, true] {
let mut d = Document::new(100.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let moved = Affine::translate((-8.0, 0.0));
let source = if instance {
let art = line(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: std::sync::Arc::new(art) });
Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Nib".into(), xf: moved })
} else {
let mut n = line(&mut d);
n.transform(moved, false);
n
};
let grid = vectorcraft_doc::TileType::Grid;
let kind = vectorcraft_doc::RepeatKind::Grid {
h_spacing: 0.0,
v_spacing: 0.0,
rows: 1,
cols: 1,
grid_type: grid,
flip_rows: false,
flip_cols: false,
};
let spec = vectorcraft_doc::RepeatSpec { kind, source: vec![std::sync::Arc::new(source)] };
let repeat = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Repeat(spec));
let l = d.layers[0].id;
d.insert(Some(l), 0, repeat).unwrap();
let img = render(&mut Renderer::new(), &d);
assert!(dark(img.pixel(1, 50)), "an instance: {instance}; {:?}", img.pixel(1, 50));
}
}
}
+11 -7
View File
@@ -15,7 +15,7 @@ pub mod tga;
pub mod tiff;
pub mod web;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
use vectorcraft_geom::Rect;
use crate::{AntiAlias, RenderOptions, Rendered, Renderer, fx};
@@ -186,22 +186,26 @@ pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
}
/// Bounds of the art an export draws: visible objects off template layers (guides left out),
/// with their strokes and live effects. `None` when nothing would be drawn.
/// with their strokes and live effects, symbol instances measured by their art. `None` when
/// nothing would be drawn.
pub fn art_bounds(doc: &Document) -> Option<Rect> {
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(l)))
let symbols = doc.symbol_boxes();
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
}
fn drawn_bounds(n: &Node) -> Option<Rect> {
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if !n.visible {
return None;
}
match &n.kind {
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(c)))
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c)))
}
_ if fx::has_fx(n) => fx::visual_bounds(n),
_ => n.visual_bounds(),
_ if fx::has_fx(n) => fx::visual_bounds(symbols, n),
// An instance's box takes in its art, the effects in the art and its own effects.
NodeKind::SymbolInstance { .. } => fx::cull_bounds(symbols, n),
_ => n.visual_bounds_with(symbols),
}
}
+16
View File
@@ -228,6 +228,22 @@ fn art_bounds_cover_visible_art_only() {
assert_eq!(art_bounds(&empty), None);
}
#[test]
fn art_bounds_take_in_the_effects_in_a_symbols_art() {
// A symbol's art, (0, 0) to (10, 10), with a drop shadow 8 pt to the right, drawn from (20, 20).
let mut d = Document::new(100.0, 100.0);
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), black);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Box".into(), art: std::sync::Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = vectorcraft_geom::Affine::translate((20.0, 20.0));
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf })).unwrap();
let b = art_bounds(&d).unwrap();
assert!(b.x0 <= 20.0 && b.x1 >= 38.0, "the art from x 20 and its shadow to x 38: {b:?}");
}
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
let id = d.alloc_id();
+148 -18
View File
@@ -38,7 +38,7 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Weak};
use vectorcraft_color::cms::Cms;
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node, NodeId, NodeKind, SymbolBoxes};
use vectorcraft_effects::pixel::{Channels, PixelFx};
use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2};
@@ -147,10 +147,11 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
}
}
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances
/// measured by their art from `symbols`).
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
return cull_bounds(&art).map(|b| {
return cull_bounds(symbols, &art).map(|b| {
let o = effects::outset(&n.appearance.effects, b);
b.inflate(o, o)
});
@@ -177,35 +178,123 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
})
}
/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in
/// the subtree (a shadow can reach the view while its object is outside it).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
/// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in
/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are
/// measured by their art from `symbols`.
pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
cull_bounds_in(symbols, n, true)
}
/// [`cull_bounds`] of `n`. With `after_reshape` true, the effects in the art of a symbol instance
/// with geometry effects of its own are measured on the art as those effects reshape it
/// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art
/// before those effects run ([`art_effects_reach`]), which `symbols` keeps for each symbol and
/// linear part.
fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Option<Rect> {
let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape);
match &n.kind {
// The object and its crop marks.
_ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| cull_bounds(&art)),
_ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)),
// The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach.
_ if effects::has_container_appearance(n) => {
let art = effects::evaluate_container(n);
let art = art.as_ref().unwrap_or(n);
let b = if art.clips() {
art.visual_bounds()
art.visual_bounds_with(symbols)
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}?;
let o = effects::outset(&n.appearance.effects, b);
Some(b.inflate(o, o))
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
_ if has_fx(n) => visual_bounds(n),
// The reshaped art (a symbol's art needs the document: its instance box stands in).
_ if has_fx(n) => visual_bounds(symbols, n),
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art), grown
// by how far the effects in a symbol instance's art paint past that art.
_ if has_object_fx(n) => {
let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?;
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
let reshaped = effects::reshape(n, symbol.as_deref());
let b = reshaped.as_ref().and_then(|r| r.visual_bounds_with(symbols)).or_else(|| n.visual_bounds_with(symbols))?;
let a = match &reshaped {
Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r),
_ => art_effects_reach(symbols, n),
};
let a = a.max(live_content_reach(symbols, n));
let b = b.inflate(a, a);
let o = effects::outset(&n.appearance.effects, b);
Some(b.inflate(o, o))
}
_ => n.visual_bounds(),
NodeKind::SymbolInstance { .. } => {
let b = n.visual_bounds_with(symbols)?;
let a = art_effects_reach(symbols, n);
Some(b.inflate(a, a))
}
_ => {
let b = n.visual_bounds_with(symbols)?;
let a = live_content_reach(symbols, n);
Some(b.inflate(a, a))
}
}
}
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the
/// farthest side. 0 for any other kind of object.
fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
if !vectorcraft_doc::live::is_live(n) {
return 0.0;
}
let mut reach = 0.0f64;
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c)));
reach
}
/// How far the effects in the art of symbol instance `n` (and in the art of the symbols the
/// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the
/// art as the instance places it before its effects run: 0 when the art has no visible effects, and
/// for any other kind of object. The reach is the same wherever the art is moved to, so the art is
/// placed by the linear part of the instance's transform alone, and `symbols` keeps the reach for
/// each symbol and linear part, up to a limit ([`SymbolBoxes::effects_reach`]).
fn art_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return 0.0 };
if !symbols.has_effects(symbol) {
return 0.0;
}
let measure = |[m0, m1, m2, m3]: [f64; 4]| {
let past = |art: &Arc<Node>| {
let mut placed = (**art).clone();
placed.transform(Affine::new([m0, m1, m2, m3, 0.0, 0.0]), false);
match (cull_bounds(symbols, &placed), placed.visual_bounds_with(symbols)) {
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
}
};
symbols.measure_art(symbol, past).unwrap_or(0.0)
};
let [m0, m1, m2, m3, _, _] = xf.as_coeffs();
symbols.effects_reach(symbol, [m0, m1, m2, m3], &measure)
}
/// How far the effects in the art of symbol instance `n` paint past `r`, that art as `n`'s own
/// geometry effects reshape it ([`effects::reshape`]), on its farthest side: 0 when the art has no
/// visible effects, and for any other kind of object. The art in `r` is measured without the raster
/// effects of `n` that `r` carries, by [`cull_bounds_in`] with `after_reshape` false: the effects in
/// the art of an instance in it are measured before that instance's own geometry effects run.
fn reshaped_art_effects_reach(symbols: &SymbolBoxes, n: &Node, r: &Node) -> f64 {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return 0.0 };
if !symbols.has_effects(symbol) {
return 0.0;
}
let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false)));
match (art, r.visual_bounds_with(symbols)) {
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
}
}
@@ -754,7 +843,7 @@ impl Renderer {
let opacity = self.opacity_of(a);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) {
let blends = self.blends_through(a);
let bounds = if blends { self.bounds_of(a) } else { None };
let bounds = if blends { self.bounds_of(f.symbols, a) } else { None };
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache));
} else {
@@ -772,7 +861,7 @@ impl Renderer {
self.made_again = outer || !kept;
// With Crop Marks, the object inside the art paints its own raster effects.
let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) };
match cull_bounds(&art) {
match cull_bounds(f.symbols, &art) {
Some(reach) if !f.opts.outline && !rfx.is_empty() => {
// Feather and Inner Glow clip to the art's outline.
let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }));
@@ -1621,7 +1710,7 @@ mod tests {
fn visual_bounds_include_shadow() {
let d = doc_with(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK, vec![("stylize.dropShadow", json!({"x": 7, "y": 7, "blur": 5}))]);
let n = &d.layers[0].children().unwrap()[0];
let b = super::visual_bounds(n).unwrap();
let b = super::visual_bounds(&d.symbol_boxes(), n).unwrap();
assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9);
}
@@ -1729,6 +1818,47 @@ mod tests {
assert!(after != last, "6.3 is not on a whole pixel");
}
/// While interacting, a group holding a symbol instance whose art lies outside the 20 × 20
/// square around the origin draws its drop shadow from the raster of an earlier frame. The
/// shadow is made from the instance's art, which the renderer measures the group's content by.
#[test]
fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() {
let mut base = Document::new(100.0, 100.0);
let orange = Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0);
let art = Node::path(base.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 130.0, 126.0)), orange);
base.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: std::sync::Arc::new(art) });
// The instance draws the art from (20, 20) to (50, 46), and the 20 × 20 square around the
// origin from (-90, -90) to (-70, -70).
let xf = Affine::translate((-80.0, -80.0));
let instance = Node::new(base.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Far".into(), xf });
let mut group = Node::group(base.alloc_id(), vec![std::sync::Arc::new(instance)]);
let shadow = json!({"x": 4, "y": 3, "blur": 2, "opacity": 80});
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
let (id, l) = (group.id, base.layers[0].id);
base.insert(Some(l), 0, group).unwrap();
// The group moved from where it started, as each frame of a drag moves it.
let moved = |dx: f64, dy: f64| {
let mut d = base.clone();
d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false);
d
};
let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let live = RenderOptions { interacting: true, ..still.clone() };
let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still);
let mut r = Renderer::new();
let first = r.render(&base, 200, 200, view, &still);
assert!(first.pixels == fresh(&base).pixels);
// The shadow, 4 pt right of the art's right edge at x 50, at (104, 70) in pixels.
assert!(lum(first.pixel(104, 70)) < WHITE, "{:?}", first.pixel(104, 70));
// Moves on whole pixels (2 a point): the moved shadow lies where a fresh renderer draws it.
for (dx, dy) in [(3.0, 0.0), (5.5, 2.0)] {
let d = moved(dx, dy);
let frame = r.render(&d, 200, 200, view, &live).pixels;
assert_eq!(r.stats.moved, 1, "the shadow moved by ({dx}, {dy})");
assert!(frame == fresh(&d).pixels, "moved by ({dx}, {dy})");
}
}
/// While interacting, a raster moves with its object only when the content's pixels (for a
/// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under
/// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object,
+66 -26
View File
@@ -24,7 +24,7 @@ pub mod proof;
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject, TraceView};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
@@ -43,9 +43,10 @@ pub use vectorcraft_effects as effects;
pub use vello_cpu;
/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and
/// geometry effects, and the shadows and glows of it and its members.
pub fn painted_bounds(n: &Node) -> Option<Rect> {
brush_fx::cull_bounds(n)
/// geometry effects, and the shadows and glows of it and its members. Symbol instances are
/// measured by their art in `doc` ([`Node::visual_bounds_with`]).
pub fn painted_bounds(doc: &Document, n: &Node) -> Option<Rect> {
brush_fx::cull_bounds(&doc.symbol_boxes(), n)
}
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
@@ -399,6 +400,11 @@ pub struct Renderer {
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
pub threads: u16,
geom: PtrMap<usize, GeomEntry>,
/// The symbols (name and art) of the document last drawn, which `geom`'s bounds measured
/// symbol instances by (see [`Self::check_symbols`]). The art is held weakly: the reference
/// keeps the art's allocation, so no other art can get its address, and does not keep the
/// art's content.
symbols: Vec<(String, std::sync::Weak<Node>)>,
/// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits,
/// tolerance level): panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<(usize, usize, u64, i32), StrokeEntry>,
@@ -515,6 +521,8 @@ struct Frame<'a> {
/// Drawing into a multithreaded context: vello filter layers must be rendered offscreen.
mt: bool,
doc: &'a Document,
/// `doc`'s symbol art boxes, which culling measures symbol instances by ([`SymbolBoxes::quick`]).
symbols: &'a SymbolBoxes<'a>,
view: Affine,
/// Visible region in document coordinates (for culling).
visible: Rect,
@@ -549,6 +557,7 @@ impl Renderer {
ctx_st: None,
threads: default_threads(),
geom: PtrMap::default(),
symbols: vec![],
strokes: PtrMap::default(),
cur: None,
made_again: false,
@@ -604,6 +613,8 @@ impl Renderer {
return Rendered { width: w, height: h, pixels };
}
}
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
self.check_symbols(doc);
let prepared = proof::prepare(doc, opts);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
@@ -616,7 +627,8 @@ impl Renderer {
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: screen };
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen };
self.begin_frame();
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
@@ -791,12 +803,14 @@ impl Renderer {
/// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`.
pub fn render_node(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<Rendered> {
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let mut ctx = single_threaded_context(w, h);
let opts = RenderOptions::default();
let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display };
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display };
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
@@ -807,7 +821,8 @@ impl Renderer {
}
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
/// `size`×`size` pixels, optionally over a solid premultiplied background.
/// `size`×`size` pixels, optionally over a solid premultiplied background. A symbol instance
/// is fitted by its symbol's art in `doc`.
pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option<Rendered> {
self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default())
}
@@ -826,7 +841,8 @@ impl Renderer {
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
let b = brush_fx::cull_bounds(n)?;
self.check_symbols(doc);
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
@@ -835,7 +851,8 @@ impl Renderer {
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
}
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
@@ -845,8 +862,9 @@ impl Renderer {
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
}
/// Cached cull bounds of a node (containers union their cached children).
fn bounds_of(&mut self, a: &Arc<Node>) -> Option<Rect> {
/// Cached cull bounds of a node (containers union their cached children), symbol instances
/// measured by their art box from `symbols`.
fn bounds_of(&mut self, symbols: &SymbolBoxes, a: &Arc<Node>) -> Option<Rect> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get_mut(&key)
&& Arc::ptr_eq(&e.node, a)
@@ -861,17 +879,29 @@ impl Renderer {
let source = a.extras().trace.is_some().then(|| children.first()).flatten();
for c in children {
if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) {
acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c));
acc = vectorcraft_geom::union_opt(acc, self.bounds_of(symbols, c));
}
}
acc
}
_ => brush_fx::cull_bounds(a),
_ => brush_fx::cull_bounds(symbols, a),
};
self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp });
b
}
/// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s
/// symbols are not those of the document last drawn (by name and art): those bounds measured
/// the instances by their symbol's art.
fn check_symbols(&mut self, doc: &Document) {
let same = self.symbols.len() == doc.symbols.len()
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art)));
if !same {
self.geom.retain(|_, e| !holds_instance(&e.node));
self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect();
}
}
/// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]):
/// outlining text and uniting shapes is too slow to repeat every frame.
fn clip_of(&mut self, a: &Arc<Node>) -> Option<Arc<(BezPath, FillRule)>> {
@@ -903,8 +933,9 @@ impl Renderer {
(fill, stroke)
}
/// Cached BezPath of a path or compound path node.
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
/// Cached BezPath of a path or compound path node (its bounds cached with it, see
/// [`Self::bounds_of`]).
fn path_of(&mut self, symbols: &SymbolBoxes, a: &Arc<Node>) -> Option<Arc<BezPath>> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get(&key)
&& Arc::ptr_eq(&e.node, a)
@@ -913,7 +944,7 @@ impl Renderer {
return Some(p.clone());
}
let p = Arc::new(a.stroke_path()?);
let bounds = self.bounds_of(a);
let bounds = self.bounds_of(symbols, a);
self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp });
Some(p)
}
@@ -922,7 +953,7 @@ impl Renderer {
/// Outline view and `n` is `fx_owner`, else `None`.
fn outline_path(&mut self, f: &Frame, n: &Node) -> Option<Arc<BezPath>> {
let owner = self.fx_owner.as_ref().filter(|o| f.opts.outline && std::ptr::eq(&***o, n)).cloned()?;
self.path_of(&owner)
self.path_of(f.symbols, &owner)
}
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled.
@@ -931,7 +962,7 @@ impl Renderer {
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
return true;
}
match self.bounds_of(a) {
match self.bounds_of(f.symbols, a) {
Some(b) => {
let pad = f.px * 2.0;
// Level of detail: leaves smaller than a quarter pixel are invisible.
@@ -956,7 +987,7 @@ impl Renderer {
// A masked object is a non-isolated group of its own.
let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height()));
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
let bounds = if blends { self.bounds_of(a) } else { None };
let bounds = if blends { self.bounds_of(f.symbols, a) } else { None };
self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
return;
}
@@ -999,7 +1030,7 @@ impl Renderer {
&& !a.isolate
&& !fx::has_fx(a)
&& (self.opacity_of(a) >= 1.0 || painted_items(a) == 1)
&& let Some(bp) = self.path_of(a)
&& let Some(bp) = self.path_of(f.symbols, a)
{
self.alpha = self.opacity_of(a).clamp(0.0, 1.0);
self.cur = Some(a.clone());
@@ -1047,7 +1078,7 @@ impl Renderer {
}
if force {
// Bounds were already checked by draw_arc (or the caller wants it drawn regardless).
} else if let Some(b) = fx::cull_bounds(n) {
} else if let Some(b) = fx::cull_bounds(f.symbols, n) {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += n.count();
@@ -1138,7 +1169,7 @@ impl Renderer {
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) {
let blends = self.blends_through(n);
let bounds = if blends { self.node_bounds(n) } else { None };
let bounds = if blends { self.node_bounds(f.symbols, n) } else { None };
let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout));
} else {
@@ -1266,7 +1297,7 @@ impl Renderer {
if !c.visible || f.opts.hidden.contains(&c.id) {
return;
}
match self.bounds_of(c) {
match self.bounds_of(f.symbols, c) {
Some(b) => {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
@@ -1309,12 +1340,13 @@ impl Renderer {
}
}
/// Cull bounds of `n`: cached when it is a node of the tree being drawn.
fn node_bounds(&mut self, n: &Node) -> Option<Rect> {
/// Cull bounds of `n` (symbol instances measured by their art box from `symbols`): cached when
/// it is a node of the tree being drawn.
fn node_bounds(&mut self, symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
match self.geom.get(&(n as *const Node as usize)) {
// The entry keeps its node alive, so the address is that node's.
Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds,
_ => fx::cull_bounds(n),
_ => fx::cull_bounds(symbols, n),
}
}
@@ -2043,6 +2075,14 @@ fn holds_type(n: &Node) -> bool {
holds
}
/// Is `n` a symbol instance, or does it hold one? Its cull bounds measure the instance by its
/// symbol's art.
fn holds_instance(n: &Node) -> bool {
let mut holds = false;
n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
holds
}
/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color
/// model, the color's components and the amount (all 0 without a stain).
fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] {
+1 -1
View File
@@ -226,7 +226,7 @@ impl Renderer {
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate || knockout) {
let blends = self.blends_through(a);
let bounds = if blends { self.bounds_of(a) } else { None };
let bounds = if blends { self.bounds_of(f.symbols, a) } else { None };
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_live_body(c, fr, a, true));
} else {
+3
View File
@@ -91,6 +91,8 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default();
r.check_symbols(doc);
let symbols = doc.symbol_boxes().quick();
r.stamp += 1;
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
for o in def.offsets_covering(region) {
@@ -98,6 +100,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
let frame = Frame {
mt: false,
doc,
symbols: &symbols,
view: v,
visible: v.inverse().transform_rect_bbox(px_rect),
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
+3 -1
View File
@@ -291,7 +291,9 @@ impl Renderer {
return;
}
// The art in its own coordinates, with its own resources.
let inner = Frame { doc: &e.doc, view: f.view * m, visible: m.inverse().transform_rect_bbox(f.visible), px: f.px / k, ..*f };
let symbols = e.doc.symbol_boxes().quick();
let visible = m.inverse().transform_rect_bbox(f.visible);
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, ..*f };
let _inside = Inside::enter();
self.draw_arc(ctx, &inner, &e.art);
}
+190
View File
@@ -187,6 +187,196 @@ fn hidden_and_culled() {
assert_eq!(rr.stats.culled, 1);
}
/// A document with symbol "Far" holding a red rectangle `art`, and an instance of it moved by
/// (-50, -50): its 20 × 20 square, (-60, -60) to (-40, -40), lies outside a view of the page.
fn far_instance(art: Rect) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = rect_node(&mut d, art, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })).unwrap();
d
}
#[test]
fn an_instance_is_culled_by_its_symbols_art() {
// The art drawn from (350, 350) is out of view.
let away = far_instance(Rect::new(400.0, 400.0, 440.0, 440.0));
let mut rr = Renderer::new();
assert_eq!(rr.render(&away, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [0, 0, 0, 0]);
assert_eq!(rr.stats.culled, 1);
// The symbol's art, from (100, 100), drawn from (50, 50): in view. The instance is the same
// node, and the renderer measures it again.
let mut near = away.clone();
let art = rect_node(&mut near, Rect::new(100.0, 100.0, 140.0, 140.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
near.symbols[0].art = Arc::new(art);
assert_eq!(rr.render(&near, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [255, 0, 0, 255]);
assert_eq!(rr.stats.culled, 0);
}
#[test]
fn an_instance_is_culled_by_the_effects_in_its_art() {
// The art, (-1, -1) to (1, 1), drawn from (-5, 20) lies left of the view, and its drop shadow,
// 8 pt to the right, reaches into it.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
#[test]
fn a_repeat_of_an_instance_is_culled_by_the_effects_in_its_art() {
// The instance of the test above as the one copy of a one-by-one grid repeat.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) });
let grid = vectorcraft_doc::TileType::Grid;
let kind =
vectorcraft_doc::RepeatKind::Grid { h_spacing: 0.0, v_spacing: 0.0, rows: 1, cols: 1, grid_type: grid, flip_rows: false, flip_cols: false };
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec { kind, source: vec![Arc::new(instance)] }));
let l = d.layers[0].id;
d.insert(Some(l), 0, repeat).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
#[test]
fn past_the_measured_placements_an_instance_is_culled_by_an_estimate_of_its_art_effects() {
// Symbol `Spot` is a square from (-1, -1) to (1, 1) with a drop shadow 8 pt to the right, and
// symbols `Dot` and `Late` each hold an instance of it. 4,100 instances of `Dot`, each turned by
// another angle, lie far left of the view: one frame measures the effects in the art of at most
// 4,096 placements of symbols that hold instances. An instance of `Late` at (-5, 20) comes
// after them: its square lies left of the view, and the shadow reaches into it.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Spot".into(), art: Arc::new(art) });
for name in ["Dot", "Late"] {
let spot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Spot".into(), xf: Affine::IDENTITY });
let group = Node::group(d.alloc_id(), vec![Arc::new(spot)]);
d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(group) });
}
let l = d.layers[0].id;
for i in 0..4100 {
let xf = Affine::translate((-1000.0, 50.0)) * Affine::rotate(f64::from(i) * 1e-4);
let id = d.alloc_id();
d.insert(Some(l), i as usize, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
}
let id = d.alloc_id();
d.insert(Some(l), 4100, Node::new(id, NodeKind::SymbolInstance { symbol: "Late".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
/// Symbols 0 to 29 each hold two instances of the next, sheared two ways, so that the 2^30
/// instances drawn from the first are all placed differently. Symbol 30 holds a square with a drop
/// shadow. An instance of symbol 0 lies far left of the view. With `effect`, every instance has
/// that effect of its own. The renderer must cull the instance of symbol 0 within 30 s.
fn nested_instances_are_culled_in_bounded_time(effect: Option<vectorcraft_doc::Effect>) {
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 2, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "S30".into(), art: Arc::new(art) });
let instance = |d: &mut Document, symbol: String, xf: Affine| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol, xf });
n.appearance.effects.extend(effect.clone());
n
};
for i in (0..30).rev() {
let next = format!("S{}", i + 1);
let mut pair = vec![];
for xf in [Affine::new([1.0, 0.0, 0.1, 1.0, 0.0, 0.0]), Affine::new([1.0, 0.1, 0.0, 1.0, 2.0, 0.0])] {
pair.push(Arc::new(instance(&mut d, next.clone(), xf)));
}
let group = Node::group(d.alloc_id(), pair);
d.symbols.push(vectorcraft_doc::Symbol { name: format!("S{i}"), art: Arc::new(group) });
}
let first = instance(&mut d, "S0".into(), Affine::translate((-100_000.0, 0.0)));
let l = d.layers[0].id;
d.insert(Some(l), 0, first).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = Renderer::new();
let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
tx.send((img.pixel(50, 50), r.stats.culled)).ok();
});
let (pixel, culled) = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the frame within 30 s");
assert_eq!(pixel, [0, 0, 0, 0]);
assert!(culled > 0);
}
#[test]
fn nested_instances_with_effects_are_culled_in_bounded_time() {
nested_instances_are_culled_in_bounded_time(None);
}
#[test]
fn nested_instances_with_geometry_effects_of_their_own_are_culled_in_bounded_time() {
// Each instance's Transform effect reshapes the art it shows before the effects in that art run.
let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 1}), visible: true };
nested_instances_are_culled_in_bounded_time(Some(moved));
}
#[test]
fn a_scaled_instance_is_culled_by_the_effects_in_its_art_as_placed() {
// The art, (-1, -1) to (1, 1), scaled 30 times and centered on (-50, 50): (-80, 20) to
// (-20, 80), out of view. Its Transform effect doubles the placed art: (-110, -10) to
// (10, 110).
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let double = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 200, "scaleV": 200}), visible: true };
art.appearance.effects.push(double);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = Affine::translate((-50.0, 50.0)) * Affine::scale(30.0);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(5, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() {
// The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect
// moves it 150 pt right, into view.
let mut d = far_instance(Rect::new(0.0, 0.0, 20.0, 20.0));
let id = d.layers[0].children().unwrap()[0].id;
let inst = d.node_mut(id).unwrap();
inst.kind = NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-100.0, 10.0)) };
let effect = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 150}), visible: true };
inst.appearance.effects.push(effect);
assert_eq!(Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(60, 20), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_the_effects_in_its_reshaped_art() {
// The art, (-1, -1) to (1, 1), and the instance at (-11, 20) each have a Transform effect that
// scales by 4. The instance's effect scales the placed art, (-12, 19) to (-10, 21), to
// (-15, 16) to (-7, 24), and the art's effect then scales that to (-27, 4) to (5, 36), which
// reaches into the view.
let mut d = Document::new(100.0, 100.0);
let scale = serde_json::json!({"scaleH": 400, "scaleV": 400});
let effect = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: scale.clone(), visible: true };
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
art.appearance.effects.push(effect());
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let mut instance = Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-11.0, 20.0)) });
instance.appearance.effects.push(effect());
d.insert(Some(l), 0, instance).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(2, 20), [255, 0, 0, 255]);
}
#[test]
fn view_transform_zooms() {
let mut d = Document::new(100.0, 100.0);
+34
View File
@@ -472,6 +472,40 @@ fn editing_an_instance_or_a_symbol_places_only_its_art_again() {
assert!(moved.is_subset(&edited) && edited.len() == moved.len() + 6, "{} copies, then {}", moved.len(), edited.len());
}
/// A symbol whose art, kept between frames, lies outside the 20 × 20 square around the origin:
/// each frame culls the instance by the art the symbol holds then and draws what a new renderer
/// draws. When the symbol holds earlier art again, the renderer draws the art it placed for it
/// then.
#[test]
fn instances_of_art_outside_the_square_follow_their_symbols_art() {
let _fonts = fonts_steady();
/// A blue square in a group, 20 × 20 from (x, 0).
fn square_at(d: &mut Document, x: f64) -> Arc<Node> {
let blue = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), blue);
Arc::new(Node::group(d.alloc_id(), vec![Arc::new(square)]))
}
let mut d = Document::new(200.0, 100.0);
// The instance moves the art by (-160, 40): the square from x 100 lies left of the view, and
// the one from x 200 lies from (40, 40) to (60, 60), in it. The 20 × 20 square around the
// origin lies from (-170, 30) to (-150, 50), left of the view.
let (off, on) = (square_at(&mut d, 100.0), square_at(&mut d, 200.0));
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: off.clone() });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-160.0, 40.0)) })).unwrap();
let opts = RenderOptions::default();
let mut r = Renderer::new();
let mut placed = vec![];
for art in [&off, &on, &off, &on] {
d.symbols[0].art = art.clone();
let frame = r.render(&d, 200, 100, Affine::IDENTITY, &opts);
assert!(frame.pixels == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels);
assert_eq!(frame.pixel(50, 50) == [0, 0, 255, 255], Arc::ptr_eq(art, &on), "{:?}", frame.pixel(50, 50));
placed.push(placed_copies(&r, &[&d]));
}
assert!(!placed[1].is_empty() && placed[3] == placed[1], "the fourth frame draws the art placed in the second");
}
/// Draws `d` (200 × 100 points, on white), applies `edit` and draws it again with the same
/// renderer: the frame changed, a new renderer draws the same, and only the document holds the
/// symbols' art.
+1 -1
View File
@@ -1589,7 +1589,7 @@ impl Writer<'_> {
}
return 0;
}
let bounds = n.visual_bounds();
let bounds = n.visual_bounds_with(&self.doc.symbol_boxes());
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
let mut opened = 0;
+39
View File
@@ -100,6 +100,16 @@ fn two_uses_give_one_symbol_and_two_instances() {
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.003);
}
#[test]
fn an_instance_is_measured_by_its_symbols_art() {
let (d, _) = open(&format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/>"##));
let id = art(&d)[0].id;
// The art, (0, 0) to (20, 20), drawn from (20, 30): not the 20 × 20 square around the origin,
// which the same transform places from (10, 20) to (30, 40).
assert_eq!(d.bounds_of(&[id], false), Some(Rect::new(20.0, 30.0, 40.0, 50.0)));
assert_eq!(d.bounds_of(&[id], true), Some(Rect::new(20.0, 30.0, 40.0, 50.0)));
}
#[test]
fn uses_the_symbol_cant_stand_for_stay_art() {
// Inherited paint: the second use shows the symbol in another colour. Scaled: the canvas keeps
@@ -125,6 +135,35 @@ fn symbols_round_trip_through_export() {
assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.001);
}
/// `name="…"` of the first element `tag` in `svg`, as a number.
fn number_attr(svg: &str, tag: &str, name: &str) -> f64 {
let at = svg.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {svg}"));
let rest = &svg[at..];
let from = rest.find(&format!(" {name}=\"")).unwrap() + name.len() + 3;
rest[from..from + rest[from..].find('"').unwrap()].parse().unwrap()
}
#[test]
fn an_instances_shadow_filter_and_the_art_export_cover_its_symbols_art() {
let (mut d, _) = open(&format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/>"##));
let id = art(&d)[0].id;
let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({"x": 0, "y": 0, "blur": 0}), visible: true };
d.node_mut(id).unwrap().appearance.effects.push(shadow);
// The filter region holds the art, (20, 30) to (40, 50).
let svg = export(&d, &ExportOptions::default());
let (x, y) = (number_attr(&svg, "filter", "x"), number_attr(&svg, "filter", "y"));
let (w, h) = (number_attr(&svg, "filter", "width"), number_attr(&svg, "filter", "height"));
assert!(x <= 20.0 && y <= 30.0 && x + w >= 40.0 && y + h >= 50.0, "({x}, {y}) {w} × {h}");
// Exported without an artboard, the file is the art's size and the instance draws the art from
// the file's top left corner.
d.node_mut(id).unwrap().appearance.effects.clear();
let svg = export(&d, &ExportOptions { artboard: None, ..ExportOptions::default() });
assert_eq!((number_attr(&svg, "svg", "width"), number_attr(&svg, "svg", "height")), (20.0, 20.0), "{svg}");
let back = import(&svg).unwrap();
let NodeKind::SymbolInstance { xf, .. } = &art(&back)[0].kind else { panic!("an instance: {svg}") };
assert!((*xf * Point::ORIGIN).distance(Point::ORIGIN) < 1e-9, "{svg}");
}
#[test]
fn a_gaussian_blur_becomes_the_effect_with_its_radius() {
let (d, w) = open(
+30 -11
View File
@@ -3,7 +3,7 @@
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::{Node, NodeKind, Unit};
use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
@@ -1277,8 +1277,8 @@ fn c32(rgb: [u8; 3]) -> Color32 {
}
/// Outline of a node for highlighting (paths, compound children, area type frames, other
/// text/image bounds).
fn node_outline(n: &Node) -> BezPath {
/// text/image bounds, symbol instances' bounds measured by their art from `symbols`).
fn node_outline(n: &Node, symbols: &SymbolBoxes) -> BezPath {
let mut bp = BezPath::new();
walk_drawn(n, &mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
@@ -1290,7 +1290,7 @@ fn node_outline(n: &Node) -> BezPath {
}
}
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
if let Some(b) = c.geometric_bounds() {
if let Some(b) = c.geometric_bounds_with(symbols) {
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
}
}
@@ -1469,7 +1469,7 @@ fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
}
if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) {
let color = c32(st.doc.layer_color(id));
stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color));
stroke_path(p, &node_outline(n, &st.doc.symbol_boxes()), xf, Stroke::new(1.5, color));
}
}
@@ -1669,6 +1669,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
let big_bounds = if big { app.selection_bounds() } else { None };
let app = &*app;
let Some(st) = app.session.active() else { return };
let symbols = st.doc.symbol_boxes();
let tool = app.session.tool_id();
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
let curvature = tool == "curvature";
@@ -1688,9 +1689,9 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
// too many paths to outline.
if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) {
let outline = if too_many_anchors([n]) {
n.geometric_bounds().map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath())
n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath())
} else {
Some(node_outline(n))
Some(node_outline(n, &symbols))
};
if let Some(bp) = outline {
stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
@@ -1701,7 +1702,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
let color = c32(st.doc.layer_color(*id));
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
// envelope's points.
walk_drawn(n, &mut |c| {
@@ -1763,7 +1764,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
});
// Centre point (Attributes panel → Show Center).
if n.shows_center()
&& let Some(b) = n.geometric_bounds()
&& let Some(b) = n.geometric_bounds_with(&symbols)
{
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
}
@@ -2325,9 +2326,27 @@ mod tests {
let st = s.active().unwrap();
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert!(g.extras().shaper.is_some());
let bounds = node_outline(g).bounding_box();
let symbols = st.doc.symbol_boxes();
let bounds = node_outline(g, &symbols).bounding_box();
assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}");
assert_eq!(node_outline(st.doc.node(line).unwrap()).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
assert_eq!(node_outline(st.doc.node(line).unwrap(), &symbols).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
}
#[test]
fn a_symbol_instance_is_outlined_around_its_art() {
// A symbol holding the square of the SVG import tests' `Star` symbol, 20 × 20 from (0, 0),
// and an instance of it at (20, 30).
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Star"><path d="M0 0H20V20H0Z" fill="#e0a000"/></symbol></defs><use href="#Star" x="20" y="30"/></svg>"##;
let mut s = Session::new();
s.execute("document.open", &json!({"name": "star.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
let st = s.active().unwrap();
let mut inst = None;
st.doc.walk(|n| {
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
inst = Some(n);
}
});
assert_eq!(node_outline(inst.unwrap(), &st.doc.symbol_boxes()).bounding_box(), Rect::new(20.0, 30.0, 40.0, 50.0));
}
/// One headless canvas frame on an 800 × 600 window.
+25
View File
@@ -280,4 +280,29 @@ mod tests {
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(symbols(&mut app), ["New Symbol", "Badge"]);
}
#[test]
fn an_imported_symbols_thumbnail_frames_its_whole_art() {
// The symbol of the SVG import tests: an orange 20 × 20 square from (0, 0) with a white dot.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Star" data-name="Gold Star"><path d="M0 0H20V20H0Z" fill="#e0a000"/><circle cx="10" cy="10" r="4" fill="#fff"/></symbol></defs><use href="#Star" x="20" y="30"/></svg>"##;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("document.open", json!({"name": "star.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
// The tile's thumbnail: 40 × 40 pixels.
let thumb = out.textures_delta.set.values().flatten().find_map(|d| match &d.image {
egui::ImageData::Color(img) if img.size == [40, 40] => Some(img.clone()),
_ => None,
});
out.textures_delta.clear();
let thumb = thumb.expect("the thumbnail");
// The art fills it, a pixel from each edge: its top-left corner is orange too, not only the
// quarter the 20 × 20 square around the origin would frame.
let orange = |x: usize, y: usize| {
let [r, g, b, a] = thumb.pixels[y * 40 + x].to_array();
a == 255 && r > 200 && (130..190).contains(&g) && b < 40
};
assert!(orange(3, 3) && orange(36, 36), "{:?} {:?}", thumb.pixels[3 * 40 + 3], thumb.pixels[36 * 40 + 36]);
}
}
+12
View File
@@ -1464,6 +1464,18 @@ in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside s
inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter
limit. A rectangle's right-angle miters stay within half the weight of its edges.
A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in
`document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees,
Fit to Artwork Bounds and exports of the art use that box. Visual bounds (Use Preview Bounds, Fit to Selected Art)
take in the strokes in the art at their own weight, as the canvas draws them when the instance scales the art. An
instance is measured as the 20 × 20 square around the origin, which symbols made in the app keep their art in, when
its symbol is missing, when its symbol or a symbol nested in its art holds an instance of itself, and when symbols
nest 32 deep in its art. PDF and EPS export and the SVG of an artboard leave out art outside the page or artboard, and
Skip Blank Artboards skips an artboard with no art on it. For both, the box of an instance whose art holds brushed
strokes grows by 16 times the widest stroke (at least 1 pt) of the objects in its art that have a brushed stroke,
plus 8 pt. The SVG of an artboard also keeps an object that holds an instance when the effects in the symbol's art, or
the instance's own, paint onto the artboard.
## Expand
`object.expand {object?, fill?, stroke?, gradient?, steps?}` (Object → Expand…) turns the selection into plain art in
+1
View File
@@ -426,6 +426,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Select menu's Same commands, its Object commands that select art, Next Object Above and Below and saved selections select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer.
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
Anchor Align moves the selected points to their bounds or the artboard (#1180). Repeating it, or moving by at most 1e-9 pt on both axes as whole-object Align does, leaves the document, Undo, Redo and saved state unchanged; actual moves still take one undo step. `crates/engine/src/tests.rs` covers repeated alignment, rounding-only alignment, unchanged handles, saved state, command recording and Undo/Redo.
A symbol instance is measured by its symbol's art, as its transform places it, for clicks, the marquee, the bounding box, the Transform panel, Align and Distribute (the art's strokes too with Use Preview Bounds), Fit to Artwork Bounds, exports of the art, thumbnails and canvas culling; one whose symbol is missing is measured as the 20 × 20 square around the origin.
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Compound paths:** Make keeps the backmost object's appearance and each contour; selecting a member in Direct Selection reads and changes the compound's fill rule in Attributes. Direction remains a per-member edit. Rendered holes, unchanged contour geometry, no-op history and Undo/Redo are covered by `engine/tests/compound_attributes.rs` (#1197). Behaviour reference: [public compound-path documentation](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-paths.html). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.