Files
vectorcraft/crates/effects/src/util.rs
T
Brian TorresandClaude Opus 5.5 54c8a7f2bd Effects: stacked geometry effects keep to an anchor and line budget (#3578)
A chain of geometry effects makes at most 2 million anchors of a path (of a
type, symbol or image art's paths together) and about 8 million lines for the
renderer to draw at 1 px/pt, or what the path costs when that is more.
Roughen spaces its points further apart, Zig Zag makes fewer ridges a
segment, Free Distort, Twist and the warps split segments into longer pieces
(map_nonlinear_within) and Transform makes fewer copies where they would pass
it; a result drawn with too many lines is built again with fewer points, and
an effect that still doesn't fit is left out.

Three Roughens of a rectangle in a 25 KB file needed gigabytes of geometry,
and 2 million anchors of them flattened into 141 million lines (7 GB, past
vello's 2^27 lines a path); the file now opens in 0.4 s at about 450 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:44:52 +07:00

146 lines
5.7 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.
//! Parameter parsing, deterministic noise and path-mapping helpers.
use serde_json::Value;
use vectorcraft_geom::{Anchor, CubicBez, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
/// Number param (also accepts numeric strings such as `"10 pt"`); non-finite → default.
pub fn num(p: &Value, key: &str, default: f64) -> f64 {
match p.get(key) {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => {
let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect();
t.parse().ok()
}
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
.filter(|v| v.is_finite())
.unwrap_or(default)
}
pub fn flag(p: &Value, key: &str, default: bool) -> bool {
match p.get(key) {
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().is_some_and(|v| v != 0.0),
_ => default,
}
}
pub fn text<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str {
p.get(key).and_then(Value::as_str).unwrap_or(default)
}
pub fn join(p: &Value, key: &str) -> vectorcraft_pathops::Join {
match text(p, key, "miter").to_ascii_lowercase().as_str() {
"round" => vectorcraft_pathops::Join::Round,
"bevel" => vectorcraft_pathops::Join::Bevel,
_ => vectorcraft_pathops::Join::Miter,
}
}
/// "smooth" (default) vs "corner" points.
pub fn smooth_points(p: &Value) -> bool {
!text(p, "points", "smooth").eq_ignore_ascii_case("corner")
}
/// Deterministic hash noise in [-1, 1] for (seed, a, b, c).
pub fn noise(seed: u64, a: u64, b: u64, c: u64) -> f64 {
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ a.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ b.wrapping_mul(0x94D0_49BB_1331_11EB) ^ c;
// splitmix64 finaliser
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
(x >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
pub fn seed(p: &Value) -> u64 {
num(p, "seed", 0.0).abs() as u64
}
/// Mean side of a box (the reference for "relative" sizes).
pub fn mean_size(b: Rect) -> f64 {
(b.width().abs() + b.height().abs()) / 2.0
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c }
}
/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`), and the split of
/// segments into pieces within an anchor budget.
pub use vectorcraft_doc::live::{fit_piece, map_nonlinear_within, pieces};
/// About how many lines the renderer flattens `path` into at 1 px/pt: one for a straight
/// segment, and for a curve the count Wang's formula gives at vello's tolerance of 0.25 px,
/// `sqrt(0.75 × d / 0.25)` for the largest second difference `d` of its control points.
pub fn line_estimate(path: &PathData) -> usize {
let curve = |c: CubicBez| {
let d =
(c.p0.to_vec2() - c.p1.to_vec2() * 2.0 + c.p2.to_vec2()).hypot().max((c.p1.to_vec2() - c.p2.to_vec2() * 2.0 + c.p3.to_vec2()).hypot());
// A NaN casts to 0 and an infinity to the largest count.
((3.0 * d).sqrt().ceil() as usize).max(1)
};
path.subpaths
.iter()
.flat_map(|sp| (0..sp.segment_count()).map(move |i| if sp.segment_is_line(i) { 1 } else { curve(sp.segment(i)) }))
.fold(0, usize::saturating_add)
}
/// Smooth subpath through `pts` (Catmull-Rom tangents, see [`turn_tangent`] for sharp turns).
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
let n = pts.len();
if n < 3 {
return SubPath::polyline(pts, closed);
}
let anchors = (0..n)
.map(|i| {
let prev = if i == 0 { if closed { pts[n - 1] } else { pts[0] } } else { pts[i - 1] };
let next = if i == n - 1 { if closed { pts[0] } else { pts[n - 1] } } else { pts[i + 1] };
let t: Vec2 = turn_tangent(prev, pts[i], next) * (tension / 6.0);
Anchor::with_handles(pts[i], pts[i] - t, pts[i] + t)
})
.collect();
SubPath::new(anchors, closed)
}
/// The Catmull-Rom tangent `next − prev` at `p`. Where the path turns back sharply, `next`
/// nearly meets `prev` and that tangent shrinks to nothing: the anchor would be a cusp, which
/// PDF viewers draw with a miter spike (#1643). So it is kept at least half as long as the
/// longer chord, and there it follows the bisector of the two chords (the perpendicular of the
/// incoming one when the path reverses exactly): the turn becomes a round hairpin. Turns of
/// less than about 150° keep the plain Catmull-Rom tangent.
fn turn_tangent(prev: Point, p: Point, next: Point) -> Vec2 {
let t = next - prev;
let (a, b) = (p - prev, next - p);
let floor = 0.5 * a.hypot().max(b.hypot());
if t.hypot() >= floor {
return t;
}
let unit = |v: Vec2| {
let l = v.hypot();
if l > 1e-12 { v / l } else { Vec2::ZERO }
};
let (u, v) = (unit(a), unit(b));
let mut d = unit(u + v);
if d == Vec2::ZERO {
d = Vec2::new(-u.y, u.x);
}
if d == Vec2::ZERO { t } else { d * floor }
}
/// Unit normal (left of travel direction in y-down coordinates) of cubic `c` at `t`.
pub fn normal_at(c: &CubicBez, t: f64) -> Vec2 {
let eps = 1e-4;
let a = c.eval((t - eps).max(0.0));
let b = c.eval((t + eps).min(1.0));
let mut d = b - a;
if d.hypot() < 1e-12 {
d = c.p3 - c.p0;
}
let len = d.hypot();
if len < 1e-12 { Vec2::ZERO } else { Vec2::new(d.y / len, -d.x / len) }
}