mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-10 20:39:36 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489de079f3 | ||
|
|
5edf3f693e | ||
|
|
16ddef0744 | ||
|
|
00b8bf38d6 | ||
|
|
3fd1dfed36 |
@@ -11,6 +11,14 @@ use crate::geom::{Op, P};
|
|||||||
/// Line segments per Bézier when a crossing shape is flattened.
|
/// Line segments per Bézier when a crossing shape is flattened.
|
||||||
const CURVE_STEPS: usize = 16;
|
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).
|
/// 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]> {
|
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
|
||||||
let mut b: Option<[f64; 4]> = None;
|
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])))
|
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`.
|
/// Sutherland–Hodgman: `poly` cut by convex `clip`, charging each point visited against each clip
|
||||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
/// edge to `budget`: the polygon
|
||||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
|
||||||
return Vec::new();
|
/// once the budget is spent.
|
||||||
}
|
pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
|
||||||
let mut poly = poly.to_vec();
|
let mut poly = poly.to_vec();
|
||||||
for i in 0..clip.len() {
|
for i in 0..clip.len() {
|
||||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
// The work actually done: this edge visits the polygon as it now stands.
|
||||||
return Vec::new();
|
if poly.len() > *budget {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
*budget -= poly.len();
|
||||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||||
let cut = |p: P, q: P| {
|
let cut = |p: P, q: P| {
|
||||||
@@ -133,7 +143,7 @@ pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
poly
|
Some(poly)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
||||||
@@ -158,15 +168,17 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let subs = flatten(&ops);
|
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();
|
let mut out = Vec::new();
|
||||||
if fill.is_some() {
|
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() {
|
if !f.is_empty() {
|
||||||
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stroke.is_some() {
|
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() {
|
if !s.is_empty() {
|
||||||
out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region });
|
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<Raw> {
|
|||||||
if disjoint(b, cb) {
|
if disjoint(b, cb) {
|
||||||
return Vec::new();
|
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 }] }
|
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(),
|
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<P>, bool)> {
|
|||||||
subs
|
subs
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
/// 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<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||||
let mut ops = Vec::new();
|
let mut ops = Vec::new();
|
||||||
for (pts, _) in subs {
|
for (pts, _) in subs {
|
||||||
let poly = clip_polygon(pts, clip);
|
let poly = clip_polygon_within(pts, clip, budget)?;
|
||||||
if poly.len() >= 3 {
|
if poly.len() >= 3 {
|
||||||
ops.push(Op::M(poly[0]));
|
ops.push(Op::M(poly[0]));
|
||||||
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
||||||
ops.push(Op::Z);
|
ops.push(Op::Z);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ops
|
Some(ops)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
/// The clipped outline of every subpath; `None` once `budget` is spent.
|
||||||
|
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||||
let mut ops = Vec::new();
|
let mut ops = Vec::new();
|
||||||
for (pts, closed) in subs {
|
for (pts, closed) in subs {
|
||||||
let mut pts = pts.clone();
|
let mut pts = pts.clone();
|
||||||
@@ -252,7 +268,7 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
|||||||
}
|
}
|
||||||
let mut last: Option<P> = None;
|
let mut last: Option<P> = None;
|
||||||
for w in pts.windows(2) {
|
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;
|
last = None;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -263,21 +279,23 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
|||||||
last = Some(b);
|
last = Some(b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ops
|
Some(ops)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`.
|
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`, one unit of `budget` per clip edge
|
||||||
fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> {
|
/// visited; the outer `None` is a spent budget.
|
||||||
|
fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option<Option<(P, P)>> {
|
||||||
let d = (b.0 - a.0, b.1 - a.1);
|
let d = (b.0 - a.0, b.1 - a.1);
|
||||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||||
for i in 0..clip.len() {
|
for i in 0..clip.len() {
|
||||||
|
*budget = budget.checked_sub(1)?;
|
||||||
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
|
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||||
// Inside is cross(e0, e1, p) >= 0, linear in t along the segment.
|
// Inside is cross(e0, e1, p) >= 0, linear in t along the segment.
|
||||||
let f0 = cross(e0, e1, a);
|
let f0 = cross(e0, e1, a);
|
||||||
let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0;
|
let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0;
|
||||||
if df == 0.0 {
|
if df == 0.0 {
|
||||||
if f0 < 0.0 {
|
if f0 < 0.0 {
|
||||||
return None;
|
return Some(None);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let t = -f0 / df;
|
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
|
/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use std::sync::Arc;
|
|||||||
use crate::bytes::Bytes;
|
use crate::bytes::Bytes;
|
||||||
use crate::canvas::Raw;
|
use crate::canvas::Raw;
|
||||||
use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul};
|
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::dib::Rgba;
|
||||||
use crate::geom::{Op, P};
|
use crate::geom::{Op, P};
|
||||||
use crate::text::{ETO_CLIPPED, Font};
|
use crate::text::{ETO_CLIPPED, Font};
|
||||||
@@ -1170,7 +1170,13 @@ fn area_of_ops(ops: &[Op]) -> Vec<P> {
|
|||||||
fn area_intersect(a: Area, b: Area) -> Area {
|
fn area_intersect(a: Area, b: Area) -> Area {
|
||||||
match (a, b) {
|
match (a, b) {
|
||||||
(None, x) | (x, None) => x,
|
(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()),
|
_ => Some(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2071,6 +2071,87 @@ fn emfplus_wrap_line_linear_performance() {
|
|||||||
fn clip_polygon_work_bound() {
|
fn clip_polygon_work_bound() {
|
||||||
let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
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 clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
assert!(crate::cliprect::clip_polygon_within(&poly, &clip, &mut 1_000_000).is_none(), "excessive polygon clip product rejected");
|
||||||
assert!(clipped.is_empty(), "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<Op> = (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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user