ScriptUI: bound the edges and bands a fillPath is tessellated with (#3001)

* fix(scriptui): bound the edges and bands a fillPath is tessellated with

* address review: cap the triangles a ScriptUI fill produces

* address review: raise fillPath limits (65,536 edges, 262,144 bands, 4M triangles, work cap); fill what fits and log a warning instead of nothing

* address review: bound the fillPath crossing scan: edges swept by top, pairs and boundaries counted against the work and band budgets

* address review: bands with crossings that were not cut are left unfilled when the crossing scan is over budget

* address review: bands left out of a fill still count against the work budget
This commit is contained in:
Dylan M. Taylor
2026-11-11 15:54:28 -05:00
committed by GitHub
parent b7e848a629
commit 00d50b8c68
+107 -4
View File
@@ -195,6 +195,15 @@ impl ImageRef {
}
}
/// Most edges a ScriptUI `fillPath` is filled with (a path past it fills its first edges).
pub const MAX_FILL_EDGES: usize = 65_536;
/// Most horizontal bands a ScriptUI fill is cut into (vertices and edge crossings); the topmost are filled.
pub const MAX_FILL_BANDS: usize = 262_144;
/// Most triangles a ScriptUI fill produces.
pub const MAX_FILL_TRIANGLES: usize = 1 << 22;
/// Most band-by-edge steps a fill takes while cutting bands.
pub const MAX_FILL_WORK: usize = 1 << 31;
impl PathSeg {
/// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with
/// whether it is closed.
@@ -248,8 +257,16 @@ impl PathSeg {
/// self-intersecting and holed paths fill correctly (frontends' polygon fills are often
/// convex-only). Exact trapezoid decomposition: the plane is cut into horizontal bands at
/// every vertex and edge crossing; inside a band no edges cross, so each inside span is a
/// trapezoid (two triangles).
/// trapezoid (two triangles). Edges are compared pairwise and every band looks at every edge,
/// on the UI thread, for geometry a script chooses, so a path past [`MAX_FILL_EDGES`] edges,
/// [`MAX_FILL_BANDS`] bands, [`MAX_FILL_TRIANGLES`] triangles or [`MAX_FILL_WORK`] steps (edge
/// pairs compared, then edges looked at per band) is filled only as far as the limit allows
/// (topmost part first) and a warning is logged.
pub fn fill_triangles(path: &[PathSeg], n: usize, even_odd: bool) -> Vec<[[f64; 2]; 3]> {
Self::fill_triangles_within(path, n, even_odd, [MAX_FILL_EDGES, MAX_FILL_BANDS, MAX_FILL_TRIANGLES, MAX_FILL_WORK])
}
fn fill_triangles_within(path: &[PathSeg], n: usize, even_odd: bool, [max_edges, max_bands, max_triangles, max_work]: [usize; 4]) -> Vec<[[f64; 2]; 3]> {
// Non-horizontal edges, top to bottom, with their winding direction.
let mut edges: Vec<([f64; 2], [f64; 2], i32)> = vec![];
for (pts, _) in PathSeg::polylines(path, n) {
@@ -261,15 +278,37 @@ impl PathSeg {
edges.push(if a[1] < b[1] { (a, b, 1) } else { (b, a, -1) });
}
}
if edges.len() > max_edges {
log::warn!("fillPath: {} edges, filling only the first {max_edges}", edges.len());
edges.truncate(max_edges);
}
if edges.len() < 2 {
return vec![];
}
let x_at = |e: &([f64; 2], [f64; 2], i32), y: f64| e.0[0] + (e.1[0] - e.0[0]) * (y - e.0[1]) / (e.1[1] - e.0[1]);
let mut ys: Vec<f64> = edges.iter().flat_map(|e| [e.0[1], e.1[1]]).collect();
// Where two edges cross, a band boundary.
for i in 0..edges.len() {
for j in i + 1..edges.len() {
// Where two edges cross, a band boundary. Edges by top, so each is compared only with the
// ones that start above its bottom (the pairs that share some height); the pairs and the
// boundaries found are counted against the budgets, and past one the rest are not looked
// for. A band that holds a crossing that was not looked for is not filled (its edges
// change order inside it, which the band loop below tells), so what is drawn is never
// outside the path.
let mut truncated = false;
let mut order: Vec<usize> = (0..edges.len()).collect();
order.sort_by(|&a, &b| edges[a].0[1].total_cmp(&edges[b].0[1]));
let mut pairs = 0usize;
'cross: for (k, &i) in order.iter().enumerate() {
for &j in &order[k + 1..] {
let (a, b) = (&edges[i], &edges[j]);
if b.0[1] >= a.1[1] {
break;
}
pairs += 1;
if pairs > max_work || ys.len() > max_bands {
log::warn!("fillPath: over its crossing budget; bands with crossings that were not cut are left unfilled");
truncated = true;
break 'cross;
}
let (lo, hi) = (a.0[1].max(b.0[1]), a.1[1].min(b.1[1]));
if hi <= lo {
continue;
@@ -282,6 +321,11 @@ impl PathSeg {
}
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
if ys.len() > max_bands {
log::warn!("fillPath: {} bands, filling only the topmost {max_bands}", ys.len());
ys.truncate(max_bands);
}
let mut work = 0usize;
let mut out = vec![];
let mut row: Vec<(f64, f64, f64, i32)> = vec![];
for w in ys.windows(2) {
@@ -290,8 +334,14 @@ impl PathSeg {
row.clear();
row.extend(edges.iter().filter(|e| e.0[1] < ym && ym < e.1[1]).map(|e| (x_at(e, ym), x_at(e, ya), x_at(e, yb), e.2)));
row.sort_by(|a, b| a.0.total_cmp(&b.0));
// With crossings missing, edges that swap places inside the band make its trapezoids
// wrong: it is left out.
let swapped = truncated && !row.windows(2).all(|w| w[0].1 <= w[1].1 + 1e-9 && w[0].2 <= w[1].2 + 1e-9);
let mut wind = 0;
for k in 0..row.len().saturating_sub(1) {
if swapped {
break;
}
wind += row[k].3;
let inside = if even_odd { wind % 2 != 0 } else { wind != 0 };
if inside {
@@ -300,6 +350,13 @@ impl PathSeg {
out.push([[l.1, ya], [r.2, yb], [l.2, yb]]);
}
}
// (Every band's edge scan counts, a band left out included.)
work = work.saturating_add(edges.len());
if out.len() > max_triangles || work > max_work {
log::warn!("fillPath: over its triangle or work budget, filled down to y = {yb}");
out.truncate(max_triangles);
break;
}
}
out
}
@@ -951,4 +1008,50 @@ mod tests {
let open = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 4.0, y: 0.0 }, PathSeg::L { x: 0.0, y: 4.0 }];
assert!((area(&PathSeg::fill_triangles(&open, 48, false)) - 8.0).abs() < 1e-9);
}
/// A script's path past the fill budgets is filled as far as they allow, never emptied.
#[test]
fn a_fill_past_its_budgets_draws_what_fits() {
let area = |t: &[[[f64; 2]; 3]]| {
t.iter().map(|t| ((t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1])).abs() / 2.0).sum::<f64>()
};
let rect = |x: f64, y: f64, w: f64, h: f64| PathSeg::R { x, y, w, h };
let rects: Vec<PathSeg> = (0..8).map(|i| rect(2.0 * i as f64, 0.0, 1.0, 10.0)).collect();
let full = PathSeg::fill_triangles(&rects, 48, false);
assert!((area(&full) - 80.0).abs() < 1e-9);
// Edges over the limit: the first edges still fill (4 edges = 2 rectangles).
let t = PathSeg::fill_triangles_within(&rects, 48, false, [4, usize::MAX, usize::MAX, usize::MAX]);
assert!((area(&t) - 20.0).abs() < 1e-9, "{}", area(&t));
// Triangles over the limit: truncated, not empty.
let t = PathSeg::fill_triangles_within(&rects, 48, false, [usize::MAX, usize::MAX, 6, usize::MAX]);
assert_eq!(t.len(), 6);
// Bands over the limit: the topmost band fills.
let stair: Vec<PathSeg> = (0..4).map(|i| rect(0.0, i as f64, 1.0, 1.0)).collect();
let t = PathSeg::fill_triangles_within(&stair, 48, false, [usize::MAX, 2, usize::MAX, usize::MAX]);
assert!(!t.is_empty() && area(&t) < 4.0);
// Work over the limit stops the cut.
let t = PathSeg::fill_triangles_within(&stair, 48, false, [usize::MAX, usize::MAX, usize::MAX, 1]);
assert!(!t.is_empty() && area(&t) < 4.0);
// Many crossing edges (every pair of 120 diagonals meets): the crossing scan is held to the
// band and work budgets too.
let fan: Vec<PathSeg> = (0..60)
.flat_map(|i| {
let x = i as f64;
[PathSeg::M { x, y: 0.0 }, PathSeg::L { x: 60.0 - x, y: 100.0 }, PathSeg::L { x: x + 0.5, y: 100.0 }]
})
.collect();
for limits in [[usize::MAX, 50, usize::MAX, usize::MAX], [usize::MAX, usize::MAX, usize::MAX, 500]] {
let t = PathSeg::fill_triangles_within(&fan, 48, false, limits);
assert!(area(&t) <= area(&PathSeg::fill_triangles(&fan, 48, false)) + 1e-6, "{limits:?}");
}
// Bands whose crossings were not looked for are not filled: a bow tie (area 50, crossing at
// y = 5) under a crossing budget of one boundary gives no overlapping triangles.
let bow = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 10.0, y: 10.0 }, PathSeg::L { x: 0.0, y: 10.0 }, PathSeg::L { x: 10.0, y: 0.0 }];
assert!((area(&PathSeg::fill_triangles(&bow, 48, false)) - 50.0).abs() < 1e-9);
let t = PathSeg::fill_triangles_within(&bow, 48, false, [usize::MAX, 2, usize::MAX, usize::MAX]);
assert!(area(&t) <= 50.0 + 1e-9, "{}", area(&t));
// Bands left out still count against the work budget (it ends the scan after the first).
let t = PathSeg::fill_triangles_within(&bow, 48, false, [usize::MAX, 2, usize::MAX, 1]);
assert!(area(&t) <= 50.0 + 1e-9, "{}", area(&t));
}
}