mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 13:23:22 +00:00
Render: a long dashed stroke is outlined only near the view after an edit
A dashed stroke of a path with more than 1,024 path elements was outlined whole in every frame after an edit. It is now outlined in and around the view like a solid stroke. dash_kept runs the pattern along every segment and keeps only the dashes and dots on the segments near the view, so they sit where dashing the whole path puts them and the pixels are the same. A pattern too fine to draw is still drawn solid, and that solid stroke is cropped too. At 1600 x 1200 the frame after one anchor moves takes 14 ms instead of 102 ms on a filled closed path of 50,000 anchors with 6 pt dashes and 4 pt gaps. anchor_bench now includes that document.
This commit is contained in:
@@ -24,7 +24,7 @@ use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
|
||||
|
||||
pub use arrow::Arrow;
|
||||
pub use dash::{Dashed, Dot, dash, dot_outline};
|
||||
pub use dash::{Dashed, Dot, dash, dash_kept, dot_outline};
|
||||
pub use gradient::{Slice, WrittenSlices, gradient_meshes, gradient_slices, written_slices};
|
||||
pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region};
|
||||
pub use width::width_outline;
|
||||
@@ -111,11 +111,7 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
}
|
||||
out
|
||||
}
|
||||
(None, Some(d)) => {
|
||||
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
|
||||
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
(None, Some(d)) => dashed_outline(&d, st, width, tol),
|
||||
(None, None) => {
|
||||
let mut out = plain_outline(line, &style(st, width), tol);
|
||||
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
|
||||
@@ -124,6 +120,28 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
}
|
||||
}
|
||||
|
||||
/// [`line_outline`] of a dashed line without a width profile, made of only the dashes and dots on
|
||||
/// the segments of `line` that `keep` accepts ([`dash_kept`]). `None` when `st` has no dash
|
||||
/// pattern or has a width profile, or when its pattern draws the line solid.
|
||||
pub fn dashed_line_outline_kept(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64, keep: &dyn Fn(&PathSeg) -> bool) -> Option<BezPath> {
|
||||
if st.profile.is_some() {
|
||||
return None;
|
||||
}
|
||||
let d = dash_kept(line, st.dash.as_ref()?, keep)?;
|
||||
if width <= 0.0 || !width.is_finite() {
|
||||
return Some(BezPath::new());
|
||||
}
|
||||
Some(dashed_outline(&d, st, width, tol.max(1e-4)))
|
||||
}
|
||||
|
||||
/// The filled outline of the dashes and dots `d` of a stroke `st` without a width profile, at
|
||||
/// `width` and flattened/fitted to `tol`.
|
||||
fn dashed_outline(d: &Dashed, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
|
||||
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
|
||||
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
|
||||
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
|
||||
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
|
||||
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
|
||||
|
||||
@@ -45,6 +45,14 @@ pub struct Dashed {
|
||||
/// entry, as in SVG and PDF) or has no positive length (the line is solid), or is too fine to draw
|
||||
/// (then the line is drawn solid too).
|
||||
pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
|
||||
dash_kept(bp, d, &|_| true)
|
||||
}
|
||||
|
||||
/// [`dash`] with only the dashes and dots on the segments of `bp` that `keep` accepts. The pattern
|
||||
/// runs along every segment as in [`dash`], so a kept dash lies where [`dash`] puts it: a dash
|
||||
/// running onto a segment left out ends where that segment starts and goes on from where the next
|
||||
/// kept segment starts. Segments left out still count toward the limit on the number of dashes.
|
||||
pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Option<Dashed> {
|
||||
if d.pattern.iter().any(|v| !v.is_finite() || *v < 0.0) || !d.offset.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -82,7 +90,7 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
|
||||
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
|
||||
let mut out = Dashed::default();
|
||||
for plan in &plans {
|
||||
dash_subpath(&mut out, plan, &pat, phase);
|
||||
dash_subpath(&mut out, plan, &pat, phase, keep);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -175,7 +183,7 @@ impl Piece {
|
||||
}
|
||||
}
|
||||
|
||||
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
|
||||
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) {
|
||||
let first_dot = out.dots.len();
|
||||
let total: f64 = plan.lens.iter().sum();
|
||||
// Fraction of the subpath's length at `x` along the plan.
|
||||
@@ -188,7 +196,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
|
||||
};
|
||||
let mut dashes: Vec<Piece> = vec![];
|
||||
let mut cur = Piece::default();
|
||||
let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false);
|
||||
let (mut cuts, mut acc, mut starts_on, mut left_out) = (0, 0.0, false, false);
|
||||
for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() {
|
||||
let k = plan.scale[i];
|
||||
if plan.restart[i] {
|
||||
@@ -199,21 +207,30 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
|
||||
}
|
||||
ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true };
|
||||
}
|
||||
// A segment left out ends the dash running onto it; the pattern still advances along it.
|
||||
let kept = keep(seg);
|
||||
if !kept {
|
||||
left_out = true;
|
||||
if !cur.segs.is_empty() {
|
||||
cur.end = acc;
|
||||
dashes.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
if i == 0 {
|
||||
starts_on = ph.on && ph.rem > 0.0;
|
||||
starts_on = kept && ph.on && ph.rem > 0.0;
|
||||
}
|
||||
let mut s = 0.0;
|
||||
loop {
|
||||
let left = len - s;
|
||||
if ph.rem > left + EPS {
|
||||
if ph.on && left > EPS {
|
||||
if kept && ph.on && left > EPS {
|
||||
cur.push(sub(seg, len, s, len), acc + s);
|
||||
}
|
||||
ph.rem -= left;
|
||||
break;
|
||||
}
|
||||
let end = (s + ph.rem).min(len);
|
||||
if ph.on {
|
||||
if kept && ph.on {
|
||||
if pat[ph.ix] > 0.0 {
|
||||
if end > s {
|
||||
cur.push(sub(seg, len, s, end), acc + s);
|
||||
@@ -240,6 +257,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
|
||||
&& plan.restart[0]
|
||||
&& pat[0] <= 0.0
|
||||
&& let Some(seg) = plan.segs.last()
|
||||
&& keep(seg)
|
||||
{
|
||||
push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 });
|
||||
}
|
||||
@@ -249,7 +267,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
|
||||
dashes.push(cur);
|
||||
}
|
||||
if plan.closed {
|
||||
if cuts == 0 && ends_on {
|
||||
if cuts == 0 && ends_on && !left_out {
|
||||
// One dash all the way round: keep it closed so the start gets a join, not caps.
|
||||
let d = dashes.pop().unwrap_or_default();
|
||||
write(&mut out.path, &d.segs);
|
||||
|
||||
@@ -110,6 +110,80 @@ fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
|
||||
assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none());
|
||||
}
|
||||
|
||||
/// The dashes of `d` as (start x, end x), for dashes along the x axis.
|
||||
fn spans_x(d: &Dashed) -> Vec<(f64, f64)> {
|
||||
let mut out = vec![];
|
||||
for el in d.path.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => out.push((p.x, p.x)),
|
||||
PathEl::LineTo(p) => {
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.1 = p.x;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ten segments of 10 pt along the x axis from 0 to 100.
|
||||
fn steps() -> BezPath {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
for i in 1..=10 {
|
||||
b.line_to((f64::from(i) * 10.0, 0.0));
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Whether a segment lies outside x 30..60.
|
||||
fn outside(s: &PathSeg) -> bool {
|
||||
s.start().x >= 60.0 || s.end().x <= 30.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_kept_on_some_segments_keep_the_phase_of_the_whole_line() {
|
||||
// A period of 7 starting 2 into it: dashes at -2..2, 5..9, 12..16, …
|
||||
let pattern = dashed(&[4.0, 3.0], 2.0);
|
||||
let all = dash(&steps(), &pattern).unwrap();
|
||||
let kept = dash_kept(&steps(), &pattern, &|s| outside(s)).unwrap();
|
||||
// The dashes of the whole line, cut at x 30 and 60.
|
||||
let cut: Vec<(f64, f64)> =
|
||||
spans_x(&all).into_iter().flat_map(|(a, b)| [(a, b.min(30.0)), (a.max(60.0), b)]).filter(|(a, b)| b - a > 1e-9).collect();
|
||||
let got = spans_x(&kept);
|
||||
assert_eq!(got.len(), cut.len(), "{got:?} against {cut:?}");
|
||||
for ((a, b), (c, d)) in got.iter().zip(&cut) {
|
||||
assert!(near(*a, *c, 1e-9) && near(*b, *d, 1e-9), "{got:?} against {cut:?}");
|
||||
}
|
||||
// The dash after the gap starts where the whole line's does, at 61, not at 60.
|
||||
assert!(got.iter().any(|(a, _)| near(*a, 61.0, 1e-9)), "{got:?}");
|
||||
// Dots on segments left out are left out (the dot at 60 ends a segment left out).
|
||||
let dots = dash_kept(&steps(), &dashed(&[0.0, 5.0], 0.0), &|s| outside(s)).unwrap();
|
||||
let xs: Vec<f64> = dots.dots.iter().map(|d| d.at.x).collect();
|
||||
let want = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 65.0, 70.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0];
|
||||
assert!(xs.len() == want.len() && xs.iter().zip(want).all(|(x, w)| near(*x, w, 1e-9)), "{xs:?}");
|
||||
// Keeping every segment is the whole line's dashing, and keeping none leaves nothing.
|
||||
assert_eq!(dash_kept(&steps(), &pattern, &|_| true).unwrap().path, all.path);
|
||||
let none = dash_kept(&steps(), &pattern, &|_| false).unwrap();
|
||||
assert!(none.path.elements().is_empty() && none.dots.is_empty());
|
||||
// A pattern too fine for the whole line draws it solid, however few segments are kept.
|
||||
assert!(dash_kept(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0), &|s| s.start().x < 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_path_with_segments_left_out_is_dashed_in_open_pieces() {
|
||||
// One dash all the way round: with the right side left out it is no longer closed.
|
||||
let keep_left = |s: &PathSeg| s.start().x < 5.0 || s.end().x < 5.0;
|
||||
let whole = dash_kept(&square(10.0), &dashed(&[100.0, 1.0], 0.0), &keep_left).unwrap();
|
||||
assert!(!is_closed(&whole.path), "{:?}", whole.path.to_svg());
|
||||
// The bottom side left out: of the 10 dashes, the one through the start point stays one dash,
|
||||
// the one into the bottom side is cut and the two on it are left out.
|
||||
let kept = dash_kept(&square(100.0), &dashed(&[20.0, 20.0], 10.0), &|s| s.start().y < 50.0 || s.end().y < 50.0).unwrap();
|
||||
assert_eq!(moves(&kept.path), 8, "{}", kept.path.to_svg());
|
||||
assert!(!kept.path.elements().iter().any(|e| matches!(e, PathEl::MoveTo(p) if p.distance(Point::ZERO) < 1e-9)), "{}", kept.path.to_svg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
|
||||
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `cargo run --release -p vectorcraft-engine --example anchor_bench` prints the wall time of the
|
||||
//! engine steps of editing anchors with Direct Selection, on three synthetic documents: one closed path of
|
||||
//! 50,000 anchors, 2,000 closed paths of 50 anchors each, and one open path of 200,000 anchors.
|
||||
//! engine steps of editing anchors with Direct Selection, on four synthetic documents: one closed path of
|
||||
//! 50,000 anchors, 2,000 closed paths of 50 anchors each, one open path of 200,000 anchors, and the closed
|
||||
//! path of 50,000 anchors filled and with a dashed stroke.
|
||||
//! `ITERS` sets the runs per step (default 7); each row is the median in ms.
|
||||
// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
@@ -10,7 +11,7 @@ use std::time::Instant;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, NodeId};
|
||||
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, SubPath, Vec2};
|
||||
@@ -70,6 +71,20 @@ fn one_outline() -> Shape {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`one_outline`] filled, with a 1 pt stroke of 6 pt dashes and 4 pt gaps.
|
||||
fn dashed_outline() -> Shape {
|
||||
let mut shape = one_outline();
|
||||
let mut look = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.2)), Paint::solid(Color::BLACK), 1.0);
|
||||
for item in &mut look.items {
|
||||
if let AppearanceItem::Stroke(st) = item {
|
||||
st.dash = Some(Dash { pattern: vec![6.0, 4.0], offset: 0.0, align_corners: false });
|
||||
}
|
||||
}
|
||||
shape.doc.node_mut(shape.ids[0]).unwrap().appearance = look.into();
|
||||
shape.name = "one closed path, 50,000 anchors, filled, dashed stroke";
|
||||
shape
|
||||
}
|
||||
|
||||
fn many_paths() -> Shape {
|
||||
let mut doc = Document::new(1600.0, 1200.0);
|
||||
let ids: Vec<NodeId> =
|
||||
@@ -317,4 +332,5 @@ fn main() {
|
||||
bench(one_outline());
|
||||
bench(many_paths());
|
||||
bench(long_open_path());
|
||||
bench(dashed_outline());
|
||||
}
|
||||
|
||||
@@ -269,10 +269,21 @@ impl Renderer {
|
||||
e.stamp = self.stamp;
|
||||
return Some(e.outline.clone());
|
||||
}
|
||||
// A long stroke without dashes or a width profile is outlined only in and around the view,
|
||||
// with a margin of the view's own size on each side ([`crop_line`]).
|
||||
// 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).
|
||||
let region = v.inflate(v.width(), v.height());
|
||||
let tol = 2f64.powi(level);
|
||||
if crops()
|
||||
&& line.elements().len() > CROP_ELEMENTS
|
||||
&& st.dash.is_some()
|
||||
&& st.profile.is_none()
|
||||
&& 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) });
|
||||
return Some(outline);
|
||||
}
|
||||
let cropped = (crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_none() && st.profile.is_none())
|
||||
.then(|| crop_line(line, region, crop_reach(st, width, f.px, tol), width))
|
||||
.flatten();
|
||||
@@ -316,6 +327,38 @@ pub(crate) fn crop_reach(st: &StrokeLayer, width: f64, px: f64, tol: f64) -> f64
|
||||
width / 2.0 * corner + 2.0 * px + tol
|
||||
}
|
||||
|
||||
/// Whether the box of segment `s`'s control points meets `near`. A coordinate that is not a number
|
||||
/// counts as near.
|
||||
fn is_near(s: &kurbo::PathSeg, near: Rect) -> bool {
|
||||
use kurbo::PathSeg;
|
||||
let pts: &[kurbo::Point] = match s {
|
||||
PathSeg::Line(l) => &[l.p0, l.p1],
|
||||
PathSeg::Quad(q) => &[q.p0, q.p1, q.p2],
|
||||
PathSeg::Cubic(c) => &[c.p0, c.p1, c.p2, c.p3],
|
||||
};
|
||||
!(pts.iter().all(|p| p.x < near.x0)
|
||||
|| pts.iter().all(|p| p.x > near.x1)
|
||||
|| pts.iter().all(|p| p.y < near.y0)
|
||||
|| pts.iter().all(|p| p.y > near.y1))
|
||||
}
|
||||
|
||||
/// The outline of the line `line` of dashed stroke `st` (without a width profile) at `width`,
|
||||
/// flattened to `tol`, made from the segments within `reach` of `region` only, for drawing in
|
||||
/// `region`. The dashes and dots on those segments sit where the whole line puts them
|
||||
/// ([`effects::stroke::dash_kept`]); a pattern that draws the stroke solid is outlined as a solid
|
||||
/// line along [`crop_line`]. `None` when every segment is kept.
|
||||
fn crop_dashed(line: &BezPath, st: &StrokeLayer, width: f64, region: Rect, reach: f64, tol: f64) -> Option<BezPath> {
|
||||
let near = region.inflate(reach, reach);
|
||||
if kurbo::segments(line.elements().iter().copied()).all(|s| is_near(&s, near)) {
|
||||
return None;
|
||||
}
|
||||
if let Some(outline) = effects::stroke::dashed_line_outline_kept(line, st, width, tol, &|s| is_near(s, near)) {
|
||||
return Some(outline);
|
||||
}
|
||||
let solid = StrokeLayer { dash: None, ..st.clone() };
|
||||
crop_line(line, region, reach, width).map(|part| effects::stroke::line_outline(&part, &solid, width, tol))
|
||||
}
|
||||
|
||||
/// The stroke line `line` without the segments whose control points' box lies more than `reach`
|
||||
/// from `region`, for outlining a stroke of `width` that is drawn only in `region`. A subpath that
|
||||
/// loses some of its segments is replaced by its runs of kept segments, each an open subpath; a
|
||||
@@ -326,18 +369,7 @@ pub(crate) fn crop_reach(st: &StrokeLayer, width: f64, px: f64, tol: f64) -> f64
|
||||
pub(crate) fn crop_line(line: &BezPath, region: Rect, reach: f64, width: f64) -> Option<BezPath> {
|
||||
use kurbo::{PathEl, PathSeg};
|
||||
let near = region.inflate(reach, reach);
|
||||
let keep = |s: &PathSeg| {
|
||||
let pts: &[kurbo::Point] = match s {
|
||||
PathSeg::Line(l) => &[l.p0, l.p1],
|
||||
PathSeg::Quad(q) => &[q.p0, q.p1, q.p2],
|
||||
PathSeg::Cubic(c) => &[c.p0, c.p1, c.p2, c.p3],
|
||||
};
|
||||
// A coordinate that is not a number keeps the segment.
|
||||
!(pts.iter().all(|p| p.x < near.x0)
|
||||
|| pts.iter().all(|p| p.x > near.x1)
|
||||
|| pts.iter().all(|p| p.y < near.y0)
|
||||
|| pts.iter().all(|p| p.y > near.y1))
|
||||
};
|
||||
let keep = |s: &PathSeg| is_near(s, near);
|
||||
let els = line.elements();
|
||||
let mut starts: Vec<usize> = els.iter().enumerate().filter(|(_, e)| matches!(e, PathEl::MoveTo(_))).map(|(i, _)| i).collect();
|
||||
if starts.first() != Some(&0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! A long stroke without dashes or a width profile is outlined in and around the view only
|
||||
//! (`crop_line`), and draws the same pixels as when it is outlined whole.
|
||||
//! A long stroke without a width profile is outlined in and around the view only (`crop_line`,
|
||||
//! and for dashes `dash_kept`), and draws the same pixels as when it is outlined whole.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, Node, StrokeLayer};
|
||||
@@ -12,8 +12,13 @@ const SIZE: u32 = 200;
|
||||
|
||||
/// A document holding `path` stroked with `st` and no fill.
|
||||
fn doc(path: &BezPath, st: StrokeLayer) -> Document {
|
||||
doc_with(path, vec![AppearanceItem::Stroke(st)])
|
||||
}
|
||||
|
||||
/// A document holding `path` painted with `items`.
|
||||
fn doc_with(path: &BezPath, items: Vec<AppearanceItem>) -> Document {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let n = Node::path(d.alloc_id(), PathData::from_bezpath(path), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||||
let n = Node::path(d.alloc_id(), PathData::from_bezpath(path), Appearance { items, ..Default::default() });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
@@ -98,6 +103,110 @@ fn a_long_stroke_draws_the_same_pixels_outlined_near_the_view() {
|
||||
}
|
||||
}
|
||||
|
||||
fn dashed(mut st: StrokeLayer, pattern: &[f64], offset: f64, align_corners: bool) -> StrokeLayer {
|
||||
st.dash = Some(vectorcraft_doc::Dash { pattern: pattern.to_vec(), offset, align_corners });
|
||||
st
|
||||
}
|
||||
|
||||
/// A document holding `path` filled red and stroked with `st`.
|
||||
fn filled(path: &BezPath, st: StrokeLayer) -> Document {
|
||||
let fill = AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||||
doc_with(path, vec![fill, AppearanceItem::Stroke(st)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_dashed_stroke_draws_the_same_pixels_outlined_near_the_view() {
|
||||
let round = stroke(6.0, LineJoin::Round, LineCap::Round);
|
||||
let mut two_rings = ring(4000, 5000.0);
|
||||
two_rings.extend(ring(3000, 4980.0).iter());
|
||||
let cases = [
|
||||
(
|
||||
"zigzag, miter joins, square caps",
|
||||
doc(&zigzag(), dashed(stroke(6.0, LineJoin::Miter, LineCap::Square), &[30.0, 12.0], 7.0, false)),
|
||||
Point::new(12_000.0, 15.0),
|
||||
),
|
||||
(
|
||||
"ring through its start, a dash across it",
|
||||
doc(&ring(4000, 5000.0), dashed(round.clone(), &[40.0, 25.0], 0.0, false)),
|
||||
Point::new(5000.0, 0.0),
|
||||
),
|
||||
("ring, dots", doc(&ring(4000, 5000.0), dashed(round.clone(), &[0.0, 9.0], 0.0, false)), Point::new(0.0, 5000.0)),
|
||||
("ring filled, dashed", filled(&ring(4000, 5000.0), dashed(round.clone(), &[15.0, 10.0, 0.0, 10.0], 4.0, false)), Point::new(-5000.0, 0.0)),
|
||||
(
|
||||
"zigzag, dashes fitted to corners",
|
||||
doc(&zigzag(), dashed(stroke(4.0, LineJoin::Bevel, LineCap::Butt), &[5.0, 3.0], 0.0, true)),
|
||||
Point::new(8000.0, 15.0),
|
||||
),
|
||||
("two subpaths, dashed", doc(&two_rings, dashed(round.clone(), &[25.0, 15.0], 0.0, false)), Point::new(0.0, -4990.0)),
|
||||
("a pattern too fine, drawn solid", doc(&zigzag(), dashed(round, &[0.001, 0.001], 0.0, false)), Point::new(12_000.0, 15.0)),
|
||||
];
|
||||
for (what, d, at) in &cases {
|
||||
let mut r = Renderer::new();
|
||||
let img = render(&mut r, d, *at);
|
||||
assert!(r.strokes.values().any(|e| e.crop.is_some()), "{what}: outlined near the view");
|
||||
assert!(img.pixels == whole(d, *at).pixels, "{what}: the same pixels as the whole outline");
|
||||
assert!(img.pixels.chunks(4).any(|p| p[0] < 128), "{what}: the stroke shows");
|
||||
for step in [Point::new(60.0, 40.0), Point::new(450.0, 0.0), Point::new(-900.0, 300.0)] {
|
||||
let next = *at + step.to_vec2();
|
||||
assert!(render(&mut r, d, next).pixels == whole(d, next).pixels, "{what}, moved by {step:?}: the same pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_dashed_stroke_draws_the_same_pixels_after_its_path_changes() {
|
||||
let st = dashed(stroke(5.0, LineJoin::Miter, LineCap::Butt), &[18.0, 7.0], 3.0, false);
|
||||
let at = Point::new(12_000.0, 15.0);
|
||||
let mut r = Renderer::new();
|
||||
render(&mut r, &doc(&zigzag(), st.clone()), at);
|
||||
// An anchor in the view moved, then one far before it, which shifts every dash after it.
|
||||
for (i, by) in [(1500, Vec2::new(0.0, 12.0)), (40, Vec2::new(-3.0, 0.0))] {
|
||||
let mut bp = zigzag();
|
||||
let mut els = bp.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(i) {
|
||||
*p += by;
|
||||
}
|
||||
bp = BezPath::from_vec(els);
|
||||
let d = doc(&bp, st.clone());
|
||||
assert!(render(&mut r, &d, at).pixels == whole(&d, at).pixels, "anchor {i} moved: the same pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_dashed_strokes_are_outlined_whole() {
|
||||
let st = dashed(stroke(4.0, LineJoin::Round, LineCap::Round), &[6.0, 4.0], 0.0, false);
|
||||
let one = BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(100.0, 100.0))]);
|
||||
let mut dot = one.clone();
|
||||
dot.line_to((100.0, 100.0));
|
||||
for (what, bp) in
|
||||
[("empty", BezPath::new()), ("one anchor", one), ("one anchor, closed on itself", dot), ("a ring of 40 anchors", ring(40, 80.0))]
|
||||
{
|
||||
let d = doc(&bp, st.clone());
|
||||
let mut r = Renderer::new();
|
||||
let img = render(&mut r, &d, Point::new(100.0, 100.0));
|
||||
assert!(r.strokes.values().all(|e| e.crop.is_none()), "{what}: outlined whole");
|
||||
assert!(img.pixels == whole(&d, Point::new(100.0, 100.0)).pixels, "{what}: the same pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dashed_stroke_a_wide_stroke_crosses_over_draws_the_same_pixels() {
|
||||
// The closed sliver of `a_stroke_a_wide_stroke_crosses_over_stays_whole`, dashed.
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
for i in 1..=2000 {
|
||||
bp.line_to((f64::from(i) * 10.0, 0.0));
|
||||
}
|
||||
bp.line_to((20_000.0, 4.0));
|
||||
bp.line_to((0.0, 4.0));
|
||||
bp.close_path();
|
||||
let d = doc(&bp, dashed(stroke(10.0, LineJoin::Miter, LineCap::Butt), &[35.0, 15.0], 0.0, false));
|
||||
for at in [Point::new(20_000.0, 2.0), Point::new(9_000.0, 2.0), Point::new(0.0, 2.0)] {
|
||||
let mut r = Renderer::new();
|
||||
assert!(render(&mut r, &d, at).pixels == whole(&d, at).pixels, "at {at:?}: the same pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// A zigzag of 3,000 anchors 8 pt apart between y = 60 and y = 550, through the bottom of a view
|
||||
/// on y = 0.
|
||||
fn tall_zigzag() -> BezPath {
|
||||
|
||||
@@ -1262,6 +1262,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429).
|
||||
With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets.
|
||||
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).
|
||||
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