mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
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>
278 lines
11 KiB
Rust
278 lines
11 KiB
Rust
//! 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);
|
||
}
|
||
}
|