Files
vectorcraft/crates/geom/src/lib.rs
T
Dylan M. TaylorandClaude Opus 5.5 e2a870e602 Security: bounded flattening, plug-in budgets and caches, loaded textures, Revolve paint copies, color book names and ICC names
CodeRabbit findings #4, #34, #46, #51, #77, #101, #170, #171, #188:

- `vectorcraft_geom::flatten`: kurbo's flatten with each curve's tolerance raised so it makes
  at most ~4096 segments (non-finite curves become a line); the canvas outline and
  variable-width strokes use it.
- Plug-ins: a heavy filter's budget is the host's `Limits::heavy_fuel`/`heavy_wall_time_ms`,
  never more; a start function runs on one fuel slice; the effect cache holds at most ~1M
  anchors of inputs and results, and doesn't keep a result past a quarter of that.
- Loaded textures are cached by their PNG (base64 spacing doesn't matter), and live textures
  hold at most 512 MB together.
- Revolve draws the source path when the faces' paint copies would pass 256 MB.
- Color books: names with prefix and suffix take at most 64 MB.
- ICC: localized-name (`mluc`) records over 1 MB together are refused before moxcms copies them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:19:54 +07:00

278 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! VectorCraft geometry kernel.
//!
//! The editable path model is anchor based (like every Illustrator tool thinks about paths):
//! a [`PathData`] is a list of [`SubPath`]s, each a list of [`Anchor`]s with absolute in/out
//! handle positions. Rendering and algorithms convert to [`kurbo::BezPath`].
//!
//! Coordinates are document points (1/72 in), y pointing down.
#![forbid(unsafe_code)]
pub mod arc;
pub mod bez;
pub mod corners;
pub mod hit;
pub mod path;
pub mod projective;
pub mod recognize;
pub mod shapes;
pub mod snap;
pub use arc::ArcPath;
pub use kurbo;
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
pub use projective::Homography;
/// Tolerance used when comparing handle positions to anchor positions.
pub const EPS: f64 = 1e-9;
/// Most line segments [`flatten`] makes of one curve, whatever its size and the tolerance.
pub const MAX_FLATTEN_PER_CURVE: f64 = 4096.0;
/// [`kurbo::flatten`], each curve's tolerance raised so that it makes at most about
/// [`MAX_FLATTEN_PER_CURVE`] segments. kurbo sizes its work and a buffer from the curve's
/// coordinates before producing anything, so a small curve with far-out handles (from a file, or
/// scaled up by a view or a projection) asked for gigabytes. The segments a curve needs grow with
/// the square root of its control polygon's length over the tolerance; that ratio is held under
/// the square of the cap. A curve whose points aren't finite is drawn as a line to its end.
pub fn flatten(path: impl IntoIterator<Item = PathEl>, tolerance: f64, mut callback: impl FnMut(PathEl)) {
let mut last = None;
let mut start = None;
for el in path {
let (from, to, length) = match el {
PathEl::MoveTo(p) => {
(last, start) = (Some(p), Some(p));
callback(el);
continue;
}
PathEl::LineTo(p) => {
last = Some(p);
callback(el);
continue;
}
PathEl::ClosePath => {
last = start;
callback(el);
continue;
}
PathEl::QuadTo(a, b) => (last, b, last.map(|p| p.distance(a) + a.distance(b))),
PathEl::CurveTo(a, b, c) => (last, c, last.map(|p| p.distance(a) + a.distance(b) + b.distance(c))),
};
last = Some(to);
let (Some(from), Some(length)) = (from, length) else { continue };
if !length.is_finite() {
callback(PathEl::LineTo(to));
continue;
}
let tol = tolerance.max(length / (MAX_FLATTEN_PER_CURVE * MAX_FLATTEN_PER_CURVE));
kurbo::flatten([PathEl::MoveTo(from), el], tol, |e| {
if !matches!(e, PathEl::MoveTo(_)) {
callback(e);
}
});
}
}
/// Union of two optional rects.
pub fn union_opt(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
match (a, b) {
(Some(a), Some(b)) => Some(a.union(b)),
(a, None) => a,
(None, b) => b,
}
}
/// Normalize an angle in degrees to (-180, 180].
pub fn normalize_deg(mut a: f64) -> f64 {
a %= 360.0;
if a <= -180.0 {
a += 360.0;
} else if a > 180.0 {
a -= 360.0;
}
a
}
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
constrain_angle_from(v, step_deg, 0.0)
}
/// [`constrain_angle`] in steps counted from `base_deg` (the Constrain Angle preference,
/// counter-clockwise as seen on the y-down page).
pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 {
let len = v.hypot();
if len < EPS {
return v;
}
// A whole turn (360°, −720°…) is no turn at all.
let base_deg = if base_deg.is_finite() { normalize_deg(base_deg) } else { 0.0 };
let (step, base) = (step_deg.to_radians(), -base_deg.to_radians());
let a = base + ((v.y.atan2(v.x) - base) / step).round() * step;
Vec2::new(a.cos() * len, a.sin() * len)
}
/// The page axes turned by `deg` (the Constrain Angle preference, counter-clockwise as seen on
/// the y-down page): maps a vector along the turned axes onto the page. `None` when they are the
/// page's own axes (any multiple of 360°, or a non-finite angle), so callers keep exact page
/// coordinates; multiples of 90° are exact quarter turns, without floating-point noise.
pub fn constrain_axes(deg: f64) -> Option<Affine> {
if !deg.is_finite() {
return None;
}
let d = normalize_deg(deg);
let (cos, sin) = if d == 0.0 {
return None;
} else if d == 90.0 {
(0.0, 1.0)
} else if d == 180.0 {
(-1.0, 0.0)
} else if d == -90.0 {
(0.0, -1.0)
} else {
let r = d.to_radians();
(r.cos(), r.sin())
};
// Counter-clockwise on a y-down page is a negative rotation of the coordinates.
Some(Affine::new([cos, -sin, sin, cos, 0.0, 0.0]))
}
/// A rotation by `deg` (clockwise as seen on the y-down page, as [`Affine::rotate`] takes it in
/// radians), exact at quarter turns and the identity at whole turns; 0 is `Affine::rotate(0.0)`
/// as before, and any other angle is `Affine::rotate` itself.
pub fn rotate_deg(deg: f64) -> Affine {
if deg.is_finite() && deg != 0.0 {
let d = normalize_deg(deg);
if d == 0.0 {
return Affine::IDENTITY;
}
if d % 90.0 == 0.0
&& let Some(a) = constrain_axes(-d)
{
return a;
}
}
Affine::rotate(deg.to_radians())
}
/// The `rotation` (degrees, as the shape and type commands take it) that turns a new object from
/// the page axes onto the axes of Constrain Angle `base`; `None` for the page's own axes (0°, a
/// whole turn, a non-finite angle), where new objects are drawn exactly as without it.
pub fn constrain_rotation(base: f64) -> Option<f64> {
constrain_axes(base).map(|_| -normalize_deg(base))
}
/// Rect from two corner points in any order.
pub fn rect_from_points(a: Point, b: Point) -> Rect {
Rect::from_points(a, b)
}
/// The 9 reference points of a rect (Illustrator's reference-point locator), row-major from top-left.
pub fn reference_point(r: Rect, index: usize) -> Point {
let xs = [r.x0, r.center().x, r.x1];
let ys = [r.y0, r.center().y, r.y1];
Point::new(xs[index % 3], ys[(index / 3).min(2)])
}
#[cfg(test)]
mod tests {
use super::*;
/// A tiny curve with far-out handles flattens into a bounded number of segments; an ordinary
/// one exactly as kurbo flattens it.
#[test]
fn flatten_bounds_the_segments_of_one_curve() {
let count = |path: &[PathEl], tol: f64| {
let mut n = 0usize;
flatten(path.iter().copied(), tol, |_| n += 1);
n
};
let wild = [PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::new(1e15, -1e15), Point::new(-1e15, 1e15), Point::new(1.0, 0.0))];
assert!(count(&wild, 0.25) < 3 * MAX_FLATTEN_PER_CURVE as usize);
let plain =
[PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::new(0.0, 50.0), Point::new(100.0, 50.0), Point::new(100.0, 0.0)), PathEl::ClosePath];
let mut ours = vec![];
flatten(plain.iter().copied(), 0.25, |e| ours.push(e));
let mut theirs = vec![];
kurbo::flatten(plain.iter().copied(), 0.25, |e| theirs.push(e));
assert_eq!(ours, theirs);
let nan = [PathEl::MoveTo(Point::ZERO), PathEl::QuadTo(Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0))];
assert_eq!(count(&nan, 0.25), 2);
}
#[test]
fn constrain_axes_are_exact_and_whole_turns_are_none() {
for d in [0.0, 360.0, -360.0, 720.0, f64::NAN, f64::INFINITY] {
assert!(constrain_axes(d).is_none(), "{d}");
}
let v = Point::new(3.0, 4.0);
// 90° counter-clockwise on the page: x points up (−y).
assert_eq!(constrain_axes(90.0).unwrap() * Point::new(1.0, 0.0), Point::new(0.0, -1.0));
assert_eq!(constrain_axes(450.0).unwrap() * v, constrain_axes(90.0).unwrap() * v);
assert_eq!(constrain_axes(-90.0).unwrap() * v, Point::new(-4.0, 3.0));
assert_eq!(constrain_axes(180.0).unwrap() * v, Point::new(-3.0, -4.0));
assert_eq!(constrain_axes(-180.0).unwrap() * v, Point::new(-3.0, -4.0));
let a = constrain_axes(30.0).unwrap() * Point::new(1.0, 0.0);
assert!((a.x - 30f64.to_radians().cos()).abs() < 1e-12 && (a.y + 0.5).abs() < 1e-12, "{a:?}");
assert_eq!(constrain_angle_from(Vec2::new(5.0, 1.0), 45.0, 360.0), constrain_angle_from(Vec2::new(5.0, 1.0), 45.0, 0.0));
}
#[test]
fn rotate_deg_is_exact_at_quarter_turns() {
let v = Point::new(3.0, 4.0);
assert_eq!(rotate_deg(90.0) * v, Point::new(-4.0, 3.0));
assert_eq!(rotate_deg(-90.0) * v, Point::new(4.0, -3.0));
assert_eq!(rotate_deg(180.0) * v, Point::new(-3.0, -4.0));
assert_eq!(rotate_deg(-270.0) * v, rotate_deg(90.0) * v);
assert_eq!(rotate_deg(360.0) * v, v);
assert_eq!(rotate_deg(0.0), Affine::rotate(0.0));
assert_eq!(rotate_deg(30.0), Affine::rotate(30f64.to_radians()));
assert_eq!(constrain_rotation(30.0), Some(-30.0));
assert_eq!(constrain_rotation(0.0), None);
assert_eq!(constrain_rotation(-360.0), None);
assert_eq!(constrain_rotation(450.0), Some(-90.0));
}
#[test]
fn angle_normalization() {
assert_eq!(normalize_deg(190.0), -170.0);
assert_eq!(normalize_deg(-190.0), 170.0);
assert_eq!(normalize_deg(180.0), 180.0);
assert_eq!(normalize_deg(720.0), 0.0);
}
#[test]
fn constrain_45() {
let v = constrain_angle(Vec2::new(10.0, 1.0), 45.0);
assert!((v.y).abs() < 1e-9);
let v = constrain_angle(Vec2::new(10.0, 9.0), 45.0);
assert!((v.x - v.y).abs() < 1e-9);
}
#[test]
fn constrain_from_a_base_angle() {
// 30° base: 45° steps land on 30°, 75°, … (counter-clockwise on the page, y down).
let deg = |v: Vec2| (-v.y).atan2(v.x).to_degrees();
assert!((deg(constrain_angle_from(Vec2::new(10.0, -4.0), 45.0, 30.0)) - 30.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(3.0, -10.0), 45.0, 30.0)) - 75.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(10.0, 1.0), 45.0, 30.0)) + 15.0).abs() < 1e-9);
}
#[test]
fn reference_points() {
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
assert_eq!(reference_point(r, 0), Point::new(0.0, 0.0));
assert_eq!(reference_point(r, 4), Point::new(5.0, 10.0));
assert_eq!(reference_point(r, 8), Point::new(10.0, 20.0));
}
#[test]
fn union_options() {
let a = Rect::new(0.0, 0.0, 1.0, 1.0);
let b = Rect::new(2.0, 2.0, 3.0, 3.0);
assert_eq!(union_opt(Some(a), Some(b)), Some(Rect::new(0.0, 0.0, 3.0, 3.0)));
assert_eq!(union_opt(None, Some(b)), Some(b));
assert_eq!(union_opt(None, None), None);
}
}