From 554ad0ea7761b06611b0d421abbddbfecfed4436 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:49:49 -0500 Subject: [PATCH] pathops: budget the closed faces input, share the dash allowance, index point incidence (#2249) * pathops: check closed inputs before faces normalises, share the dash allowance across subpaths, index point incidence once * pathops: leave out move-only subpaths when closing, budget dashes by the fitted pattern, index only join points --- crates/pathops/src/budget_tests.rs | 26 ++++++++++++++++++++++++++ crates/pathops/src/lib.rs | 18 ++++++++++++++---- crates/pathops/src/points.rs | 17 +++++++++++++++-- crates/pathops/src/width.rs | 18 ++++++++++++++---- crates/pathops/src/width_tests.rs | 28 ++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/pathops/src/budget_tests.rs b/crates/pathops/src/budget_tests.rs index a55e58e85..dfcffdb5b 100644 --- a/crates/pathops/src/budget_tests.rs +++ b/crates/pathops/src/budget_tests.rs @@ -99,3 +99,29 @@ fn overlapping_rectangle_bounds_count_self_and_cross_pairs() { let shapes = rects([Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(1.0, 1.0, 3.0, 3.0), Rect::new(4.0, 4.0, 5.0, 5.0)]); assert_eq!(complexity([&shapes]), Some(Complexity { segments: 12, crossings: 14 })); } + +/// Open L shapes whose segments never overlap but whose implicit closing edges all do. +#[test] +fn faces_checks_the_closed_inputs_before_normalising() { + let mut p = BezPath::new(); + for i in 0..2200 { + let (x, y) = (-(i as f64), 10_000.0 + i as f64); + p.move_to((x, 0.0)); + p.line_to((x, y)); + p.line_to((10_000.0, y)); + } + assert!(complexity([&p]).is_some_and(|c| c.crossings <= MAX_CROSSINGS), "open input passes"); + assert!(matches!(faces(&[(p, false)]), Err(PathOpsError::TooComplex))); +} + +#[test] +fn closing_drops_move_only_subpaths() { + let mut p = BezPath::new(); + for i in 0..1000 { + p.move_to((i as f64, 0.0)); + } + assert!(crate::closed(&p).elements().is_empty()); + p.line_to((5.0, 5.0)); + let c = crate::closed(&p); + assert_eq!(c.elements().len(), 3, "the last move, the line and the close: {:?}", c.elements()); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index c24a22248..85f1ecd10 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -57,20 +57,27 @@ impl std::fmt::Display for PathOpsError { } } -/// Close every open subpath (a filled open path paints as if closed). +/// Close every open subpath (a filled open path paints as if closed). A subpath of a lone move +/// fills nothing and is left out. pub fn closed(path: &BezPath) -> BezPath { let mut out = BezPath::new(); let mut open = false; + let mut pending: Option = None; for el in path.elements() { match el { PathEl::MoveTo(_) => { if open { out.close_path(); } - open = true; + open = false; + pending = Some(*el); + continue; } PathEl::ClosePath => open = false, - _ => {} + _ => open = true, + } + if let Some(m) = pending.take() { + out.push(m); } out.push(*el); } @@ -240,7 +247,10 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result, PathOpsE // corner folds over itself and touches the shape's fill) leave the winding on each side of a // segment ambiguous where they touch, which made the sweep report overlapping faces. Reducing // every input to the simple contours of the area it fills first keeps the faces disjoint. - let closed_paths: Vec = paths.iter().map(|(p, even_odd)| plain(&closed(p), *even_odd)).collect(); + // Closing adds edges, so what `plain` sweeps is checked first. + let closed_inputs: Vec = paths.iter().map(|(p, _)| closed(p)).collect(); + budget::check(&closed_inputs)?; + let closed_paths: Vec = closed_inputs.iter().zip(paths).map(|(p, (_, even_odd))| plain(p, *even_odd)).collect(); if closed_paths.iter().all(|p| p.elements().is_empty()) { return Ok(Vec::new()); } diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index 650f309f4..452cd33ac 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -66,10 +66,23 @@ impl Styled { } // Each point's own join: a short corner along its two segments stroked with that join. let reach = (width * 0.05).max(0.2); + // Segments per point, built once (a scan of every segment per point is quadratic); like `incident`, a loop segment counts at its `a` end only. + // Only points that draw a join of their own get a list. + let mut incident: std::collections::HashMap> = + (0..self.net.vertices.len()).filter(|v| self.breaks(*v)).map(|v| (v, Vec::new())).collect(); + for (i, s) in self.net.segments.iter().enumerate() { + if let Some(list) = incident.get_mut(&s.a) { + list.push((i, true)); + } + if s.b != s.a + && let Some(list) = incident.get_mut(&s.b) + { + list.push((i, false)); + } + } for (v, x) in self.net.vertices.iter().enumerate() { let Some(join) = x.join.filter(|_| self.breaks(v)) else { continue }; - let inc = self.net.incident(v); - let [(s1, a1), (s2, a2)] = inc.as_slice() else { continue }; + let Some([(s1, a1), (s2, a2)]) = incident.get(&v).map(Vec::as_slice) else { continue }; let (Some(d1), Some(d2)) = (self.net.tangent_out(*s1, *a1), self.net.tangent_out(*s2, *a2)) else { continue }; let p = x.point(); let mut corner = BezPath::new(); diff --git a/crates/pathops/src/width.rs b/crates/pathops/src/width.rs index c1f2d2de3..3bdd2a4d0 100644 --- a/crates/pathops/src/width.rs +++ b/crates/pathops/src/width.rs @@ -262,7 +262,12 @@ const DASH_ACCURACY: f64 = 0.05; /// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`; /// past that a stroke draws solid instead of one outline per dash (#1060). pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool { - len / pattern.iter().sum::() * (pattern.len() as f64) < MAX_DASHES as f64 + dash_count(len, pattern) < MAX_DASHES as f64 +} + +/// About how many dashes `pattern` makes along `len`. +fn dash_count(len: f64, pattern: &[f64]) -> f64 { + len / pattern.iter().sum::() * (pattern.len() as f64) } /// Resample a polyline so no step is longer than `max_step`; returns the points and the length at @@ -303,6 +308,8 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start, cap_end }; let pattern = dash_pattern(props); let mut budget = MAX_WIDTH_SAMPLES; + // The dashes of all subpaths together stay within MAX_DASHES; the rest draw solid. + let mut dash_budget = MAX_DASHES as f64; let mut out = BezPath::new(); for line in &lines { let len = line.length(); @@ -317,9 +324,12 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile [0.5 * width * right, 0.5 * width * left] }) .collect(); - match &pattern { - Some(pat) if dashes_fit(len, pat) => { - let (pat, offset) = line_dashes(len, line.closed, pat); + // Budgeted by the pattern as fitted to this line (a short line still takes a whole pattern). + let fit = pattern.as_ref().map(|pat| line_dashes(len, line.closed, pat)); + let used = fit.as_ref().map_or(0.0, |(pat, _)| dash_count(len, pat).max(pat.len() as f64)); + match fit { + Some((pat, offset)) if used < dash_budget && dashes_fit(len, &pat) => { + dash_budget -= used; for (p, h) in dashes(&pts, &hw, &pat, offset) { outline_piece(&mut out, &p, &h, false, style); } diff --git a/crates/pathops/src/width_tests.rs b/crates/pathops/src/width_tests.rs index e9a6427ce..769a120e3 100644 --- a/crates/pathops/src/width_tests.rs +++ b/crates/pathops/src/width_tests.rs @@ -145,3 +145,31 @@ fn hostile_and_huge_input_is_bounded() { let s = stroke_outline(&line(1e6), &tiny, false); assert!(s.elements().len() < 1_000_000); } + +#[test] +fn dashes_of_all_subpaths_share_one_allowance() { + let tiny = StrokeProps { dashes: vec![1.0, 1.0], ..props(1.0, Some(WidthProfile::uniform())) }; + let many = |n: usize| { + let mut p = BezPath::new(); + for i in 0..n { + p.move_to((0.0, i as f64 * 4.0)); + p.line_to((15_000.0, i as f64 * 4.0)); + } + variable_outline(&p, 1.0, &tiny, &WidthProfile::uniform()).unwrap().elements().len() + }; + // The later subpaths draw solid once the first has used most of the allowance. + assert!(many(10) < 3 * many(1), "{} vs {}", many(10), many(1)); +} + +#[test] +fn a_short_subpath_is_budgeted_by_the_whole_pattern_it_takes() { + // 100 one-unit lines, each fitted to a 4,000-entry pattern: ~400,000 dashes if not budgeted. + let long = StrokeProps { dashes: vec![1.0; 4000], ..props(1.0, Some(WidthProfile::uniform())) }; + let mut p = BezPath::new(); + for i in 0..100 { + p.move_to((0.0, i as f64 * 4.0)); + p.line_to((1.0, i as f64 * 4.0)); + } + let n = variable_outline(&p, 1.0, &long, &WidthProfile::uniform()).unwrap().elements().len(); + assert!(n < 20_000 * 12, "{n}"); +}