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
This commit is contained in:
Dylan M. Taylor
2026-11-11 14:49:49 -05:00
committed by GitHub
parent 81371f4a53
commit 554ad0ea77
5 changed files with 97 additions and 10 deletions
+26
View File
@@ -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());
}
+14 -4
View File
@@ -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<PathEl> = 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<Vec<ArrangementFace>, 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<BezPath> = 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<BezPath> = paths.iter().map(|(p, _)| closed(p)).collect();
budget::check(&closed_inputs)?;
let closed_paths: Vec<BezPath> = 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());
}
+15 -2
View File
@@ -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<usize, Vec<(usize, bool)>> =
(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();
+14 -4
View File
@@ -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::<f64>() * (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::<f64>() * (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);
}
+28
View File
@@ -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}");
}