diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index a1b8eed98..5a619f8ac 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -29,8 +29,8 @@ use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt}; use crate::group::has_own_paint; use crate::reshape::{geometry_effects, leaf_geometry}; use crate::{ - GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline, - outline_art, paints, reshape, + GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, has_transform_art, + needs_outline, outline_art, paints, reshape, }; /// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects: @@ -187,6 +187,9 @@ impl<'s, 'd> Drawn<'s, 'd> { let painted = || has_own_paint(n) && inner(); match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n), + // A Transform effect that copies with a gradient, scales strokes or moves patterns makes + // objects of their own, as the renderer draws them. + NodeKind::Path { .. } | NodeKind::Compound { .. } if has_transform_art(n) => reshape(Some(self.symbols.document()), n, None), NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n), NodeKind::SymbolInstance { symbol, .. } => { if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0d912b531..fd5968a01 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -47,16 +47,28 @@ pub fn has_gradient_copies(n: &Node) -> bool { } /// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or -/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as -/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of -/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]). +/// Scale Strokes & Effects is on (#885), its own or one of a fill or stroke that paints +/// ([`item_transform_options`])? Such an effect changes the fills and strokes as well as the +/// geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of its +/// own with its strokes scaled and its patterns moved ([`transform_leaf`], [`transform_items`]). pub fn has_transform_options(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) - && geometry_effects(n) + && (geometry_effects(n) .iter() // Both options default to off, so the stored params tell without merging the defaults // (this runs for every object drawn). .any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance()) + || n.appearance.items.iter().any(item_transform_options)) +} + +/// Does `item` paint and carry a Transform effect of its own that [`reaches_appearance`]? +fn item_transform_options(item: &AppearanceItem) -> bool { + crate::paints(item) && item.effects().iter().any(reaches_appearance) +} + +/// Is `e` a visible Transform effect whose Transform Patterns or Scale Strokes & Effects is on? +fn reaches_appearance(e: &Effect) -> bool { + e.visible && e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance() } /// Is `n` a path or compound path that its Transform effects make into art of separate objects @@ -304,9 +316,11 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, -/// or has no art (a symbol instance without `symbol_art`). +/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is +/// always reshaped. The fills and strokes in the art whose own Transform effects reach their +/// appearance become objects of their own ([`transform_items`]). pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) { + if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) { return None; } as_art(doc, n, symbol_art) @@ -364,6 +378,7 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node> _ => outline_art(doc, n, symbol_art)?, }; reshape_leaves(&mut art, &fx); + transform_items(&mut art); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { project_leaves(&mut art, &h); @@ -577,6 +592,94 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling made } +/// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose +/// own Transform effect has Transform Patterns or Scale Strokes & Effects on +/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]). +/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is +/// more). +fn transform_items(art: &mut Node) { + let mut used = art_cost(art); + let max = Budget::EFFECTS.at_least(used); + for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used)); +} + +/// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform +/// Patterns or Scale Strokes & Effects on, as a group with its id, name, transparency and object +/// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after +/// the geometry effects of that fill or stroke before its first such Transform effect, transformed +/// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the +/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the +/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer +/// draws a fill's or stroke's own effects. Its raster effects and the effects after the Transform +/// effect stay on it (and on each copy), and a later such Transform effect applies to the result in +/// the same way. The other fills and strokes stay together on paths of the leaf's geometry between +/// them, with their own effects. `max` is the budget of the whole art and `used` what it has spent. +fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { + if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) { + return; + } + let Some((path, rule)) = leaf_geometry(leaf) else { return }; + let original = leaf.clone(); + let ctx = GeomContext::of(&original); + let bounds = path.to_bezpath().bounding_box(); + // A path of the leaf's geometry painted by `items` alone, without the leaf's transparency and + // object effects (the group keeps them). + let piece = |items: Vec| { + let mut p = original.clone(); + p.appearance.items = items; + p.appearance.effects.clear(); + p.appearance.contents_index = None; + p.opacity = 1.0; + p.blend = Default::default(); + p.isolate = false; + p.knockout = Default::default(); + p.extras_mut().mask = None; + p + }; + let mut pieces: Vec> = vec![]; + let mut plain: Vec = vec![]; + for item in &original.appearance.items { + let fx = item.effects(); + let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else { + plain.push(item.clone()); + continue; + }; + if !plain.is_empty() { + pieces.push(Arc::new(piece(std::mem::take(&mut plain)))); + } + let before = &fx[..at]; + // What the art's other paths leave of the budget, with the leaf's own geometry (counted in + // `used`) given back: the geometry before the Transform effect and its copies keep to it. + let room = max.minus(*used).plus(Budget::of(&path)); + let g = if crate::has_geometry(before) && !path.is_empty() { + crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room) + } else { + path.clone() + }; + let Some(tb) = g.bounds() else { continue }; + let params = merged_params(&fx[at].id, &fx[at].params); + let (_, copies) = crate::distort::transform_map(tb, ¶ms, ctx.seed); + let options = crate::distort::TransformOptions::of(¶ms); + let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); + let mut it = item.clone(); + let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest; + let mut p = piece(vec![it]); + set_geometry(&mut p, g.clone(), rule); + let made = transform_leaf(&mut p, &g, map, copies, options, room); + *used = used.plus(made); + for_each_leaf(&mut p, &mut |l| transform_item_leaf(l, max, used)); + pieces.push(Arc::new(p)); + } + if !plain.is_empty() { + pieces.push(Arc::new(piece(plain))); + } + leaf.appearance.items.clear(); + leaf.appearance.contents_index = None; + leaf.knockout = vectorcraft_doc::Knockout::Off; + leaf.kind = NodeKind::Group { children: pieces, clip: false }; +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index a5fdd674c..fa24809a9 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -3,11 +3,11 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Dash, Effect, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Effect, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; use crate::distort::{TransformOptions, transform_map, transform_move}; -use crate::{has_transform_art, has_transform_options, reshape}; +use crate::{bake_document, expand_outlined, has_transform_art, has_transform_options, reshape}; fn transform(params: Value) -> Effect { Effect { id: "distort.transform".into(), params, visible: true } @@ -157,3 +157,160 @@ fn stroke_scaling_across_copies_stays_finite() { } } } + +/// A 100 pt square at the origin with a solid gray fill and a 2 pt black stroke (dashed 4/2) that +/// carries the Transform effect `params` itself. +fn stroke_with_effect(params: Value) -> Node { + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let mut n = + Node::path(NodeId(10), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gray, Paint::solid(Color::BLACK), 2.0)); + if let Some(s) = n.appearance.stroke_mut() { + s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }); + s.effects.push(transform(params)); + } + n +} + +/// A 100 pt square at the origin filled with pattern "p" placed at the identity, the fill carrying +/// the Transform effect `params` itself. +fn fill_with_effect(params: Value) -> Node { + let mut n = patterned(json!({})); + n.appearance.effects.clear(); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.effects.push(transform(params)); + } + n +} + +/// The paths under `n` that a stroke paints. +fn stroked_leaves(n: &Node) -> Vec<&Node> { + leaves(n).into_iter().filter(|p| p.appearance.stroke().is_some()).collect() +} + +/// The paths under `n` that a pattern fills. +fn pattern_leaves(n: &Node) -> Vec<&Node> { + leaves(n).into_iter().filter(|p| matches!(p.appearance.fill_paint(), Paint::Pattern { .. })).collect() +} + +#[test] +fn a_strokes_own_transform_scales_its_weight_and_dashes_with_scale_strokes_and_effects() { + let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + assert!(has_transform_options(&n)); + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 1); + let (w, dash) = stroke_of(paths[0]); + assert!(close(w, 4.0), "{w}"); + assert_eq!(dash, vec![8.0, 4.0]); + assert!(close(paths[0].geometric_bounds().unwrap().width(), 200.0)); + // The fill stays on the path as it was, below the stroke. + let all = leaves(&art); + assert_eq!(all.len(), 2); + assert!(all[0].appearance.stroke().is_none()); + assert_eq!(all[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + // Off, the stroke keeps its weight and dashes: the effect stays geometry alone. + let plain = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "strokes": false})); + assert!(!has_transform_art(&plain)); +} + +#[test] +fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { + let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); + let art = reshape(None, &n, None).unwrap(); + let widths: Vec = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect(); + assert_eq!(widths, vec![2.0, 4.0, 8.0]); +} + +#[test] +fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { + let mut n = stroke_with_effect(json!({})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); + f.effects.push(Effect { id: "path.offsetPath".into(), params: json!({"offset": 5.0}), visible: true }); + } + let art = reshape(None, &n, None).unwrap(); + let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); + let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; + let ids: Vec<&str> = f.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, vec!["path.offsetPath"]); + assert!(close(f.effects[0].params["offset"].as_f64().unwrap(), 15.0)); + assert!(close(fill.geometric_bounds().unwrap().width(), 300.0)); +} + +#[test] +fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() { + let n = fill_with_effect(json!({"moveH": 30, "copies": 2, "patterns": true})); + assert!(has_transform_options(&n)); + let art = reshape(None, &n, None).unwrap(); + let xs: Vec = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); + assert_eq!(xs, vec![0.0, 30.0, 60.0]); + // Off, the pattern stays put. + assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2})))); +} + +#[test] +fn a_strokes_own_transform_with_transform_objects_off_keeps_its_geometry() { + let n = stroke_with_effect(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true})); + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 1, "no copies"); + assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(close(stroke_of(paths[0]).0, 1.0)); + let n = fill_with_effect(json!({"rotate": 45, "objects": false, "patterns": true})); + let art = reshape(None, &n, None).unwrap(); + let paths = pattern_leaves(&art); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_ne!(pattern_xf(paths[0]), Affine::IDENTITY); +} + +#[test] +fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() { + let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); + let drawn = reshape(None, &n, None).unwrap(); + let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::>(); + assert_eq!(widths(&drawn), vec![2.0, 4.0]); + // Expand Appearance. + assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn)); + // Export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n.clone()).unwrap(); + let baked = bake_document(&d).unwrap(); + assert_eq!(widths(baked.node(NodeId(10)).unwrap()), widths(&drawn)); + // Use Preview Bounds: the copy's 4 pt stroke (on -50..150) reaches 2 pt past it. + let symbols = d.symbol_boxes(); + let b = crate::preview::visual_bounds(&symbols, &n).unwrap(); + assert!(close(b.width(), 204.0), "{b:?}"); + assert_eq!(Some(b), drawn.visual_bounds_with(&symbols)); +} + +/// Three strokes, each with three max-detail Roughens (#3578: nearly one effect budget on their +/// own) before its own Transform effect: their paths keep to one effect budget together, not one +/// each. +#[test] +fn strokes_with_their_own_transform_keep_to_one_effect_budget() { + use crate::geometry::Budget; + let rough = Effect { id: "distort.roughen".into(), params: json!({"size": 10000, "relative": false, "detail": 100}), visible: true }; + let mut n = Node::path( + NodeId(11), + shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), + Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0), + ); + let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else { + panic!("no stroke") + }; + n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_))); + for _ in 0..3 { + let mut s = s.clone(); + s.effects = vec![rough.clone(); 3]; + s.effects.push(transform(json!({"scaleH": 50, "scaleV": 50, "strokes": true}))); + n.appearance.items.push(AppearanceItem::Stroke(s)); + } + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 3); + let cost = + paths.iter().filter_map(|p| crate::reshape::leaf_geometry(p)).fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g))); + assert!(Budget::EFFECTS.fits(cost) && cost.lines > crate::MAX_EFFECT_LINES / 2, "{cost:?}"); +} diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 0c0bc052d..d4aa59eaf 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect (on the object, Transform has all three options and visible checkboxes, #885); Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | diff --git a/docs/gaps.md b/docs/gaps.md index 1743e5ae4..680c0984e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -294,8 +294,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (#885):** the Transform effect dialog's checkboxes are visible, and it has Transform Objects, Transform Patterns and Scale Strokes & Effects: off, Transform Objects leaves the geometry in place; the other two scale stroke weights, dashes and the fills' and strokes' effect distances and move pattern tiles with the object, copy - `k` by the transformation `k` times. Canvas, export and Expand Appearance agree. Not yet: a Transform effect in - a fill's or stroke's own effect list ignores the two options, and object effects after it keep their distances. + `k` by the transformation `k` times. Canvas, export and Expand Appearance agree. A Transform effect in a fill's or + stroke's own effect list applies the two options to that fill or stroke and its later effects in the same way. + Not yet: object effects after it keep their distances. - **Done (#937, reproduced case):** Shape Builder regions on art of more than 4,096 segments (traced or type outlines): the planar map no longer gives up there, so lines cut such art instead of the tool offering whole shapes (the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536; Divide with open paths shares it). The edge-source index files a long segment under the grid cells along it, so a diagonal hatch (every line's bounds hold most points) no longer costs a scan of the hatch per edge. The grid holds a budget of references (64 per item plus four rows' or columns' worth), the items past it scanned, so coincident segments can't make it quadratic in memory. @@ -305,8 +306,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall such stubs into one vertex (within 4× the sweep's tolerance up and down its frame and 1/16 of the 0.01 pt precision along it, bounded by each cluster's extent so a run of tiny level segments keeps its length); regression tests in `crates/pathops/src/planar.rs`. Estimate unchanged. -- **Missing:** SVG units reverting to points (#864); a Transform effect in a fill's or stroke's own effect list ignores - Transform Patterns and Scale Strokes & Effects (#885, above). Shape Builder regions (#937, closed), PDF text placement +- **Missing:** SVG units reverting to points (#864). Shape Builder regions (#937, closed), PDF text placement (#722, above), the bezier drag preview (#834, #3065, #3729) and deleting an interior anchor of an open path (above) are done. - **Done (#722):** PDF text opens where the file draws it. Each line used to come in as point type laid out by the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index da63cd9be..726b0b8fc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -134,7 +134,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; 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; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | -| Live vector effects | 5 | 85% | Outline Object, SVG Filters, a Transform effect in a fill's or stroke's own effects ignoring Transform Patterns and Scale Strokes & Effects | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | +| Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | @@ -476,7 +476,7 @@ since 2026-10-01. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. -- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). Not yet: a Transform in a fill's or stroke's own effects ignores the two options, and object effects after it keep their distances. Public reference: [Transform objects](https://helpx.adobe.com/illustrator/using/transforming-objects.html), [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-of-effects.html). Scores and hours unchanged. +- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Transform objects](https://helpx.adobe.com/illustrator/using/transforming-objects.html), [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-of-effects.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.