mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
1673b3b747
commit
54c8a7f2bd
+60
-2
@@ -467,11 +467,49 @@ fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
|
||||
cum
|
||||
}
|
||||
|
||||
/// Most pieces [`map_nonlinear`] splits one segment into.
|
||||
const MAX_PIECES: usize = 64;
|
||||
|
||||
/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`.
|
||||
pub fn pieces(len: f64, piece: f64, most: usize) -> usize {
|
||||
((len / piece).ceil() as usize).clamp(1, most.max(1))
|
||||
}
|
||||
|
||||
/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so
|
||||
/// that the pieces past each segment's first number at most `room`: `piece` when they already do,
|
||||
/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path,
|
||||
/// so pieces of the total length over `room` fit; with no room every segment stays one piece.
|
||||
pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 {
|
||||
let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add);
|
||||
if added <= room {
|
||||
return piece;
|
||||
}
|
||||
if room == 0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
// A NaN length is always one piece.
|
||||
piece.max(lens.iter().filter(|len| !len.is_nan()).sum::<f64>() / room as f64)
|
||||
}
|
||||
|
||||
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
|
||||
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
|
||||
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
|
||||
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
|
||||
let max_piece = max_piece.max(1e-3);
|
||||
map_nonlinear_within(path, max_piece, usize::MAX, f)
|
||||
}
|
||||
|
||||
/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more:
|
||||
/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]),
|
||||
/// a coarser approximation of the image curve. Geometry effects map within their anchor budget.
|
||||
pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData {
|
||||
let mut max_piece = max_piece.max(1e-3);
|
||||
// Each segment adds one anchor per piece past its first.
|
||||
let room = max.saturating_sub(path.anchor_count());
|
||||
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
|
||||
if segs.saturating_mul(MAX_PIECES - 1) > room {
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect();
|
||||
max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room);
|
||||
}
|
||||
let mut subs = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in &path.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
@@ -487,7 +525,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
|
||||
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
|
||||
if i < segs {
|
||||
let c = seg_cubic(sp, i);
|
||||
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
|
||||
let k = pieces(poly_len(&c), max_piece, MAX_PIECES);
|
||||
for j in 0..k {
|
||||
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
|
||||
if let Some(last) = res.last_mut() {
|
||||
@@ -1884,6 +1922,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3578: a non-linear map within an anchor budget splits its segments into longer pieces,
|
||||
/// down to one a segment, and matches the plain map where the budget leaves room.
|
||||
#[test]
|
||||
fn map_nonlinear_within_keeps_to_its_anchor_budget() {
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let f = |p: Point| Point::new(p.x + (p.y / 10.0).sin(), p.y);
|
||||
let full = map_nonlinear(&square, 1.0, f);
|
||||
assert_eq!(full.anchor_count(), 4 * 64);
|
||||
assert_eq!(map_nonlinear_within(&square, 1.0, 4 * 64, f), full);
|
||||
// Every side takes the same pieces: of 400 / (max - 4) pt, so 12 a side for 50 anchors.
|
||||
for (max, want) in [(0, 4), (4, 4), (5, 4), (50, 4 * 12), (255, 4 * 63)] {
|
||||
assert_eq!(map_nonlinear_within(&square, 1.0, max, f).anchor_count(), want, "{max}");
|
||||
}
|
||||
// Lengths that are not numbers stay one piece; with no room every segment is one piece.
|
||||
assert_eq!(fit_piece(&[f64::NAN, 10.0], 1.0, 64, 5), 2.0);
|
||||
assert_eq!(fit_piece(&[f64::INFINITY, 10.0], 1.0, 64, 0), f64::INFINITY);
|
||||
assert_eq!(pieces(f64::INFINITY, f64::INFINITY, 64), 1);
|
||||
assert_eq!(pieces(10.0, 1.0, 0), 1);
|
||||
}
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
|
||||
@@ -10,8 +10,9 @@ fn diag(b: Rect) -> f64 {
|
||||
b.width().hypot(b.height()).max(1e-6)
|
||||
}
|
||||
|
||||
/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates).
|
||||
pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates),
|
||||
/// making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
|
||||
if let Some(arr) = p.get("corners").and_then(Value::as_array) {
|
||||
for (i, v) in arr.iter().take(4).enumerate() {
|
||||
@@ -34,7 +35,7 @@ pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let bot = c[3].lerp(c[2], u);
|
||||
top.lerp(bot, v)
|
||||
};
|
||||
map_nonlinear(path, diag(b) / 16.0, f)
|
||||
map_nonlinear_within(path, diag(b) / 16.0, max, f)
|
||||
}
|
||||
|
||||
/// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the
|
||||
@@ -67,21 +68,29 @@ pub fn pucker_bloat(path: &PathData, b: Rect, amount: f64) -> PathData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`.
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
/// Most points Roughen resamples one segment into.
|
||||
const MAX_ROUGHEN_POINTS: usize = 2000;
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where
|
||||
/// that would make more than `max` anchors, the points are spaced further apart until it doesn't
|
||||
/// (stacked Roughens multiply their points, #3578).
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 5.0).clamp(0.0, 1e4);
|
||||
let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size };
|
||||
let detail = num(p, "detail", 10.0).clamp(0.0, 100.0);
|
||||
let spacing = if detail > 0.0 { 72.0 / detail } else { f64::INFINITY };
|
||||
// A segment of k points adds k - 1 anchors to the path.
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect();
|
||||
let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count()));
|
||||
let mut lens = lens.iter();
|
||||
let smooth = smooth_points(p);
|
||||
let seed = seed(p);
|
||||
let mut subs = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let mut pts = vec![];
|
||||
for i in 0..sp.segment_count() {
|
||||
for (i, &len) in (0..sp.segment_count()).zip(&mut lens) {
|
||||
let c = sp.segment(i);
|
||||
let len = vectorcraft_geom::kurbo::ParamCurveArclen::arclen(&c, 0.1);
|
||||
let k = if spacing.is_finite() { ((len / spacing).ceil() as usize).clamp(1, 2000) } else { 1 };
|
||||
let k = pieces(len, spacing, MAX_ROUGHEN_POINTS);
|
||||
for j in 0..k {
|
||||
pts.push(c.eval(j as f64 / k as f64));
|
||||
}
|
||||
@@ -246,8 +255,9 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
out
|
||||
}
|
||||
|
||||
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle.
|
||||
pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle,
|
||||
/// making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
|
||||
let ang = angle.clamp(-3600.0, 3600.0).to_radians();
|
||||
if ang == 0.0 {
|
||||
return path.clone();
|
||||
@@ -261,8 +271,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
let (s, co) = a.sin_cos();
|
||||
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
|
||||
};
|
||||
let pieces = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0));
|
||||
map_nonlinear(path, pieces, f)
|
||||
let piece = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0));
|
||||
map_nonlinear_within(path, piece, max, f)
|
||||
}
|
||||
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
@@ -280,11 +290,17 @@ struct Ridge {
|
||||
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
|
||||
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
|
||||
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
|
||||
/// whatever the size of the ridges (#2343).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
/// whatever the size of the ridges (#2343). Where `ridges` would make more than `max` anchors,
|
||||
/// each segment gets as many as fit (stacked Zig Zags multiply their points, #3578).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
let ridges = num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize;
|
||||
// Each segment makes `ridges + 1` points, and an open path's end one more (all its anchors
|
||||
// when it has no segment). With no ridges, that is at most the path's own anchors.
|
||||
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
|
||||
let ends: usize = path.subpaths.iter().filter(|sp| !sp.closed).map(|sp| if sp.segment_count() > 0 { 1 } else { sp.anchors.len() }).sum();
|
||||
let fit = (max.saturating_sub(ends) / segs.max(1)).saturating_sub(1);
|
||||
let ridges = (num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize).min(fit);
|
||||
let smooth = smooth_points(p);
|
||||
let mut subs = vec![];
|
||||
for sp in &path.subpaths {
|
||||
|
||||
+101
-9
@@ -1083,15 +1083,80 @@ impl<'a> GeomContext<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type,
|
||||
/// symbol or image art together; a path that already has more keeps its own count as the limit.
|
||||
/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps
|
||||
/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of
|
||||
/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is
|
||||
/// left out.
|
||||
pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at
|
||||
/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large
|
||||
/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of
|
||||
/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past
|
||||
/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few
|
||||
/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does.
|
||||
pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000;
|
||||
|
||||
/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the
|
||||
/// renderer is estimated to draw it with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Budget {
|
||||
pub anchors: usize,
|
||||
pub lines: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// The budget of a chain of geometry effects.
|
||||
pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES };
|
||||
|
||||
/// What `path` costs.
|
||||
pub(crate) fn of(path: &PathData) -> Self {
|
||||
Self { anchors: path.anchor_count(), lines: util::line_estimate(path) }
|
||||
}
|
||||
|
||||
/// This budget, raised to `cost` where that is more.
|
||||
pub(crate) fn at_least(self, cost: Self) -> Self {
|
||||
Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) }
|
||||
}
|
||||
|
||||
/// Does `cost` fit this budget?
|
||||
pub(crate) fn fits(self, cost: Self) -> bool {
|
||||
cost.anchors <= self.anchors && cost.lines <= self.lines
|
||||
}
|
||||
|
||||
/// What is left of this budget once `cost` is spent.
|
||||
pub(crate) fn minus(self, cost: Self) -> Self {
|
||||
Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) }
|
||||
}
|
||||
|
||||
/// This cost and `cost` together.
|
||||
pub(crate) fn plus(self, cost: Self) -> Self {
|
||||
Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the
|
||||
/// reference box of the first effect (usually the object's geometric bounds); each later effect
|
||||
/// uses the bounds of its input. Raster and unknown effects are skipped.
|
||||
/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect
|
||||
/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts,
|
||||
/// when they are more).
|
||||
pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData {
|
||||
apply_geometry_with(effects, path, bounds, &GeomContext::default())
|
||||
}
|
||||
|
||||
/// [`apply_geometry`] with an explicit [`GeomContext`].
|
||||
pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS)
|
||||
}
|
||||
|
||||
/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that
|
||||
/// is more.
|
||||
pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData {
|
||||
// `budget` raised to what `path` costs, measured once, and only for a path that has geometry
|
||||
// effects.
|
||||
let mut limit = None;
|
||||
let mut cur = path.clone();
|
||||
let mut b = bounds;
|
||||
let mut first = true;
|
||||
@@ -1104,7 +1169,10 @@ pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ct
|
||||
break;
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
cur = apply_one(&e.id, &p, &cur, b, ctx);
|
||||
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
@@ -1114,20 +1182,44 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its
|
||||
/// result would not fit `budget` (the effects that multiply anchors build within its anchors).
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option<PathData> {
|
||||
let out = apply_unchecked(id, p, path, b, ctx, budget.anchors);
|
||||
let cost = Budget::of(&out);
|
||||
if budget.fits(cost) {
|
||||
return Some(out);
|
||||
}
|
||||
if cost.lines <= budget.lines || !builds_within(id) {
|
||||
return None;
|
||||
}
|
||||
// Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer
|
||||
// anchors as there were lines too many, and a tenth to spare, as segments vary in their lines.
|
||||
let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize;
|
||||
let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors));
|
||||
budget.fits(Budget::of(&out)).then_some(out)
|
||||
}
|
||||
|
||||
/// Does geometry effect `id` build within the anchors it is given?
|
||||
fn builds_within(id: &str) -> bool {
|
||||
matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.")
|
||||
}
|
||||
|
||||
/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it.
|
||||
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p, max),
|
||||
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
|
||||
"distort.roughen" => distort::roughen(path, b, p),
|
||||
"distort.transform" => distort::transform(path, b, p, ctx.seed),
|
||||
"distort.roughen" => distort::roughen(path, b, p, max),
|
||||
"distort.transform" => distort::transform_within(path, b, p, ctx.seed, max),
|
||||
"distort.tweak" => distort::tweak(path, b, p),
|
||||
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)),
|
||||
"distort.zigZag" => distort::zig_zag(path, b, p),
|
||||
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max),
|
||||
"distort.zigZag" => distort::zig_zag(path, b, p, max),
|
||||
"path.offsetPath" => {
|
||||
let off = num(p, "offset", 10.0).clamp(-1e5, 1e5);
|
||||
if off.abs() < 1e-9 {
|
||||
@@ -1160,7 +1252,7 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
|
||||
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
|
||||
"stylize.scribble" => stylize::scribble(path, b, p),
|
||||
_ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) {
|
||||
Some(style) => warp::warp(path, b, style, p),
|
||||
Some(style) => warp::warp(path, b, style, p, max),
|
||||
None => path.clone(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::sync::Arc;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
|
||||
/// neither a path nor a container)?
|
||||
@@ -372,35 +372,52 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
|
||||
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp).
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
|
||||
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
|
||||
/// whose effect would pass what the others leave of it keeps its shape.
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS);
|
||||
}
|
||||
|
||||
/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost.
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) {
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let seed = art.id.0;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
// pieces, from the outlines' bounds before the effects to their bounds after them.
|
||||
let mut outlines = vec![];
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
let mut used = art_cost(art);
|
||||
let max = budget.at_least(used);
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed));
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// What the other paths leave of the budget.
|
||||
let before = Budget::of(&path);
|
||||
let room = max.minus(used.minus(before));
|
||||
// A gradient paints each copy by itself, not the one piece the copies make together.
|
||||
if let Some((m, copies)) = transform
|
||||
&& copies > 0
|
||||
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
|
||||
{
|
||||
copy_with_gradients(leaf, &path, m, copies);
|
||||
let made = copy_with_gradients(leaf, &path, m, copies, room);
|
||||
used = used.minus(before).plus(made);
|
||||
return;
|
||||
}
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return };
|
||||
used = used.minus(before).plus(Budget::of(&out));
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if let Some((m, copies)) = transform {
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
|
||||
if outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
}
|
||||
@@ -408,29 +425,32 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
|
||||
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
|
||||
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
|
||||
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would,
|
||||
/// or before they pass `budget`. Returns what the copies cost, the original included.
|
||||
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, budget: Budget) -> Budget {
|
||||
let mut made = Budget::of(path);
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > crate::distort::MAX_COPY_ANCHORS {
|
||||
xf *= m;
|
||||
// A copy scaled up is drawn with more lines.
|
||||
let cost = Budget::of(&path.transformed(xf));
|
||||
if !budget.fits(made.plus(cost)) {
|
||||
break;
|
||||
}
|
||||
xf *= m;
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, vectorcraft_doc::Scaling::default());
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
made = made.plus(cost);
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name.
|
||||
let mut g = Node::group(leaf.id, placements);
|
||||
g.name = leaf.name.clone();
|
||||
*leaf = g;
|
||||
made
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
@@ -597,6 +617,18 @@ fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the paths under `n` that [`for_each_leaf`] visits cost together, as [`leaf_geometry`]
|
||||
/// gives them.
|
||||
fn art_cost(n: &Node) -> Budget {
|
||||
let none = Budget { anchors: 0, lines: 0 };
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, path, .. } => Budget::of(path),
|
||||
NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).map(Budget::of).fold(none, Budget::plus),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| art_cost(c)).fold(none, Budget::plus),
|
||||
_ => none,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
|
||||
|
||||
@@ -287,6 +287,153 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
|
||||
}
|
||||
|
||||
/// What `effects` make of `path` within `budget`.
|
||||
fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData {
|
||||
apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget)
|
||||
}
|
||||
|
||||
/// What `effects` make of `path` within a budget of `max` anchors (and any number of lines).
|
||||
fn anchors_within(effects: &[Effect], path: &PathData, max: usize) -> PathData {
|
||||
within(effects, path, Budget { anchors: max, lines: usize::MAX })
|
||||
}
|
||||
|
||||
/// #3578: stacked Roughens multiply their points (up to 2000 a segment); the anchors they make
|
||||
/// stay within the budget, each Roughen spacing its points further apart where it must.
|
||||
#[test]
|
||||
fn stacked_roughens_stay_within_the_anchor_budget() {
|
||||
let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100}));
|
||||
let outs: Vec<PathData> = (1..=3).map(|n| anchors_within(&vec![rough.clone(); n], &square(), 10_000)).collect();
|
||||
let counts: Vec<usize> = outs.iter().map(PathData::anchor_count).collect();
|
||||
// 100 pt sides at 100 points per inch: 139 points each, within the budget.
|
||||
assert_eq!(counts[0], 4 * 139);
|
||||
assert_eq!(outs[0], run("distort.roughen", rough.params.clone(), &square()));
|
||||
assert!(counts[1..].iter().all(|&c| c > 5_000 && c <= 10_000), "{counts:?}");
|
||||
// The third Roughen still roughens, with its points spaced to fit.
|
||||
assert_ne!(outs[2], outs[1]);
|
||||
assert!(outs.iter().all(all_finite));
|
||||
}
|
||||
|
||||
/// #3578: stacked Zig Zags multiply their points (up to 101 a segment); each segment gets as many
|
||||
/// ridges as fit the budget.
|
||||
#[test]
|
||||
fn stacked_zig_zags_stay_within_the_anchor_budget() {
|
||||
let zig = fx("distort.zigZag", json!({"ridges": 100, "points": "corner"}));
|
||||
let counts: Vec<usize> = (1..=3).map(|n| anchors_within(&vec![zig.clone(); n], &square(), 10_000).anchor_count()).collect();
|
||||
// 4 × 101 points; then 23 ridges a segment fit (404 × 24); then none, each point displaced.
|
||||
assert_eq!(counts, [404, 9_696, 9_696]);
|
||||
let twice = anchors_within(&vec![zig.clone(); 2], &square(), 10_000);
|
||||
assert_ne!(anchors_within(&vec![zig.clone(); 3], &square(), 10_000), twice);
|
||||
// An open path's end point counts as well.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
|
||||
assert_eq!(anchors_within(&[zig], &line, 30).anchor_count(), 30, "the start, 28 ridges and the end");
|
||||
}
|
||||
|
||||
/// #3578: every effect that multiplies anchors builds within the budget, stacked or not, and one
|
||||
/// that doesn't (Round Corners doubles them) is left out where it would pass it.
|
||||
#[test]
|
||||
fn stacked_geometry_effects_stay_within_the_anchor_budget() {
|
||||
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 5, 0.0);
|
||||
for (id, params) in [
|
||||
("distort.roughen", json!({"size": 50, "detail": 100})),
|
||||
("distort.zigZag", json!({"ridges": 100, "size": 30})),
|
||||
("distort.twist", json!({"angle": 3600})),
|
||||
("distort.freeDistort", json!({"corners": [[0.3, -2], [1, 0], [4, 1], [0, 3]]})),
|
||||
("warp.twist", json!({"bend": 100})),
|
||||
("distort.transform", json!({"copies": 1000, "moveH": 1})),
|
||||
] {
|
||||
for n in 1..=4 {
|
||||
let out = anchors_within(&vec![fx(id, params.clone()); n], &star, 2_000);
|
||||
assert!(out.anchor_count() <= 2_000 && all_finite(&out), "{id} × {n}: {}", out.anchor_count());
|
||||
}
|
||||
}
|
||||
let zig = fx("distort.zigZag", json!({"ridges": 100, "points": "corner"}));
|
||||
let full = anchors_within(&[zig.clone(), zig.clone()], &square(), 10_000);
|
||||
let round = fx("stylize.roundCorners", json!({"radius": 1}));
|
||||
assert_eq!(anchors_within(&[zig.clone(), zig.clone(), round.clone()], &square(), 10_000), full);
|
||||
assert_ne!(anchors_within(&[zig, round], &square(), 10_000), run("distort.zigZag", json!({"ridges": 100, "points": "corner"}), &square()));
|
||||
}
|
||||
|
||||
/// #3578: a path with more anchors than the budget keeps its count as its budget: effects that
|
||||
/// add none apply, and the others add none.
|
||||
#[test]
|
||||
fn a_path_past_the_anchor_budget_still_takes_effects() {
|
||||
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 25, 0.0);
|
||||
let n = star.anchor_count();
|
||||
for (id, params) in [
|
||||
("distort.tweak", json!({})),
|
||||
("distort.roughen", json!({"detail": 100})),
|
||||
("distort.zigZag", json!({"ridges": 100})),
|
||||
("distort.twist", json!({"angle": 90})),
|
||||
] {
|
||||
let out = anchors_within(&[fx(id, params)], &star, 10);
|
||||
assert_eq!(out.anchor_count(), n, "{id}");
|
||||
assert_ne!(out, star, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3578: the effects on type, symbols and images reshape all their paths within one budget.
|
||||
#[test]
|
||||
fn stacked_effects_on_art_stay_within_the_anchor_budget() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::NodeId;
|
||||
let id = NodeId(9);
|
||||
let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default()));
|
||||
let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect());
|
||||
let rough = fx("distort.roughen", json!({"size": 50, "detail": 100}));
|
||||
crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX });
|
||||
let mut total = 0;
|
||||
let mut reshaped = 0;
|
||||
for c in art.children().into_iter().flatten() {
|
||||
let path = c.path_data().unwrap();
|
||||
total += path.anchor_count();
|
||||
reshaped += usize::from(path.anchor_count() > 4);
|
||||
}
|
||||
// One Roughen alone makes 10 × 556: the budget binds, and goes to the paths in turn.
|
||||
assert!(total > 4_000 && total <= 5_000, "{total}");
|
||||
assert_eq!(reshaped, 10);
|
||||
}
|
||||
|
||||
/// #3578: a large Roughen draws each of its points as many lines; they stay within the budget,
|
||||
/// the Roughen built again with fewer points where they wouldn't.
|
||||
#[test]
|
||||
fn large_roughens_stay_within_the_line_budget() {
|
||||
let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100}));
|
||||
let budget = Budget { anchors: 100_000, lines: 200_000 };
|
||||
let once = within(std::slice::from_ref(&rough), &square(), budget);
|
||||
assert_eq!(once, run("distort.roughen", rough.params.clone(), &square()), "within both budgets");
|
||||
let twice = within(&[rough.clone(), rough], &square(), budget);
|
||||
let cost = Budget::of(&twice);
|
||||
assert!(budget.fits(cost) && cost.lines > 100_000, "{cost:?}");
|
||||
assert!(cost.anchors < 10_000 && twice != once, "the second Roughen with fewer points: {cost:?}");
|
||||
// Art of small curves draws its anchors as a few lines each: the anchors bind first.
|
||||
let zig = fx("distort.zigZag", json!({"ridges": 100, "size": 2}));
|
||||
let out = within(&[zig.clone(), zig], &square(), Budget { anchors: 10_000, lines: 100_000 });
|
||||
assert!(out.anchor_count() > 9_000 && util::line_estimate(&out) < 50_000, "{}", util::line_estimate(&out));
|
||||
}
|
||||
|
||||
/// Straight segments are one line each, and a curve as many as Wang's formula takes at 0.25 px.
|
||||
#[test]
|
||||
fn line_estimate_counts_the_lines_a_path_is_drawn_with() {
|
||||
assert_eq!(util::line_estimate(&square()), 4);
|
||||
let circle = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert_eq!(util::line_estimate(&circle), 4 * 9, "a quarter circle of 50 pt in 9 lines");
|
||||
let big = shapes::ellipse(Rect::new(0.0, 0.0, 10_000.0, 10_000.0));
|
||||
assert_eq!(util::line_estimate(&big), 4 * 84, "100 times as large: about 10 times the lines");
|
||||
}
|
||||
|
||||
/// #3578: the issue's input, a large rectangle with three max-detail Roughens of the largest
|
||||
/// size, at the real budgets: two million anchors of it were 141 million lines to draw.
|
||||
#[test]
|
||||
fn three_max_detail_roughens_stay_within_the_effect_budget() {
|
||||
let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100}));
|
||||
let rect = shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0));
|
||||
let out = apply_geometry(&vec![rough.clone(); 3], &rect, rect.bounds().unwrap());
|
||||
let cost = Budget::of(&out);
|
||||
assert!(Budget::EFFECTS.fits(cost) && cost.lines > MAX_EFFECT_LINES / 2, "{cost:?}");
|
||||
assert_ne!(out, apply_geometry(&[rough], &rect, rect.bounds().unwrap()), "more than one Roughen applies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Parameter parsing, deterministic noise and path-mapping helpers.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Anchor, CubicBez, ParamCurve, Point, Rect, SubPath, Vec2};
|
||||
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 {
|
||||
@@ -69,8 +69,25 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
|
||||
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`).
|
||||
pub use vectorcraft_doc::live::map_nonlinear;
|
||||
/// 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 {
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::util::*;
|
||||
|
||||
pub use vectorcraft_doc::live::{WarpStyle, warp_point};
|
||||
|
||||
/// Apply a warp to `path` using box `b`.
|
||||
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
|
||||
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
|
||||
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
|
||||
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
|
||||
@@ -30,5 +30,5 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
};
|
||||
let piece = b.width().hypot(b.height()) / 24.0;
|
||||
map_nonlinear(path, piece, f)
|
||||
map_nonlinear_within(path, piece, max, f)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G2: Effect › Sketch › Note Paper; Halftone Pattern and Note Paper defaults documented as unverified VectorCraft choices; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 46/57, 11 remaining (8–14 h); G2: Sketch › Chrome; Sketch › Chalk & Charcoal, Graphic Pen and Reticulation and Artistic › Plastic Wrap, #2972, #2967, #2976, #3034: 37/57 Photoshop effects; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 46/57, 11 remaining (8–14 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw; Roughen, Zig Zag, Free Distort, Twist and the warps build within it, #3578; scores unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G2: Effect › Sketch › Note Paper; Halftone Pattern and Note Paper defaults documented as unverified VectorCraft choices; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 46/57, 11 remaining (8–14 h); G2: Sketch › Chrome; Sketch › Chalk & Charcoal, Graphic Pen and Reticulation and Artistic › Plastic Wrap, #2972, #2967, #2976, #3034: 37/57 Photoshop effects; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 46/57, 11 remaining (8–14 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Stacked geometry effects make at most 2 million anchors of a path (of a type, symbol or image art's paths together) and about 8 million lines to draw: Roughen spaces its points further apart, Zig Zag makes fewer ridges a segment, Free Distort, Twist and the warps split into longer pieces, a result drawn with too many lines is built again with fewer points, and an effect that would still pass either is left out (#3578). Scores unchanged (estimated) |
|
||||
| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Sketch 14/14. |
|
||||
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
||||
| 2026-10-20 | minor | G2: Effect › Sketch › Water Paper (Fiber Length, Brightness, Contrast; colours flow on damp fibrous paper, defaults unverified, #2955); measured Photoshop filters 47/57, Sketch 12/14. |
|
||||
|
||||
+11
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user