From 54c8a7f2bd06aea32e4a469727d557c7323bf9f1 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Thu, 22 Oct 2026 12:42:57 -0700 Subject: [PATCH] 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 --- crates/doc/src/live.rs | 62 +++++++++++++- crates/effects/src/distort.rs | 46 +++++++---- crates/effects/src/lib.rs | 110 ++++++++++++++++++++++--- crates/effects/src/reshape.rs | 56 ++++++++++--- crates/effects/src/tests.rs | 147 ++++++++++++++++++++++++++++++++++ crates/effects/src/util.rs | 23 +++++- crates/effects/src/warp.rs | 6 +- docs/effects-parity.md | 3 +- docs/gaps.md | 12 ++- 9 files changed, 419 insertions(+), 46 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index fe1f545e3..fafa2fc98 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -467,11 +467,49 @@ fn cumulative_lengths(pts: &[Point]) -> Vec { 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::() / 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 = 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. diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 502cf962a..cdb717400 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -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 = 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 { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 0f2f5b9ba..cac3586e3 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { + 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(), }, } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ce844a076..474a3a820 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -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> = 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)), diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 34047cc05..80d67eef6 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -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 = (1..=3).map(|n| anchors_within(&vec![rough.clone(); n], &square(), 10_000)).collect(); + let counts: Vec = 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 = (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()); diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 336d65199..ec8cb4cba 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -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 { diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 35a67ba23..f3b7efea9 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -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) } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 212473c17..4ce8e3a34 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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. | diff --git a/docs/gaps.md b/docs/gaps.md index 72308b05d..4070dd330 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G13: gradient Method (Illustrator 30.0): a Method menu in the Gradient panel, Classic or Perceptual (`paint.editGradient`/`paint.setFill` `method`, saved in the native file only when Perceptual, so older files open as Classic); Perceptual mixes a linear or radial gradient's colours in OKLab, on the canvas and in raster exports, and SVG and PDF exports write it as an image at the Document Raster Effects resolution, as the public documentation describes; Classic draws as before; a dithered Perceptual fill takes dither's noise; G13 estimate 14–24 h → 13–23 h; G8: SVG images in SVG images share one depth cap, whatever their data URL type, and SVGZ images one unpacking cap, so a crafted SVG no longer overflows the stack or unpacks gigabytes (#3562, #3563); scores and estimates unchanged; G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged; G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours 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); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G8: stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw, Roughen, Zig Zag and the warps building within that and any other effect that would pass it left out, so a 25 KB file with three Roughens opens in 0.4 s and about 450 MB where it took 7 GB and aborted (#3578); scores and estimates unchanged; G13: gradient Method (Illustrator 30.0): a Method menu in the Gradient panel, Classic or Perceptual (`paint.editGradient`/`paint.setFill` `method`, saved in the native file only when Perceptual, so older files open as Classic); Perceptual mixes a linear or radial gradient's colours in OKLab, on the canvas and in raster exports, and SVG and PDF exports write it as an image at the Document Raster Effects resolution, as the public documentation describes; Classic draws as before; a dithered Perceptual fill takes dither's noise; G13 estimate 14–24 h → 13–23 h; G8: SVG images in SVG images share one depth cap, whatever their data URL type, and SVGZ images one unpacking cap, so a crafted SVG no longer overflows the stack or unpacks gigabytes (#3562, #3563); scores and estimates unchanged; G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged; G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours 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); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -175,6 +175,15 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall reads as SVG) are measured as `image/svg+xml` ones are; an image past the cap is left out with a warning instead of overflowing the stack (#3562). SVGZ images are unpacked before usvg reads them, within one 512 MB cap for all of a file's images, where usvg unpacked a data URL's SVGZ without a cap (#3563). +- **Done (2026-10-22):** stacked geometry effects make at most 2 million anchors of a path (of all the paths of a + type, symbol or image art together), or the path's own count when that is more. Roughen spaces its points further + apart, Zig Zag makes fewer ridges a segment and Free Distort, Twist and the warps split segments into longer + pieces where they would pass it, and an effect that would still pass it (Offset Path, Round Corners, a plug-in) is + left out. The result is also kept to about 8 million lines for the renderer to draw at 1 px/pt (Wang's formula at + vello's 0.25 px tolerance): a large Roughen or Zig Zag draws each point as up to hundreds of lines, so it is built + again with fewer points. Three Roughens of a rectangle in a 25 KB file needed tens of gigabytes of geometry, and two + million anchors of them 141 million lines (7 GB, past vello's 2²⁷ lines a path); the file now opens in 0.4 s and + about 450 MB (#3578). The budget is per path, not per document: many objects that each reach it still add up. - **Missing:** a corpus of real-world SVG/PDF/EPS files; more Affinity versions and platforms (51 pinned files now, including 28 original Affinity 3.2.3 feature probes; large interacting documents and complex typography missing); Windows, Linux and browser QA by hand; packaging polish (Homebrew cask #588, Flathub #554, update @@ -458,6 +467,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | G8: 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, the warps and Transform build within it, and an effect that would still pass it is left out (#3578). No entry closed, estimates unchanged. | | 2026-10-22 | minor | G13: gradient Method (30.0), Classic or Perceptual, in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports (with Dither, a Perceptual fill takes its noise); G13 estimate 14–24 h → 13–23 h. | | 2026-10-22 | minor | G8: SVG images in SVG images share the depth cap (any data URL type usvg reads as SVG) and SVGZ images one 512 MB unpacking cap; past either, an image is left out with a warning (#3562, #3563). No entry closed, estimates unchanged. | | 2026-10-22 | minor | G19: PDF export asks viewers to display the document title only when File Info has a Document Title or an explicit export title is supplied; untitled documents keep the PDF file name in the viewer title bar (#3646); scores and estimates unchanged. |