diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index ee8aba6c2..cdde7d135 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -528,8 +528,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// painted by that fill and every visible stroke without a brush its filled outline in the /// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke: /// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as -/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` -/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. +/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path +/// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`] +/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor +/// a geometry effect, so bending first changes nothing. pub fn bake_appearance(n: &Node) -> Option { if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { return Some(m); @@ -537,6 +539,15 @@ pub fn bake_appearance(n: &Node) -> Option { if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { return None; } + // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its + // patterns: the art Expand Appearance makes of it (each copy an object with its own strokes, + // patterns and gradients), each piece baked in turn. + if crate::has_transform_art(n) + && let Some(mut m) = reshape(None, n, None) + { + bake_appearance_pieces(&mut m); + return Some(m); + } let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))); if !geometric && !n.appearance.items.iter().any(outlined) { @@ -589,6 +600,19 @@ pub fn bake_appearance(n: &Node) -> Option { Some(m) } +/// [`bake_appearance`] every path and compound path in `n` that it changes, in place. +fn bake_appearance_pieces(n: &mut Node) { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + if let Some(m) = bake_appearance(n) { + *n = m; + } + return; + } + for c in n.children_mut().into_iter().flatten() { + bake_appearance_pieces(Arc::make_mut(c)); + } +} + /// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index b150f2004..e16742270 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -106,6 +106,63 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline assert_eq!(ch[1].opacity, 0.5); } +/// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform +/// effect `params`. +fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, stroke, width)); + n.appearance.effects.push(fx("distort.transform", params)); + n +} + +/// The paths under `n`, in document order. +fn baked_paths(n: &Node) -> Vec<&Node> { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().flat_map(|c| baked_paths(c)).collect(), + _ => vec![n], + } +} + +fn widths(n: &Node) -> Vec { + baked_paths(n).iter().map(|p| p.geometric_bounds().unwrap().width()).collect() +} + +#[test] +fn a_baked_transform_copy_keeps_its_scaled_stroke() { + // Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline + // is 204 pt wide. + let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); + let m = bake_appearance(&n).unwrap(); + let w = widths(&m); + assert_eq!(w.len(), 2, "{w:?}"); + assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); + assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined"); +} + +#[test] +fn a_baked_transform_copy_keeps_its_moved_pattern() { + let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true})); + let m = bake_appearance(&n).unwrap(); + let xs: Vec = baked_paths(&m) + .iter() + .map(|p| match p.appearance.fill_paint() { + Paint::Pattern { xf, .. } => xf.as_coeffs()[4], + p => panic!("not a pattern: {p:?}"), + }) + .collect(); + assert_eq!(xs, vec![0.0, 30.0]); +} + +#[test] +fn a_baked_transform_without_its_options_outlines_the_copies_together() { + // Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares. + let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1})); + let m = bake_appearance(&n).unwrap(); + let w = widths(&m); + assert_eq!(w.len(), 1, "{w:?}"); + assert!((w[0] - 202.0).abs() < 0.5, "{w:?}"); +} + /// Black 24 pt "Type" with its baseline at (20, 60). fn type_node(id: u64) -> Node { let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 66f87ed51..73f3c02f5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -478,6 +478,7 @@ since 2026-10-01. - **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). 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: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.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. +- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **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. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged.