From eb10e2b9b3362f99f982f8ecdafa5c75e8b22ebe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 03:35:40 -0500 Subject: [PATCH] Metafile: bound clipping work across all subpaths of one item (#1738) * metafile: bound clipping work across all subpaths of one drawn item * address review: charge fill clipping as polygons grow, one budget per item * address review: charge actual clip work instead of projected polygon growth * address review: share one clip-work allowance between fill and stroke * address review: share one clip-work budget across fill and stroke, give region intersection its own budget --- crates/metafile/src/cliprect.rs | 60 +++++++++++++++-------- crates/metafile/src/emfplus.rs | 10 +++- crates/metafile/src/tests.rs | 85 ++++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 25 deletions(-) diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs index fd9dfc14a..ef6bb7c88 100644 --- a/crates/metafile/src/cliprect.rs +++ b/crates/metafile/src/cliprect.rs @@ -11,6 +11,14 @@ use crate::geom::{Op, P}; /// Line segments per Bézier when a crossing shape is flattened. const CURVE_STEPS: usize = 16; +/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over +/// it is dropped, as a single polygon over it already was. +const MAX_CLIP_WORK: usize = 1_000_000; + +/// The same allowance for intersecting two clip regions, a budget of its own: two convex +/// 1,000-gons cost about 1.5 million tests once the cut polygon has grown. +pub(crate) const MAX_AREA_CLIP_WORK: usize = 4 * MAX_CLIP_WORK; + /// The bounding box `[l, t, r, b]` of the points in `ops` (control points included). pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> { let mut b: Option<[f64; 4]> = None; @@ -98,16 +106,18 @@ fn is_rect(clip: &[P]) -> bool { pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3]))) } -/// Sutherland–Hodgman: `poly` cut by convex `clip`. -pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec

{ - if poly.len().saturating_mul(clip.len()) > 1_000_000 { - return Vec::new(); - } +/// Sutherland–Hodgman: `poly` cut by convex `clip`, charging each point visited against each clip +/// edge to `budget`: the polygon +/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None` +/// once the budget is spent. +pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option> { let mut poly = poly.to_vec(); for i in 0..clip.len() { - if poly.len().saturating_mul(clip.len() - i) > 1_000_000 { - return Vec::new(); + // The work actually done: this edge visits the polygon as it now stands. + if poly.len() > *budget { + return None; } + *budget -= poly.len(); let (a, b) = (clip[i], clip[(i + 1) % clip.len()]); let keep = |p: P| cross(a, b, p) >= 0.0; let cut = |p: P, q: P| { @@ -133,7 +143,7 @@ pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec

{ break; } } - poly + Some(poly) } fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { @@ -158,15 +168,17 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { return Vec::new(); } let subs = flatten(&ops); + // One allowance for the fill and the stroke together; the item goes once it is spent. + let mut budget = MAX_CLIP_WORK; let mut out = Vec::new(); if fill.is_some() { - let f = clip_fill(&subs, clip); + let Some(f) = clip_fill(&subs, clip, &mut budget) else { return Vec::new() }; if !f.is_empty() { out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); } } if stroke.is_some() { - let s = clip_stroke(&subs, clip); + let Some(s) = clip_stroke(&subs, clip, &mut budget) else { return Vec::new() }; if !s.is_empty() { out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); } @@ -181,7 +193,8 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { if disjoint(b, cb) { return Vec::new(); } - let f = clip_fill(&flatten(&ops), clip); + let subs = flatten(&ops); + let f = clip_fill(&subs, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default(); if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } } Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), @@ -230,20 +243,23 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { subs } -fn clip_fill(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { +/// The clipped fill of every subpath, its clipping work charged to `budget` with that of the +/// stroke: `None` (the whole item goes) once it is spent. +fn clip_fill(subs: &[(Vec

, bool)], clip: &[P], budget: &mut usize) -> Option> { let mut ops = Vec::new(); for (pts, _) in subs { - let poly = clip_polygon(pts, clip); + let poly = clip_polygon_within(pts, clip, budget)?; if poly.len() >= 3 { ops.push(Op::M(poly[0])); ops.extend(poly[1..].iter().map(|p| Op::L(*p))); ops.push(Op::Z); } } - ops + Some(ops) } -fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { +/// The clipped outline of every subpath; `None` once `budget` is spent. +fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P], budget: &mut usize) -> Option> { let mut ops = Vec::new(); for (pts, closed) in subs { let mut pts = pts.clone(); @@ -252,7 +268,7 @@ fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { } let mut last: Option

= None; for w in pts.windows(2) { - let Some((a, b)) = segment(w[0], w[1], clip) else { + let Some((a, b)) = segment(w[0], w[1], clip, budget)? else { last = None; continue; }; @@ -263,21 +279,23 @@ fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { last = Some(b); } } - ops + Some(ops) } -/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`. -fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> { +/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`, one unit of `budget` per clip edge +/// visited; the outer `None` is a spent budget. +fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option> { let d = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64); for i in 0..clip.len() { + *budget = budget.checked_sub(1)?; let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); // Inside is cross(e0, e1, p) >= 0, linear in t along the segment. let f0 = cross(e0, e1, a); let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0; if df == 0.0 { if f0 < 0.0 { - return None; + return Some(None); } } else { let t = -f0 / df; @@ -288,7 +306,7 @@ fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> { } } } - (t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))) + Some((t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1)))) } /// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index cf5fd97e9..d0f56f862 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use crate::bytes::Bytes; use crate::canvas::Raw; use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; -use crate::cliprect::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly}; +use crate::cliprect::{MAX_AREA_CLIP_WORK, clip_polygon_within, convex, flatten, ops_bbox, pts_bbox, rect_poly}; use crate::dib::Rgba; use crate::geom::{Op, P}; use crate::text::{ETO_CLIPPED, Font}; @@ -1170,7 +1170,13 @@ fn area_of_ops(ops: &[Op]) -> Vec

{ fn area_intersect(a: Area, b: Area) -> Area { match (a, b) { (None, x) | (x, None) => x, - (Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()), + (Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => { + // Out of work: keep the area so far rather than call the region empty. + match clip_polygon_within(&a, &b, &mut { MAX_AREA_CLIP_WORK }) { + Some(p) => Some(convex(&p).unwrap_or_default()), + None => Some(a), + } + } _ => Some(Vec::new()), } } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 29fab81ec..766beddab 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -2071,6 +2071,87 @@ fn emfplus_wrap_line_linear_performance() { fn clip_polygon_work_bound() { let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect(); let clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect(); - let clipped = crate::cliprect::clip_polygon(&poly, &clip); - assert!(clipped.is_empty(), "excessive polygon clip product rejected"); + assert!(crate::cliprect::clip_polygon_within(&poly, &clip, &mut 1_000_000).is_none(), "excessive polygon clip product rejected"); +} + +#[test] +fn clip_polygon_charges_actual_work_not_projected_growth() { + // Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must + // not make a later step look over budget. + let gon = |cx: f64| -> Vec<(f64, f64)> { + (0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect() + }; + let clipped = crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 4_000_000).unwrap(); + assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len()); +} + +#[test] +fn clip_work_is_shared_by_fill_and_stroke() { + use crate::canvas::Raw; + use crate::geom::Op; + // One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a + // fill alone fits the allowance but a fill plus a stroke would spend it twice. + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + let mut ops = vec![Op::M((0.0, 95.0))]; + ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 })))); + ops.push(Op::Z); + let raw = |fill, stroke| Raw::Path { ops: ops.clone(), fill, stroke, even_odd: false, region: None }; + let black = [0, 0, 0, 255]; + assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance"); + assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance"); +} + +#[test] +fn clip_work_is_bounded_across_subpaths() { + use crate::canvas::Raw; + use crate::geom::Op; + // 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together + // far over it, for both the fill and the stroke. + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z]; + let many: Vec = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect(); + let raw = |ops| Raw::Path { ops, fill: Some([0, 0, 0, 255]), stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None }; + assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded"); + assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped"); +} + +/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size: +/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against +/// one budget for the whole item. +#[test] +fn growing_fill_clips_share_one_budget() { + use crate::canvas::Raw; + use crate::geom::Op; + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + // A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps. + let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z]; + let raw = |n: usize| Raw::Path { ops: tri.repeat(n), fill: Some([0, 0, 0, 255]), stroke: None, even_odd: false, region: None }; + // 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million. + assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged"); + assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped"); +} + +#[test] +fn stroke_work_is_charged_and_outside_items_are_cheap() { + use crate::canvas::Raw; + use crate::geom::Op; + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + // A stroke-only zigzag of 4,000 segments across the edge: 4,000 x 512 tests, over the allowance. + let mut ops = vec![Op::M((0.0, 95.0))]; + ops.extend((1..4000).map(|i| Op::L((i as f64 * 0.05, if i % 2 == 0 { 95.0 } else { 300.0 })))); + let raw = Raw::Path { ops, fill: None, stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None }; + assert!(crate::cliprect::clip_raw(raw, &clip).is_empty(), "stroke work is bounded"); +} + +#[test] +fn region_intersection_over_budget_keeps_the_previous_area() { + let gon = |cx: f64| -> Vec<(f64, f64)> { + (0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect() + }; + assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none()); + assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some()); }