Files
photocraft/crates/vector/src/stroke.rs
T
bcaadccbfa Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204)
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:02:09 -07:00

592 lines
24 KiB
Rust

//! Polygon stroking: a stroke is the union of one quad per segment, a wedge per join and a
//! cap shape per open end. Every piece is emitted with positive orientation, so filling them
//! together with the non-zero rule yields their exact union without computing offset curves.
//! Dashing splits the flattened polylines by arc length first.
use std::borrow::Cow;
use photocraft_doc::{LineCap, LineJoin};
use crate::flatten::Polyline;
/// Stroke geometry in pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeStyle {
pub width: f64,
pub cap: LineCap,
pub join: LineJoin,
/// Miter limit as a multiple of the width (ratio miter length / width, like SVG).
pub miter_limit: f64,
/// Dash pattern in px (`on, off, on, off…`); empty = solid.
pub dashes: Vec<f64>,
pub dash_offset: f64,
}
impl Default for StrokeStyle {
fn default() -> Self {
StrokeStyle { width: 1.0, cap: LineCap::Butt, join: LineJoin::Miter, miter_limit: 4.0, dashes: Vec::new(), dash_offset: 0.0 }
}
}
type P = (f64, f64);
fn sub(a: P, b: P) -> P {
(a.0 - b.0, a.1 - b.1)
}
fn add(a: P, b: P) -> P {
(a.0 + b.0, a.1 + b.1)
}
fn mul(a: P, k: f64) -> P {
(a.0 * k, a.1 * k)
}
fn len(a: P) -> f64 {
a.0.hypot(a.1)
}
fn norm(a: P) -> P {
let l = len(a);
if l > 0.0 { (a.0 / l, a.1 / l) } else { (0.0, 0.0) }
}
/// Left normal in y-down coordinates.
fn perp(a: P) -> P {
(a.1, -a.0)
}
fn cross(a: P, b: P) -> f64 {
a.0 * b.1 - a.1 * b.0
}
fn dot(a: P, b: P) -> f64 {
a.0 * b.0 + a.1 * b.1
}
fn signed_area(p: &[P]) -> f64 {
let n = p.len();
(0..n).map(|i| cross(p[i], p[(i + 1) % n])).sum::<f64>() * 0.5
}
/// Pushes `poly` with positive orientation (drops degenerate pieces).
fn emit(out: &mut Vec<Vec<P>>, mut poly: Vec<P>) {
let a = signed_area(&poly);
if a.abs() < 1e-12 {
return;
}
if a < 0.0 {
poly.reverse();
}
out.push(poly);
}
/// Angular step so that arc chords deviate at most `tol` from a circle of radius `r`.
fn arc_step(r: f64, tol: f64) -> f64 {
if r <= tol {
return std::f64::consts::FRAC_PI_2;
}
(2.0 * (1.0 - tol / r).clamp(-1.0, 1.0).acos()).clamp(0.01, std::f64::consts::FRAC_PI_2)
}
/// Vertex radius for chords of `step` radians so each chord's pie slice has the area of the
/// circular sector (keeps round caps/joins area-exact at any tessellation).
fn area_radius(r: f64, step: f64) -> f64 {
if step < 1e-9 { r } else { r * (step / step.sin()).sqrt() }
}
/// Points a full circle needs at `tol` (at least 8).
fn circle_points(r: f64, tol: f64) -> usize {
((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8)
}
/// A circle of at most `arc_cap` points (`usize::MAX` = no limit, at least 4).
fn circle(c: P, r: f64, tol: f64, arc_cap: usize) -> Vec<P> {
let n = circle_points(r, tol).min(arc_cap).max(4);
let rr = area_radius(r, std::f64::consts::TAU / n as f64);
(0..n)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / n as f64;
(c.0 + rr * a.cos(), c.1 + rr * a.sin())
})
.collect()
}
/// Dash steps one stroke may spend, shared by all of its lines: it bounds the number of dashes
/// (and so of caps) for file-provided patterns. Past it, the rest is drawn solid.
const DASH_STEP_BUDGET: usize = 100_000;
/// Outline points a stroke may spend before its round caps and joins get coarser (#5211).
const OUTLINE_POINT_BUDGET: usize = 1 << 20;
/// Stroke outline pieces for `lines`. Fill them with the non-zero rule.
///
/// The outline is bounded: when full-resolution round caps and joins would exceed
/// [`OUTLINE_POINT_BUDGET`] points, every round cap and join gets the same, smaller number of
/// segments (down to 4 per circle and 1 per join; areas stay exact). Strokes under the budget
/// are unchanged and no piece is dropped, so the total is at most the larger of the budget and
/// that minimal tessellation, which is linear in the (budgeted) flattened points and dashes.
pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec<Vec<P>> {
let mut out = Vec::new();
if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 {
return out;
}
let mut steps_left = DASH_STEP_BUDGET;
let mut pieces: Vec<Cow<Polyline>> = Vec::new();
for pl in lines {
if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
continue;
}
if !style.dashes.is_empty() {
pieces.extend(dash_polyline_budgeted(pl, &style.dashes, style.dash_offset, &mut steps_left).into_iter().map(Cow::Owned));
} else {
pieces.push(Cow::Borrowed(pl));
}
}
let arc_cap = round_arc_cap(&pieces, style, tol);
for pl in &pieces {
stroke_one(pl, style, tol, arc_cap, &mut out);
}
out
}
/// Points per full circle allowed for round caps and joins so the outline of `pieces` stays
/// within [`OUTLINE_POINT_BUDGET`]; `usize::MAX` when full resolution fits.
fn round_arc_cap(pieces: &[Cow<Polyline>], style: &StrokeStyle, tol: f64) -> usize {
let (round_cap, round_join) = (style.cap == LineCap::Round, style.join == LineJoin::Round);
if !round_cap && !round_join {
return usize::MAX;
}
// Upper bounds: ribbons and miter/bevel pieces, full circles (caps and dots), round joins.
let (mut base, mut circles, mut joins) = (0usize, 0usize, 0usize);
for pl in pieces {
let m = pl.pts.len();
if m == 0 {
continue;
}
base = base.saturating_add(m.saturating_mul(6)).saturating_add(8);
if round_cap && !(pl.closed && m > 2) {
circles = circles.saturating_add(if m == 1 { 1 } else { 2 });
}
if round_join {
joins = joins.saturating_add(m);
}
}
// A join sweeps at most half a circle, plus its centre and two end points.
let cost = |n: usize| base.saturating_add(circles.saturating_mul(n)).saturating_add(joins.saturating_mul(n / 2 + 3));
let full = circle_points(style.width * 0.5, tol);
if cost(full) <= OUTLINE_POINT_BUDGET {
return usize::MAX;
}
let left = OUTLINE_POINT_BUDGET.saturating_sub(base).saturating_sub(joins.saturating_mul(3)) as f64;
let per_circle = circles as f64 + joins as f64 * 0.5;
((left / per_circle.max(1.0)) as usize).clamp(4, full)
}
fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, arc_cap: usize, out: &mut Vec<Vec<P>>) {
let hw = style.width * 0.5;
// Drop repeated points.
let mut pts: Vec<P> = Vec::with_capacity(pl.pts.len());
let mut knot: Vec<bool> = Vec::with_capacity(pl.pts.len());
for (i, p) in pl.pts.iter().enumerate() {
if pts.last().is_some_and(|q| len(sub(*p, *q)) < 1e-9) {
continue;
}
pts.push(*p);
knot.push(pl.knot.get(i).copied().unwrap_or(true));
}
let mut closed = pl.closed;
if closed && pts.len() > 1 && len(sub(pts[0], pts[pts.len() - 1])) < 1e-9 {
pts.pop();
knot.pop();
}
if pts.len() < 3 {
closed = false;
}
if pts.is_empty() {
return;
}
if pts.len() == 1 {
// A dot: only round and square caps draw something.
let c = pts[0];
match style.cap {
LineCap::Round => emit(out, circle(c, hw, tol, arc_cap)),
LineCap::Square => emit(out, vec![(c.0 - hw, c.1 - hw), (c.0 + hw, c.1 - hw), (c.0 + hw, c.1 + hw), (c.0 - hw, c.1 + hw)]),
LineCap::Butt => {}
}
return;
}
let n = pts.len();
let segs = if closed { n } else { n - 1 };
let dir = |i: usize| norm(sub(pts[(i + 1) % n], pts[i]));
let seg_len = |i: usize| len(sub(pts[(i + 1) % n], pts[i]));
// A vertex continues a ribbon (no separate join piece) when it is a flattening point with a
// small turn whose inner offset does not fold back, or a knot where the path is straight.
let cos_smooth = 25f64.to_radians().cos();
let smooth = |j: usize| -> bool {
if j == 0 || (!closed && j == n - 1) {
return false;
}
let (i0, i1) = ((j + n - 1) % n, j);
let d = dot(dir(i0), dir(i1));
if knot[j] {
return d > 1.0 - 1e-12;
}
if d < cos_smooth {
return false;
}
let tan_half = ((1.0 - d) / (1.0 + d)).max(0.0).sqrt();
hw * tan_half < 0.5 * seg_len(i0).min(seg_len(i1))
};
let mut left: Vec<P> = Vec::new();
let mut right: Vec<P> = Vec::new();
for i in 0..segs {
let a = pts[i];
let b = pts[(i + 1) % n];
let nrm = mul(perp(dir(i)), hw);
if left.is_empty() {
left.push(add(a, nrm));
right.push(sub(a, nrm));
}
let j = (i + 1) % n;
if i + 1 < segs + usize::from(!closed) && smooth(j) {
// Offset lines of both segments meet at v ± bisector · hw / cos(θ/2).
let n2 = mul(perp(dir(j)), hw);
let bis = norm(add(nrm, n2));
let cos_half = ((1.0 + dot(dir(i), dir(j))) * 0.5).max(1e-9).sqrt();
let off = mul(bis, hw / cos_half);
left.push(add(b, off));
right.push(sub(b, off));
continue;
}
left.push(add(b, nrm));
right.push(sub(b, nrm));
let mut poly = std::mem::take(&mut left);
poly.extend(right.drain(..).rev());
emit(out, poly);
let is_join = closed || j != n - 1;
if is_join {
let prev = pts[i];
let v = pts[j];
let next = pts[(j + 1) % n];
let d1 = norm(sub(v, prev));
let d2 = norm(sub(next, v));
// Flattening points use miters (the true offset of a smooth curve) unless the
// requested join is round; bevels only at real corners.
let join = match (style.join, knot[j]) {
(LineJoin::Round, _) => LineJoin::Round,
(jn, true) => jn,
(_, false) => LineJoin::Miter,
};
join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, arc_cap, out);
}
}
if !closed {
let d0 = norm(sub(pts[1], pts[0]));
let d1 = norm(sub(pts[n - 1], pts[n - 2]));
cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, arc_cap, out);
cap_piece(pts[n - 1], d1, hw, style.cap, tol, arc_cap, out);
}
}
#[allow(clippy::too_many_arguments)]
fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, arc_cap: usize, out: &mut Vec<Vec<P>>) {
let c = cross(d1, d2);
let d = dot(d1, d2);
if c.abs() < 1e-12 && d > 0.0 {
return;
}
// Outer side: a right turn (c > 0 in y-down) opens the left side.
let s = if c > 0.0 { 1.0 } else { -1.0 };
let n1 = mul(perp(d1), hw * s);
let n2 = mul(perp(d2), hw * s);
let (a, b) = (add(v, n1), add(v, n2));
match join {
LineJoin::Bevel => emit(out, vec![v, a, b]),
LineJoin::Miter => {
// Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments,
// i.e. 1 / cos of half the turning angle.
let cos_half = ((1.0 + d) * 0.5).max(0.0).sqrt();
if cos_half < 1e-9 || 1.0 / cos_half > miter_limit {
emit(out, vec![v, a, b]);
} else {
let bis = norm(add(n1, n2));
let m = add(v, mul(bis, hw / cos_half));
emit(out, vec![v, a, m, b]);
}
}
LineJoin::Round => {
let a0 = n1.1.atan2(n1.0);
let mut a1 = n2.1.atan2(n2.0);
// Sweep the short way from n1 to n2 on the outer side.
let mut delta = a1 - a0;
while delta > std::f64::consts::PI {
delta -= std::f64::consts::TAU;
}
while delta < -std::f64::consts::PI {
delta += std::f64::consts::TAU;
}
if c.abs() < 1e-12 {
// U-turn: half circle on the side of n1 rotating towards d1.
delta = std::f64::consts::PI * if cross(n1, d1) > 0.0 { 1.0 } else { -1.0 };
}
a1 = a0 + delta;
let mut steps = (delta.abs() / arc_step(hw, tol)).ceil() as usize;
if arc_cap != usize::MAX {
// Over the outline budget: the join's share of an `arc_cap`-point circle.
steps = steps.min((arc_cap as f64 * delta.abs() / std::f64::consts::TAU).ceil() as usize);
}
let steps = steps.max(1);
let rr = area_radius(hw, delta.abs() / steps as f64);
let mut poly = vec![v, a];
for k in 1..steps {
let t = a0 + (a1 - a0) * k as f64 / steps as f64;
poly.push((v.0 + rr * t.cos(), v.1 + rr * t.sin()));
}
poly.push(b);
emit(out, poly);
}
}
}
fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, arc_cap: usize, out: &mut Vec<Vec<P>>) {
match cap {
LineCap::Butt => {}
LineCap::Round => emit(out, circle(p, hw, tol, arc_cap)),
LineCap::Square => {
let n = mul(perp(dir), hw);
let e = mul(dir, hw);
emit(out, vec![add(p, n), add(add(p, n), e), add(sub(p, n), e), sub(p, n)]);
}
}
}
/// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px.
pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polyline> {
dash_polyline_budgeted(pl, pattern, offset, &mut DASH_STEP_BUDGET.clone())
}
/// [`dash_polyline`] spending at most `steps_left` dash steps (decremented); once they are
/// spent, the rest of the line is drawn solid.
fn dash_polyline_budgeted(pl: &Polyline, pattern: &[f64], offset: f64, steps_left: &mut usize) -> Vec<Polyline> {
if pattern.is_empty() {
return vec![pl.clone()];
}
if pattern.len() > 32
|| !offset.is_finite()
|| pattern.iter().any(|d| !d.is_finite() || *d < 0.0)
|| pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite())
{
return Vec::new();
}
let mut pat = pattern.to_vec();
if pat.len() % 2 == 1 {
let copy = pat.clone();
pat.extend(copy);
}
let total: f64 = pat.iter().sum();
if !total.is_finite() || total <= 1e-9 || pl.pts.len() < 2 {
return Vec::new();
}
// Only a closed polyline needs a copy (with its first point repeated at the end).
let pts: Cow<[P]> = if pl.closed {
let mut pts = pl.pts.clone();
pts.push(pl.pts[0]);
pts.into()
} else {
pl.pts.as_slice().into()
};
// Knot flags past the source's (and the repeated closing point) count as knots.
let knot_at = |i: usize| i >= pl.pts.len() || pl.knot.get(i).copied().unwrap_or(true);
// Position in the pattern.
let mut idx = 0usize;
let mut left = pat[0];
let mut skip = offset.rem_euclid(total);
while skip > 0.0 {
if skip >= left {
skip -= left;
idx = (idx + 1) % pat.len();
left = pat[idx];
} else {
left -= skip;
skip = 0.0;
}
}
let mut out = Vec::new();
let mut cur: Option<Polyline> = if idx.is_multiple_of(2) { Some(Polyline { pts: vec![pts[0]], knot: vec![true], closed: false }) } else { None };
'walk: for (wi, w) in pts.windows(2).enumerate() {
let (a, b) = (w[0], w[1]);
let b_knot = knot_at(wi + 1);
let seg = len(sub(b, a));
let mut pos = 0.0;
while seg - pos > 1e-12 {
let step = left.min(seg - pos);
if *steps_left == 0 || (step > 0.0 && pos + step == pos) {
// Past the step budget (which bounds allocations and time for file-provided
// patterns; commands reject such patterns before mutating the document) or
// dashes below the coordinates' precision: keep the dashes made so far and draw
// the rest of the line solid rather than dropping the whole stroke (#5195).
let here = if pos > 0.0 { add(a, mul(sub(b, a), pos / seg)) } else { a };
let rest = cur.get_or_insert_with(|| Polyline { pts: vec![here], knot: vec![true], closed: false });
for (j, p) in pts.iter().enumerate().skip(wi + 1) {
rest.pts.push(*p);
rest.knot.push(knot_at(j));
}
break 'walk;
}
*steps_left -= 1;
pos += step;
left -= step;
let at_end = seg - pos <= 1e-12;
let p = if at_end { b } else { add(a, mul(sub(b, a), pos / seg)) };
if let Some(c) = cur.as_mut() {
c.pts.push(p);
c.knot.push(!at_end || b_knot);
}
if left <= 1e-12 {
idx = (idx + 1) % pat.len();
left = pat[idx];
if idx % 2 == 1 {
if let Some(c) = cur.take() {
out.push(c);
}
} else {
cur = Some(Polyline { pts: vec![p], knot: vec![true], closed: false });
}
}
}
// The vertex at `b` is a join inside a dash: keep it (already pushed when reached).
}
if let Some(c) = cur.take()
&& c.pts.len() > 1
{
out.push(c);
}
if pl.closed {
// A dash that crosses the closing vertex has a join there, not two caps.
// Preserve knot flags when stitching the tail onto the first dash.
let seam = pl.pts.first().copied();
let wraps = out.first().and_then(|d| d.pts.first()).copied() == seam && out.last().and_then(|d| d.pts.last()).copied() == seam;
if wraps && out.len() == 1 {
if let Some(dash) = out.first_mut() {
dash.closed = true;
}
} else if wraps
&& out.len() > 1
&& let Some(mut tail) = out.pop()
&& let Some(first) = out.first_mut()
{
tail.pts.extend(first.pts.iter().skip(1).copied());
tail.knot.extend(first.knot.iter().skip(1).copied());
*first = tail;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn line(pts: &[P], closed: bool) -> Polyline {
Polyline { pts: pts.to_vec(), knot: vec![true; pts.len()], closed }
}
#[test]
fn dashes_split_by_length() {
let pl = line(&[(0.0, 0.0), (10.0, 0.0)], false);
let d = dash_polyline(&pl, &[2.0, 1.0], 0.0);
// on [0,2] [3,5] [6,8] [9,10]
assert_eq!(d.len(), 4);
assert!((d[1].pts[0].0 - 3.0).abs() < 1e-9 && (d[1].pts.last().unwrap().0 - 5.0).abs() < 1e-9);
assert!((d[3].pts.last().unwrap().0 - 10.0).abs() < 1e-9);
let o = dash_polyline(&pl, &[2.0, 1.0], 1.0);
assert!((o[0].pts.last().unwrap().0 - 1.0).abs() < 1e-9);
}
#[test]
fn pieces_are_positive() {
let pl = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], false);
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
let st = StrokeStyle { width: 2.0, join, cap, ..Default::default() };
for p in stroke_polygons(std::slice::from_ref(&pl), &st, 0.1) {
assert!(signed_area(&p) > 0.0);
}
}
}
}
#[test]
fn closed_dashes_have_a_join_at_the_seam() {
let square = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], true);
let dashes = dash_polyline(&square, &[8.0, 4.0], 2.0);
assert_eq!(dashes.len(), 3);
let crossing = &dashes[0];
assert_eq!(crossing.pts, [(0.0, 6.0), (0.0, 0.0), (6.0, 0.0)]);
assert_eq!(crossing.knot, [true, true, true]);
let full = dash_polyline(&square, &[80.0, 2.0], 0.0);
assert_eq!(full.len(), 1);
assert!(full[0].closed);
}
#[test]
fn invalid_and_too_dense_dashes_finish_with_bounded_work() {
let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false);
for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33]] {
assert!(dash_polyline(&pl, &pattern, 0.0).is_empty());
}
assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty());
assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty());
// Too dense for the step budget: bounded work, and the line is still drawn.
let d = dash_polyline(&pl, &[1e-8, 1e-8], 0.0);
assert!(!d.is_empty() && d.len() <= 50_001);
assert_eq!(d.last().and_then(|p| p.pts.last()), Some(&(100.0, 0.0)));
}
#[test]
fn dashes_past_the_step_budget_keep_the_rest_of_the_line_solid() {
// An SVG `stroke-dasharray="0.5 0.5"` along 60000 px needs more steps than the budget:
// the stroke used to vanish entirely (#5195). The dashes made so far stay, the rest of
// the line (through its later vertices) is drawn solid.
let pl = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0)], false);
let d = dash_polyline(&pl, &[0.5, 0.5], 0.0);
assert!(d.len() > 40_000, "{} dashes", d.len());
assert_eq!(d[0].pts, [(0.0, 0.0), (0.5, 0.0)]);
let tail = d.last().unwrap();
assert_eq!(tail.pts.last(), Some(&(60_000.0, 1000.0)));
assert!(tail.pts.contains(&(60_000.0, 0.0)) && tail.pts.len() == tail.knot.len());
// The solid tail starts where the dashes stopped (100000 steps of 0.5 px).
assert!((tail.pts[0].0 - 50_000.0).abs() < 1.0 && tail.pts[0].1 == 0.0, "{:?}", tail.pts[0]);
// Closed outlines finish the same way, back to their first point, where the solid tail
// joins the first dash (one piece with a join at the seam, no caps).
let outline = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0), (0.0, 1000.0)], true);
let d = dash_polyline(&outline, &[0.5, 0.5], 0.0);
let joined = &d[0];
assert!(joined.pts.contains(&(60_000.0, 1000.0)) && joined.pts.contains(&(0.0, 1000.0)) && joined.pts.contains(&(0.0, 0.0)));
assert_eq!(joined.pts.last(), Some(&(0.5, 0.0)));
}
#[test]
fn wide_round_capped_dashes_keep_the_outline_within_its_point_budget() {
let points = |polys: &[Vec<P>]| polys.iter().map(Vec::len).sum::<usize>();
// 5000 dashes, each with two round caps of ~629 points at full resolution (#5211).
let st = StrokeStyle { width: 4000.0, cap: LineCap::Round, join: LineJoin::Round, dashes: vec![1.0, 9.0], ..Default::default() };
let pl = line(&[(0.0, 0.0), (25_000.0, 0.0), (25_000.0, 25_000.0)], false);
let polys = stroke_polygons(std::slice::from_ref(&pl), &st, 0.01);
assert!(points(&polys) <= OUTLINE_POINT_BUDGET, "{} points", points(&polys));
// No dash is dropped: a ribbon and two caps each.
assert!(polys.len() >= 3 * 5000, "{} pieces", polys.len());
// Many tiny undashed subpaths: bounded too, every one still drawn.
let dots: Vec<Polyline> = (0..3000).map(|i| line(&[(f64::from(i), 0.0), (f64::from(i) + 0.5, 0.0)], false)).collect();
let polys = stroke_polygons(&dots, &StrokeStyle { dashes: Vec::new(), ..st.clone() }, 0.01);
assert!(points(&polys) <= OUTLINE_POINT_BUDGET && polys.len() >= 3 * 3000, "{} points", points(&polys));
// Under the budget, round caps keep their full resolution.
let one = stroke_polygons(&dots[..1], &StrokeStyle { dashes: Vec::new(), ..st }, 0.01);
assert!(one.iter().any(|p| p.len() == circle_points(2000.0, 0.01)) && circle_points(2000.0, 0.01) > 600);
}
#[test]
fn nonuniform_patterns_and_negative_offsets_repeat_by_arc_length() {
let pl = line(&[(0.0, 0.0), (30.0, 0.0)], false);
let d = dash_polyline(&pl, &[4.0, 2.0, 1.0, 3.0], -10.0);
assert_eq!(
d.iter().map(|p| (p.pts.first().unwrap().0, p.pts.last().unwrap().0)).collect::<Vec<_>>(),
[(0.0, 4.0), (6.0, 7.0), (10.0, 14.0), (16.0, 17.0), (20.0, 24.0), (26.0, 27.0)]
);
}
}