mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 03:51:42 +00:00
Merge pull request #1774 from TrevorBrown-dev/fix/geom-bounded-sizes
M8: shape builders cap sizes, radii and angles at 1e9, so an absurd value cannot build millions of segments
This commit is contained in:
4 files changed
+128
-19
No files matched your search
@@ -298,8 +298,9 @@ mod tests {
|
||||
};
|
||||
assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
|
||||
|
||||
// Past the cap the builders hold their sizes to (`figcraft_geom::MAX_COORD`, inclusive).
|
||||
let huge = Network {
|
||||
vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)],
|
||||
vertices: vec![vertex(0.0, 0.0), vertex(2e9, 0.0)],
|
||||
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
|
||||
..Network::default()
|
||||
};
|
||||
|
||||
+27
-15
@@ -18,6 +18,18 @@ pub use transform::*;
|
||||
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
|
||||
pub type Radii = [f64; 4];
|
||||
|
||||
/// The largest magnitude a shape builder takes for a size, radius or angle. kurbo subdivides an
|
||||
/// arc in proportion to the sixth root of its radius, so an uncapped radius means an unbounded
|
||||
/// number of segments: 1e36 is about a million cubics and 1e60 never returns. Sizes come from
|
||||
/// files and commands, so they are capped here (the sanity bound `measure::finite_path` uses).
|
||||
pub const MAX_COORD: f64 = 1e9;
|
||||
|
||||
/// `v` as a usable size, radius or angle: NaN and infinities are 0 and the magnitude is capped at
|
||||
/// [`MAX_COORD`].
|
||||
fn bounded(v: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The tolerance an arc of radius `r` is turned into cubics with: a hundredth of a unit, or a
|
||||
/// millionth of the radius once that is more. kurbo's cubic count grows with radius ÷ tolerance,
|
||||
/// so a fixed tolerance let a radius of 1e300 ask for more cubics than memory holds.
|
||||
@@ -84,15 +96,15 @@ fn finite(v: f64) -> f64 {
|
||||
/// Radii are clamped so adjacent corners never overlap; when smoothing doesn't fit, it is reduced
|
||||
/// first, then the radius.
|
||||
pub fn rounded_rect(width: f64, height: f64, radii: Radii, smoothing: f64) -> BezPath {
|
||||
let w = finite(width).max(0.0);
|
||||
let h = finite(height).max(0.0);
|
||||
let w = bounded(width).max(0.0);
|
||||
let h = bounded(height).max(0.0);
|
||||
let mut path = BezPath::new();
|
||||
if w <= 0.0 && h <= 0.0 {
|
||||
return path;
|
||||
}
|
||||
let s = finite(smoothing).clamp(0.0, 1.0);
|
||||
let s = bounded(smoothing).clamp(0.0, 1.0);
|
||||
let max_r = w.min(h) / 2.0;
|
||||
let radii = radii.map(|r| finite(r).clamp(0.0, max_r));
|
||||
let radii = radii.map(|r| bounded(r).clamp(0.0, max_r));
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((w, 0.0));
|
||||
@@ -196,7 +208,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof:
|
||||
/// first vertex at the top centre (Figma's polygon).
|
||||
pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec<Point> {
|
||||
let n = count.clamp(3, 120);
|
||||
let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0);
|
||||
let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let t = -FRAC_PI_2 + TAU * f64::from(i) / f64::from(n);
|
||||
@@ -208,8 +220,8 @@ pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec<Point> {
|
||||
/// The vertices of a star with `count` points and `inner_ratio` (0..1) inner radius.
|
||||
pub fn star_points(width: f64, height: f64, count: u32, inner_ratio: f64) -> Vec<Point> {
|
||||
let n = count.clamp(3, 120);
|
||||
let k = finite(inner_ratio).clamp(0.001, 1.0);
|
||||
let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0);
|
||||
let k = bounded(inner_ratio).clamp(0.001, 1.0);
|
||||
let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0);
|
||||
(0..n * 2)
|
||||
.map(|i| {
|
||||
let t = -FRAC_PI_2 + PI * f64::from(i) / f64::from(n);
|
||||
@@ -226,7 +238,7 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath {
|
||||
if n < 2 {
|
||||
return path;
|
||||
}
|
||||
let radius = finite(radius).max(0.0);
|
||||
let radius = bounded(radius).max(0.0);
|
||||
if radius <= 0.0 || n < 3 {
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -292,8 +304,8 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath {
|
||||
/// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is
|
||||
/// reduced first, then the radius. Without smoothing this is [`rounded_polygon`].
|
||||
pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath {
|
||||
let s = finite(smoothing).clamp(0.0, 1.0);
|
||||
let radius = finite(radius).max(0.0);
|
||||
let s = bounded(smoothing).clamp(0.0, 1.0);
|
||||
let radius = bounded(radius).max(0.0);
|
||||
let n = points.len();
|
||||
if s <= 0.0 || radius <= 0.0 || n < 3 {
|
||||
return rounded_polygon(points, radius);
|
||||
@@ -444,17 +456,17 @@ fn line_or_move(path: &mut BezPath, p: Point) {
|
||||
/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring,
|
||||
/// without the sharp corners where an arc's ends meet its sides.
|
||||
pub fn full_sweep(sweep: f64) -> bool {
|
||||
finite(sweep).abs() >= TAU - 1e-9
|
||||
bounded(sweep).abs() >= TAU - 1e-9
|
||||
}
|
||||
|
||||
/// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in
|
||||
/// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1.
|
||||
pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath {
|
||||
let (rx, ry) = (finite(width).max(0.0) / 2.0, finite(height).max(0.0) / 2.0);
|
||||
let (rx, ry) = (bounded(width).max(0.0) / 2.0, bounded(height).max(0.0) / 2.0);
|
||||
let c = Point::new(rx, ry);
|
||||
let sweep = finite(sweep).clamp(-TAU, TAU);
|
||||
let start = finite(start);
|
||||
let inner = finite(inner).clamp(0.0, 1.0);
|
||||
let sweep = bounded(sweep).clamp(-TAU, TAU);
|
||||
let start = bounded(start);
|
||||
let inner = bounded(inner).clamp(0.0, 1.0);
|
||||
let full = full_sweep(sweep);
|
||||
let tol = arc_tolerance(rx.max(ry));
|
||||
let mut path = BezPath::new();
|
||||
|
||||
@@ -8,9 +8,16 @@ pub const FLATTEN_TOL: f64 = 0.05;
|
||||
/// The most flattened points a path is measured with; longer paths are refused.
|
||||
pub const MAX_SAMPLES: usize = 40_000;
|
||||
|
||||
/// Every coordinate is finite and of a sane magnitude.
|
||||
/// How far out a measured coordinate may lie. The shape builders hold their sizes, radii and
|
||||
/// angles to [`crate::MAX_COORD`], so a path built at the cap has its points there and the
|
||||
/// control points of its curves a little past them (the cubics approximating an arc reach past
|
||||
/// the arc's box by up to about a tenth of the radius). Half as much again keeps every built path
|
||||
/// measurable and still refuses the absurd.
|
||||
pub const MAX_MEASURED_COORD: f64 = crate::MAX_COORD * 1.5;
|
||||
|
||||
/// Every coordinate is finite and of a sane magnitude (within [`MAX_MEASURED_COORD`]).
|
||||
pub fn finite_path(path: &BezPath) -> bool {
|
||||
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
|
||||
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_MEASURED_COORD && p.y.abs() <= MAX_MEASURED_COORD;
|
||||
path.elements().iter().all(|el| match el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p),
|
||||
PathEl::QuadTo(a, b) => ok(a) && ok(b),
|
||||
@@ -66,7 +73,7 @@ impl Polyline {
|
||||
/// The nearest point to `p`: (t by length, distance).
|
||||
pub fn nearest(&self, p: Point) -> Option<(f64, f64)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 {
|
||||
if len <= 1e-9 || !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
|
||||
@@ -101,6 +101,95 @@ fn smoothed_corner_draws_the_polygon_corner() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The measurement's sanity bound is the builders' cap, inclusive: a path built at `MAX_COORD`
|
||||
/// has coordinates exactly there and still measures; one past it doesn't.
|
||||
#[test]
|
||||
fn paths_at_the_cap_still_measure() {
|
||||
use crate::measure::{finite_path, polylines};
|
||||
let at_cap = rounded_rect(MAX_COORD, MAX_COORD, [0.0; 4], 0.0);
|
||||
let b = at_cap.bounding_box();
|
||||
assert_eq!((b.x1, b.y1), (MAX_COORD, MAX_COORD), "corners exactly at the cap");
|
||||
assert!(finite_path(&at_cap));
|
||||
assert!(polylines(&at_cap).is_some(), "a rectangle at the cap measures");
|
||||
let mut line = BezPath::new();
|
||||
line.move_to(Point::new(-MAX_COORD, 0.0));
|
||||
line.line_to(Point::new(MAX_COORD, -MAX_COORD));
|
||||
assert!(polylines(&line).is_some(), "a line between the cap's corners measures");
|
||||
// A curve at the cap: the cubics approximating the arc have control points past the cap, and
|
||||
// the path still measures.
|
||||
let arc = ellipse(MAX_COORD, 1.0, -0.15, 0.2, 0.0);
|
||||
let reach = arc
|
||||
.elements()
|
||||
.iter()
|
||||
.flat_map(|el| match el {
|
||||
kurbo::PathEl::CurveTo(a, b, _) => vec![a.x, b.x],
|
||||
_ => vec![],
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
assert!(reach > MAX_COORD, "a control point lies past the cap: {reach}");
|
||||
assert!(finite_path(&arc));
|
||||
assert!(polylines(&arc).is_some(), "an arc at the cap measures");
|
||||
let mut past = BezPath::new();
|
||||
past.move_to(Point::new(0.0, 0.0));
|
||||
past.line_to(Point::new(2.0 * MAX_COORD, 0.0));
|
||||
assert!(!finite_path(&past));
|
||||
assert!(polylines(&past).is_none(), "a coordinate well past the cap is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_sizes_build_bounded_paths() {
|
||||
// kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the
|
||||
// segment count grows without bound with the input: a 1e36 ellipse was about a million
|
||||
// elements and 1e60 never returned. Sizes are untrusted (files, commands), so they are capped.
|
||||
// At the cap itself (1e9) an arc is still a few dozen segments.
|
||||
for mag in [MAX_COORD, 1e36, 1e300, f64::MAX] {
|
||||
let paths = [
|
||||
("ellipse", ellipse(mag, mag, 0.0, PI, 0.0)),
|
||||
("ring", ellipse(mag, mag, 0.0, TAU, 0.5)),
|
||||
("rounded_rect", rounded_rect(mag, mag, [mag; 4], 0.0)),
|
||||
("smoothed rect", rounded_rect(mag, mag, [mag; 4], 0.6)),
|
||||
("smoothed_polygon", smoothed_polygon(&polygon_points(mag, mag, 5), mag, 0.6)),
|
||||
];
|
||||
for (name, p) in paths {
|
||||
assert!(p.elements().len() < 1_000, "{name} at {mag:e}: {} elements", p.elements().len());
|
||||
let b = p.bounding_box();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1.01 * MAX_COORD), "{name} at {mag:e}: {b:?}");
|
||||
}
|
||||
// Points are the caller's to keep sane; a huge radius is still capped.
|
||||
let p = smoothed_polygon(&[Point::new(0.0, 0.0), Point::new(mag, 0.0), Point::new(mag, mag)], mag, 0.6);
|
||||
assert!(p.elements().len() < 1_000, "huge points at {mag:e}: {} elements", p.elements().len());
|
||||
}
|
||||
// NaN, infinities and negative sizes build nothing or a bounded path with a finite box.
|
||||
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1e36, -1.0] {
|
||||
let paths = [
|
||||
("ellipse", ellipse(v, v, v, v, v)),
|
||||
("rounded_rect", rounded_rect(v, v, [v; 4], v)),
|
||||
("smoothed_polygon", smoothed_polygon(&polygon_points(100.0, 100.0, 5), v, v)),
|
||||
];
|
||||
for (name, p) in paths {
|
||||
assert!(p.elements().len() < 1_000, "{name} at {v}: {} elements", p.elements().len());
|
||||
let b = p.bounding_box();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|x| x.is_finite()), "{name} at {v}: {b:?}");
|
||||
}
|
||||
}
|
||||
// Sizes a document can hold are untouched.
|
||||
let p = rounded_rect(1e6, 1e6, [1e5; 4], 0.6);
|
||||
let b = p.bounding_box();
|
||||
assert!((b.width() - 1e6).abs() < 1e-3 && (b.height() - 1e6).abs() < 1e-3, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_refuses_a_non_finite_point() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((10.0, 0.0));
|
||||
let lines = measure::polylines(&p).unwrap();
|
||||
assert_eq!(lines[0].nearest(Point::new(f64::NAN, 0.0)), None);
|
||||
assert_eq!(lines[0].nearest(Point::new(0.0, f64::INFINITY)), None);
|
||||
let (t, d) = lines[0].nearest(Point::new(5.0, 3.0)).unwrap();
|
||||
assert!((t - 0.5).abs() < 1e-9 && (d - 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_first_vertex_top_center() {
|
||||
let pts = polygon_points(100.0, 100.0, 3);
|
||||
|
||||
Reference in new issue
Block a user