mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 02:34:09 +00:00
Merge pull request #5732 from dotCooCoo/fix/render-stroke-outline-reused-on-move
Render: a moved path reuses its stroke outline
This commit is contained in:
@@ -30,14 +30,22 @@ pub(crate) type PtrMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<PtrHa
|
||||
/// stroke's place in it, width bits, tolerance level).
|
||||
pub(crate) type StrokeKey = (usize, usize, u64, i32);
|
||||
|
||||
/// A stroke expanded to a fill outline: (owning node, outline, last frame used, and the region a
|
||||
/// frame must lie in to draw the outline, when the outline leaves out the segments far from that
|
||||
/// region; `None` when it has every segment).
|
||||
/// What the [`StrokeKey`] of the latest outline of an object's stroke is found under once the
|
||||
/// object has changed: (the object's id, the stroke's place in its appearance items, width bits,
|
||||
/// tolerance level).
|
||||
pub(crate) type MovedKey = (u64, usize, u64, i32);
|
||||
|
||||
/// A stroke expanded to a fill outline: (owning node, outline, last frame used, the region a frame
|
||||
/// must lie in to draw the outline, when the outline leaves out the segments far from that region,
|
||||
/// `None` when it has every segment, and whether the outline was made along a line of the node's
|
||||
/// own path, the one [`Node::stroke_path`] makes, so that the line can be made again from the
|
||||
/// node and its stroke).
|
||||
pub(crate) struct StrokeEntry {
|
||||
pub(crate) node: Arc<Node>,
|
||||
pub(crate) outline: Arc<BezPath>,
|
||||
pub(crate) stamp: u64,
|
||||
pub(crate) crop: Option<Rect>,
|
||||
pub(crate) own: bool,
|
||||
}
|
||||
|
||||
/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds.
|
||||
|
||||
@@ -74,6 +74,9 @@ pub struct Renderer {
|
||||
/// 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<StrokeKey, StrokeEntry>,
|
||||
/// The key in `strokes` of the latest outline of each stroke of an object, under its
|
||||
/// [`frame::MovedKey`]: a moved copy of the object draws that outline moved ([`Self::moved_outline`]).
|
||||
moved_strokes: PtrMap<frame::MovedKey, StrokeKey>,
|
||||
/// Expanded character stroke outlines, with the glyph geometry kept in `texts` they belong to,
|
||||
/// keyed by ([`Self::stroke_owner`] of that geometry, run, width bits, tolerance level): type
|
||||
/// that doesn't change draws them again instead of expanding its strokes in every frame. Within
|
||||
|
||||
@@ -18,6 +18,9 @@ use super::{
|
||||
group, ink, kurbo, live, lru, peniko, proof, vello_cpu,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use paint::{STROKES_OUTLINED, offset_between};
|
||||
|
||||
mod images;
|
||||
mod nodes;
|
||||
mod paint;
|
||||
@@ -38,6 +41,7 @@ impl Renderer {
|
||||
symbols: vec![],
|
||||
plugin_revision: 0,
|
||||
strokes: PtrMap::default(),
|
||||
moved_strokes: PtrMap::default(),
|
||||
char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0),
|
||||
cur: None,
|
||||
made_again: false,
|
||||
@@ -186,6 +190,8 @@ impl Renderer {
|
||||
self.kept.retain(|_, e| g - e.stamp <= 3 || geom.get(&(Arc::as_ptr(&e.node) as usize)).is_some_and(|k| Arc::ptr_eq(&k.node, &e.node)));
|
||||
if self.strokes.len() > 1024 {
|
||||
self.strokes.retain(|_, e| g - e.stamp <= 3);
|
||||
let strokes = &self.strokes;
|
||||
self.moved_strokes.retain(|_, k| strokes.contains_key(k));
|
||||
}
|
||||
if self.stroke_slices.len() > 256 {
|
||||
self.stroke_slices.retain(|_, e| g - e.stamp <= 3);
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::{
|
||||
Slices, StrokeAlign, StrokeEntry, StrokeLayer, TextGeom, blend_mode, drawable, effects, fill_rule, fx, kurbo, lru, owns_stroke, peniko,
|
||||
rects_overlap,
|
||||
};
|
||||
use crate::frame::{MovedKey, StrokeKey};
|
||||
use crate::paint;
|
||||
|
||||
impl Renderer {
|
||||
@@ -133,7 +134,7 @@ impl Renderer {
|
||||
} else if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
|
||||
self.fold_alpha(ctx, f.ink, &st.paint);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
match self.cached_stroke(f, chars, &pieces.line, st, width) {
|
||||
match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
|
||||
Some(o) => ctx.fill_path(&o),
|
||||
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
|
||||
}
|
||||
@@ -209,7 +210,7 @@ impl Renderer {
|
||||
}
|
||||
None => make(),
|
||||
};
|
||||
let line = match self.cached_stroke(f, chars, &pieces.line, st, width) {
|
||||
let line = match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
|
||||
Some(o) => o,
|
||||
None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
|
||||
};
|
||||
@@ -236,11 +237,12 @@ impl Renderer {
|
||||
/// stroke of `self.cur` in `strokes` (under [`Self::stroke_owner`] and the stroke layer). The
|
||||
/// tolerance (vello's 0.25 device px) is bucketed to powers of two, never coarser than needed,
|
||||
/// so zooming reuses outlines until the level changes. Other strokes aren't cached: for them
|
||||
/// this returns `None`.
|
||||
/// this returns `None`. `line` is the line of `st` along `path` ([`effects::stroke::stroke_pieces`]).
|
||||
fn cached_stroke(
|
||||
&mut self,
|
||||
f: &Frame,
|
||||
chars: Option<(&Arc<TextGeom>, usize)>,
|
||||
path: &BezPath,
|
||||
line: &BezPath,
|
||||
st: &StrokeLayer,
|
||||
width: f64,
|
||||
@@ -269,6 +271,19 @@ impl Renderer {
|
||||
e.stamp = self.stamp;
|
||||
return Some(e.outline.clone());
|
||||
}
|
||||
// Under its object's id, the stroke's place and the outline's width and tolerance, the key
|
||||
// of the stroke's latest outline is kept, except for art made again in every frame.
|
||||
let place = node.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st)));
|
||||
let moved = place.filter(|_| !self.made_again).map(|place| (node.id.0, place, width.to_bits(), level));
|
||||
let own = moved.is_some() && self.own_path(&node, path);
|
||||
if let Some(mk) = moved
|
||||
&& let Some((outline, crop)) = self.moved_outline(mk, &node, st, line, v)
|
||||
{
|
||||
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, own }, moved);
|
||||
return Some(outline);
|
||||
}
|
||||
#[cfg(test)]
|
||||
STROKES_OUTLINED.with(|n| n.set(n.get() + 1));
|
||||
// A long stroke without a width profile is outlined only in and around the view, with a
|
||||
// margin of the view's own size on each side ([`crop_dashed`] for a dashed one,
|
||||
// [`crop_line`] otherwise).
|
||||
@@ -281,7 +296,7 @@ impl Renderer {
|
||||
&& let Some(outline) = crop_dashed(line, st, width, region, crop_reach(st, width, f.px, tol), tol)
|
||||
{
|
||||
let outline = Arc::new(outline);
|
||||
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop: Some(region) });
|
||||
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop: Some(region), own }, moved);
|
||||
return Some(outline);
|
||||
}
|
||||
let cropped = (crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_none() && st.profile.is_none())
|
||||
@@ -291,10 +306,64 @@ impl Renderer {
|
||||
Some(part) => (Arc::new(effects::stroke::line_outline(&part, st, width, tol)), Some(region)),
|
||||
None => (expand(), None),
|
||||
};
|
||||
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop });
|
||||
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, own }, moved);
|
||||
Some(outline)
|
||||
}
|
||||
|
||||
/// Whether `path` is the path that `geom` keeps for `node` ([`Self::path_of`]), the path
|
||||
/// [`Node::stroke_path`] makes from its anchors.
|
||||
fn own_path(&self, node: &Arc<Node>, path: &BezPath) -> bool {
|
||||
self.geom
|
||||
.get(&(Arc::as_ptr(node) as usize))
|
||||
.is_some_and(|g| Arc::ptr_eq(&g.node, node) && g.path.as_deref().is_some_and(|p| std::ptr::eq(p, path)))
|
||||
}
|
||||
|
||||
/// Keeps outline `e` in `strokes` under `key`, and lists `key` as the latest outline under
|
||||
/// `moved` ([`MovedKey`]).
|
||||
fn keep_stroke(&mut self, key: StrokeKey, e: StrokeEntry, moved: Option<MovedKey>) {
|
||||
self.strokes.insert(key, e);
|
||||
if let Some(mk) = moved {
|
||||
self.moved_strokes.insert(mk, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of stroke `st` of `node` along `line`, made from the latest outline kept under
|
||||
/// `mk` ([`MovedKey`]) when that outline was made along a line of its object's own path, its
|
||||
/// object is `node` changed, its stroke differs from `st` in its paint at most, and its line is
|
||||
/// `line` moved (to within rounding): that outline moved by the same offset, with the region it
|
||||
/// was cropped to moved too. The anchors of the two objects are compared first
|
||||
/// ([`anchors_moved`]), and the earlier line is made again from its object and stroke only when
|
||||
/// they match. `None` when there is no such outline, or when its region does not hold the view
|
||||
/// `v`.
|
||||
fn moved_outline(&self, mk: MovedKey, node: &Node, st: &StrokeLayer, line: &BezPath, v: Rect) -> Option<(Arc<BezPath>, Option<Rect>)> {
|
||||
let e = self.strokes.get(self.moved_strokes.get(&mk)?)?;
|
||||
if !e.own || e.node.id != node.id {
|
||||
return None;
|
||||
}
|
||||
let Some(AppearanceItem::Stroke(old)) = e.node.appearance.items.get(mk.1) else {
|
||||
return None;
|
||||
};
|
||||
let unpainted = |s: &StrokeLayer| StrokeLayer { paint: vectorcraft_color::Paint::None, ..s.clone() };
|
||||
if !std::ptr::eq(old, st) && unpainted(old) != unpainted(st) {
|
||||
return None;
|
||||
}
|
||||
if !anchors_moved(&e.node, node) {
|
||||
return None;
|
||||
}
|
||||
let path = e.node.stroke_path()?;
|
||||
let by = offset_between(&effects::stroke::stroke_pieces(&path, old).line, line)?;
|
||||
let crop = e.crop.map(|r| r + by);
|
||||
if crop.is_some_and(|r| r.x0 > v.x0 || r.y0 > v.y0 || r.x1 < v.x1 || r.y1 < v.y1) {
|
||||
return None;
|
||||
}
|
||||
if by == kurbo::Vec2::ZERO {
|
||||
return Some((e.outline.clone(), crop));
|
||||
}
|
||||
let mut outline = (*e.outline).clone();
|
||||
outline.apply_affine(Affine::translate(by));
|
||||
Some((Arc::new(outline), crop))
|
||||
}
|
||||
|
||||
/// What the `strokes`, `char_strokes` and `stroke_slices` keys hold for a stroke owner at
|
||||
/// `address` (`self.cur` or kept glyph geometry): the address, or 0 while art made again in
|
||||
/// every frame is drawn ([`Self::made_again`]).
|
||||
@@ -311,6 +380,75 @@ pub(crate) const CROP_ELEMENTS: usize = 1024;
|
||||
thread_local! {
|
||||
/// Whether long strokes are outlined near the view only on this thread ([`crop_line`]).
|
||||
pub(crate) static CROP_STROKES: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
|
||||
/// How many stroke outlines of nodes [`Renderer::cached_stroke`] has made on this thread.
|
||||
pub(crate) static STROKES_OUTLINED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each coordinate
|
||||
/// within a few units in the last place of the moved one, the error that adding the offset and
|
||||
/// measuring it from the first points can make). `None` otherwise, and for an empty path.
|
||||
pub(crate) fn offset_between(was: &BezPath, now: &BezPath) -> Option<kurbo::Vec2> {
|
||||
use kurbo::PathEl;
|
||||
let (was, now) = (was.elements(), now.elements());
|
||||
if was.len() != now.len() {
|
||||
return None;
|
||||
}
|
||||
let (by, first) = match (was.first()?, now.first()?) {
|
||||
(PathEl::MoveTo(a), PathEl::MoveTo(b)) => first_offset(*a, *b)?,
|
||||
_ => return None,
|
||||
};
|
||||
let near = |a: &kurbo::Point, b: &kurbo::Point| moved_by(*a, *b, by, first);
|
||||
let moved = was.iter().zip(now).all(|pair| match pair {
|
||||
(PathEl::MoveTo(a), PathEl::MoveTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => near(a, b),
|
||||
(PathEl::QuadTo(a0, a1), PathEl::QuadTo(b0, b1)) => near(a0, b0) && near(a1, b1),
|
||||
(PathEl::CurveTo(a0, a1, a2), PathEl::CurveTo(b0, b1, b2)) => near(a0, b0) && near(a1, b1) && near(a2, b2),
|
||||
(PathEl::ClosePath, PathEl::ClosePath) => true,
|
||||
_ => false,
|
||||
});
|
||||
moved.then_some(by)
|
||||
}
|
||||
|
||||
/// The offset from first point `a` to first point `b` and the largest size of their coordinates,
|
||||
/// which [`moved_by`] measures its tolerance with. `None` when the offset is not finite.
|
||||
fn first_offset(a: kurbo::Point, b: kurbo::Point) -> Option<(kurbo::Vec2, f64)> {
|
||||
let by = b - a;
|
||||
by.is_finite().then(|| (by, a.x.abs().max(b.x.abs()).max(a.y.abs()).max(b.y.abs())))
|
||||
}
|
||||
|
||||
/// Whether `b` is `a` moved by `by`, each coordinate within a few units in the last place (see
|
||||
/// [`offset_between`]; `first` from [`first_offset`]).
|
||||
fn moved_by(a: kurbo::Point, b: kurbo::Point, by: kurbo::Vec2, first: f64) -> bool {
|
||||
let close = |a: f64, by: f64, b: f64| (a + by - b).abs() <= 4.0 * f64::EPSILON * (a.abs() + b.abs() + first);
|
||||
close(a.x, by.x, b.x) && close(a.y, by.y, b.y)
|
||||
}
|
||||
|
||||
/// Whether the anchors and handles of path `now` are those of path `was` moved by one offset,
|
||||
/// measured as [`offset_between`] measures it, with the same subpaths, anchor counts and closed
|
||||
/// subpaths. It reads the anchors without making either path ([`Node::stroke_path`]), and stops at
|
||||
/// the first anchor that differs. `false` for objects other than paths.
|
||||
fn anchors_moved(was: &Node, now: &Node) -> bool {
|
||||
let (vectorcraft_doc::NodeKind::Path { path: was, .. }, vectorcraft_doc::NodeKind::Path { path: now, .. }) = (&was.kind, &now.kind) else {
|
||||
return false;
|
||||
};
|
||||
if was.subpaths.len() != now.subpaths.len() {
|
||||
return false;
|
||||
}
|
||||
let mut offset = None;
|
||||
for (a, b) in was.subpaths.iter().zip(&now.subpaths) {
|
||||
if a.closed != b.closed || a.anchors.len() != b.anchors.len() {
|
||||
return false;
|
||||
}
|
||||
for (a, b) in a.anchors.iter().zip(&b.anchors) {
|
||||
if offset.is_none() {
|
||||
offset = first_offset(a.p, b.p);
|
||||
}
|
||||
let Some((by, first)) = offset else { return false };
|
||||
if !(moved_by(a.p, b.p, by, first) && moved_by(a.h_in, b.h_in, by, first) && moved_by(a.h_out, b.h_out, by, first)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn crops() -> bool {
|
||||
|
||||
@@ -24,6 +24,7 @@ mod knockout;
|
||||
mod layeropts;
|
||||
mod longstroke;
|
||||
mod memory;
|
||||
mod movedstroke;
|
||||
mod objectfx;
|
||||
mod placed_document;
|
||||
mod rasterize;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! A moved object draws its stroke from the outline made for it before it moved, moved by the same
|
||||
//! offset, with the pixels a new renderer draws. A change other than a move outlines the stroke
|
||||
//! again.
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, WidthProfile,
|
||||
};
|
||||
use vectorcraft_geom::{PathData, Point, Vec2};
|
||||
|
||||
use crate::renderer::{STROKES_OUTLINED, offset_between};
|
||||
use crate::*;
|
||||
|
||||
const SIZE: u32 = 200;
|
||||
|
||||
/// The view that shows `at` in the middle of the 200×200 frame.
|
||||
fn view_at(at: Point) -> Affine {
|
||||
Affine::translate((100.0 - at.x, 100.0 - at.y))
|
||||
}
|
||||
|
||||
fn render(r: &mut Renderer, d: &Document, at: Point) -> Rendered {
|
||||
r.render(d, SIZE, SIZE, view_at(at), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
/// How many stroke outlines the renderers on this thread have made.
|
||||
fn outlined() -> usize {
|
||||
STROKES_OUTLINED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// A document holding `path` painted with `items`, and the path's id.
|
||||
fn doc(path: &BezPath, items: Vec<AppearanceItem>) -> (Document, NodeId) {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let id = d.alloc_id();
|
||||
let n = Node::path(id, PathData::from_bezpath(path), Appearance { items, ..Default::default() });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
fn stroke(width: f64, join: LineJoin, cap: LineCap) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||||
st.join = join;
|
||||
st.cap = cap;
|
||||
st.miter_limit = 10.0;
|
||||
st
|
||||
}
|
||||
|
||||
fn dashed(mut st: StrokeLayer, pattern: &[f64]) -> StrokeLayer {
|
||||
st.dash = Some(Dash { pattern: pattern.to_vec(), offset: 0.0, align_corners: false });
|
||||
st
|
||||
}
|
||||
|
||||
/// `n` anchors 8 pt apart, up and down by 30 pt, every third segment curved.
|
||||
fn zigzag(n: usize) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
let at = |i: usize| Point::new(i as f64 * 8.0, if i.is_multiple_of(2) { 0.0 } else { 30.0 });
|
||||
bp.move_to(at(0));
|
||||
for i in 1..n {
|
||||
if i % 3 == 0 {
|
||||
bp.curve_to(at(i - 1) + Vec2::new(6.0, 0.0), at(i) + Vec2::new(-6.0, 10.0), at(i));
|
||||
} else {
|
||||
bp.line_to(at(i));
|
||||
}
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
/// A closed polygon of `n` anchors round a circle of radius `r` about the origin.
|
||||
fn ring(n: usize, r: f64) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for i in 0..n {
|
||||
let t = i as f64 / n as f64 * std::f64::consts::TAU;
|
||||
let p = Point::new(r * t.cos(), r * t.sin());
|
||||
if i == 0 { bp.move_to(p) } else { bp.line_to(p) }
|
||||
}
|
||||
bp.close_path();
|
||||
bp
|
||||
}
|
||||
|
||||
/// The documents of the cases: open and closed paths, short and long (outlined near the view), plain,
|
||||
/// dashed, with a width profile, with arrowheads, and filled with a gradient (which a move changes, so the moved
|
||||
/// object has an appearance of its own), each with the point the view is centered on.
|
||||
fn cases() -> Vec<(&'static str, (Document, NodeId), Point)> {
|
||||
let round = stroke(6.0, LineJoin::Round, LineCap::Round);
|
||||
let mut lens = stroke(10.0, LineJoin::Round, LineCap::Butt);
|
||||
lens.profile = Some(WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] });
|
||||
let mut arrows = stroke(4.0, LineJoin::Miter, LineCap::Butt);
|
||||
arrows.start_arrow = Some(Arrowhead::Triangle);
|
||||
arrows.end_arrow = Some(Arrowhead::Circle);
|
||||
arrows.arrow_align = ArrowAlign::Tip;
|
||||
let gradient = FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))));
|
||||
let only = |st: StrokeLayer| vec![AppearanceItem::Stroke(st)];
|
||||
vec![
|
||||
("a short open path", doc(&zigzag(20), only(stroke(5.0, LineJoin::Miter, LineCap::Square))), Point::new(70.0, 15.0)),
|
||||
("a short closed path, dashed", doc(&ring(40, 80.0), only(dashed(round.clone(), &[12.0, 7.0]))), Point::new(0.0, 0.0)),
|
||||
("a long open path", doc(&zigzag(3000), only(stroke(6.0, LineJoin::Miter, LineCap::Square))), Point::new(12_000.0, 15.0)),
|
||||
("a long closed path", doc(&ring(4000, 5000.0), only(round.clone())), Point::new(0.0, 5000.0)),
|
||||
(
|
||||
"a long open path, dashed",
|
||||
doc(&zigzag(3000), only(dashed(stroke(5.0, LineJoin::Bevel, LineCap::Butt), &[30.0, 12.0]))),
|
||||
Point::new(8000.0, 15.0),
|
||||
),
|
||||
(
|
||||
"a long closed path, dashed, filled with a gradient",
|
||||
doc(&ring(4000, 5000.0), vec![AppearanceItem::Fill(gradient), AppearanceItem::Stroke(dashed(round, &[25.0, 15.0]))]),
|
||||
Point::new(-5000.0, 0.0),
|
||||
),
|
||||
("a long open path, a width profile", doc(&zigzag(3000), only(lens)), Point::new(12_000.0, 15.0)),
|
||||
// The heads shorten the line, so the line is not the path itself.
|
||||
("a short open path, arrowheads", doc(&zigzag(20), only(arrows.clone())), Point::new(70.0, 15.0)),
|
||||
("a long open path, arrowheads", doc(&zigzag(3000), only(arrows)), Point::new(40.0, 15.0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Draws `d` after `change` with `r` and with a new renderer, checks that the two draw the same
|
||||
/// pixels, and returns how many stroke outlines `r` made.
|
||||
fn after(r: &mut Renderer, d: &mut Document, id: NodeId, at: Point, change: impl FnOnce(&mut Node)) -> usize {
|
||||
change(d.node_mut(id).unwrap());
|
||||
let before = outlined();
|
||||
let img = render(r, d, at);
|
||||
let made = outlined() - before;
|
||||
assert!(img.pixels == render(&mut Renderer::new(), d, at).pixels, "the same pixels as a new renderer");
|
||||
assert!(img.pixels.chunks(4).any(|p| p[0] < 128), "the stroke shows");
|
||||
made
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_path_draws_its_stroke_from_the_outline_made_before() {
|
||||
for (what, (mut d, id), at) in cases() {
|
||||
let mut r = Renderer::new();
|
||||
render(&mut r, &d, at);
|
||||
for by in [Vec2::new(1.0, 0.0), Vec2::new(0.25, -0.5), Vec2::new(-3.0, 2.0), Vec2::new(0.1, 0.0)] {
|
||||
let made = after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate(by), true));
|
||||
assert_eq!(made, 0, "{what}, moved by {by:?}: no new outline");
|
||||
}
|
||||
// A fill of another color keeps the path where it is.
|
||||
let made = after(&mut r, &mut d, id, at, |n| {
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
|
||||
f.paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
|
||||
}
|
||||
});
|
||||
assert_eq!(made, 0, "{what}, a fill changed: no new outline");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_changed_otherwise_outlines_its_stroke_again() {
|
||||
let edit_anchor = |n: &mut Node| {
|
||||
let NodeKind::Path { path, .. } = &mut n.kind else {
|
||||
panic!("not a path");
|
||||
};
|
||||
let mut bp = path.to_bezpath();
|
||||
let mut els = bp.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) {
|
||||
*p += Vec2::new(0.0, 3.0);
|
||||
}
|
||||
bp = BezPath::from_vec(els);
|
||||
*path = PathData::from_bezpath(&bp);
|
||||
};
|
||||
for (what, (mut d, id), at) in cases() {
|
||||
let mut r = Renderer::new();
|
||||
render(&mut r, &d, at);
|
||||
// A move, then an edit of an anchor near the path's start.
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((0.5, 0.0)), true)), 0, "{what}: moved");
|
||||
assert_eq!(after(&mut r, &mut d, id, at, edit_anchor), 1, "{what}: an anchor moved");
|
||||
// A move after the edit uses the outline made for the edited path.
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((0.5, 0.0)), true)), 0, "{what}: moved after the edit");
|
||||
// Scaled and rotated about the view's center, stroke weights kept.
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::scale_about(1.001, at), false)), 1, "{what}: scaled");
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::rotate_about(0.001, at), false)), 1, "{what}: rotated");
|
||||
// Another join.
|
||||
let rejoin = |n: &mut Node| {
|
||||
for i in n.appearance.items.iter_mut() {
|
||||
if let AppearanceItem::Stroke(s) = i {
|
||||
s.join = if s.join == LineJoin::Round { LineJoin::Bevel } else { LineJoin::Round };
|
||||
}
|
||||
}
|
||||
};
|
||||
assert_eq!(after(&mut r, &mut d, id, at, rejoin), 1, "{what}: another join");
|
||||
// Back to the first join and moved again: the outline of the join before is not used.
|
||||
assert_eq!(
|
||||
after(&mut r, &mut d, id, at, |n| {
|
||||
rejoin(n);
|
||||
n.transform(Affine::translate((1.0, 0.0)), true);
|
||||
}),
|
||||
1,
|
||||
"{what}: the join changed back and moved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that each case, drawn once and then changed by `change` (given the point the view is
|
||||
/// centered on), is drawn with one new outline and the pixels of a new renderer.
|
||||
fn outlined_again(change: impl Fn(&mut Node, Point)) {
|
||||
for (what, (mut d, id), at) in cases() {
|
||||
let mut r = Renderer::new();
|
||||
render(&mut r, &d, at);
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| change(n, at)), 1, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `change` to every stroke of `n`.
|
||||
fn restroke(n: &mut Node, change: impl Fn(&mut StrokeLayer)) {
|
||||
for i in n.appearance.items.iter_mut() {
|
||||
if let AppearanceItem::Stroke(s) = i {
|
||||
change(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheared_path_outlines_its_stroke_again() {
|
||||
// Sheared about the view's center, stroke weights kept.
|
||||
outlined_again(|n, at| n.transform(Affine::translate(at.to_vec2()) * Affine::skew(0.001, 0.0) * Affine::translate(-at.to_vec2()), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_stroke_width_outlines_the_stroke_again() {
|
||||
outlined_again(|n, _| restroke(n, |s| s.width += 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_cap_outlines_the_stroke_again() {
|
||||
outlined_again(|n, _| restroke(n, |s| s.cap = if s.cap == LineCap::Round { LineCap::Butt } else { LineCap::Round }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_dash_pattern_outlines_the_stroke_again() {
|
||||
outlined_again(|n, _| {
|
||||
restroke(n, |s| match &mut s.dash {
|
||||
Some(d) => d.pattern[0] += 4.0,
|
||||
None => s.dash = Some(Dash { pattern: vec![9.0, 5.0], offset: 0.0, align_corners: false }),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_width_profile_outlines_the_stroke_again() {
|
||||
outlined_again(|n, _| {
|
||||
restroke(n, |s| match &mut s.profile {
|
||||
Some(p) => p.points[1].1 = 0.5,
|
||||
None => s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.25, 0.25)] }),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_edit_far_from_the_origin_is_not_taken_for_a_move() {
|
||||
let far = Affine::translate((1e9, -1e9));
|
||||
let was = far * zigzag(20);
|
||||
let by = Vec2::new(0.25, 3.0);
|
||||
let moved = Affine::translate(by) * was.clone();
|
||||
assert_eq!(offset_between(&was, &moved), Some(moved.elements()[0].end_point().unwrap() - was.elements()[0].end_point().unwrap()));
|
||||
let mut els = moved.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) {
|
||||
p.y += 0.0005;
|
||||
}
|
||||
assert_eq!(offset_between(&was, &BezPath::from_vec(els)), None, "an anchor moved by 0.0005 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_moved_out_of_the_region_its_outline_was_cropped_to_is_outlined_again() {
|
||||
let (mut d, id) = doc(&zigzag(3000), vec![AppearanceItem::Stroke(stroke(6.0, LineJoin::Miter, LineCap::Square))]);
|
||||
let at = Point::new(12_000.0, 15.0);
|
||||
let mut r = Renderer::new();
|
||||
render(&mut r, &d, at);
|
||||
assert!(r.strokes.values().any(|e| e.crop.is_some()), "outlined near the view");
|
||||
// The outline reaches the view's size past each side of it: 200 pt.
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((150.0, 0.0)), true)), 0, "moved within the region");
|
||||
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((150.0, 0.0)), true)), 1, "moved past it");
|
||||
}
|
||||
@@ -1269,6 +1269,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`).
|
||||
The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document).
|
||||
The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`).
|
||||
When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile.
|
||||
When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 128 ms instead of 405 ms with an open path of 200,000 anchors in view (104 ms with nothing changed), and 14 ms instead of 28 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile.
|
||||
The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`).
|
||||
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
|
||||
A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460).
|
||||
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
|
||||
|
||||
Reference in New Issue
Block a user