From 444046331586d1c9d7f82b4e1c2820887460a618 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 02:46:14 -0700 Subject: [PATCH] =?UTF-8?q?Effects:=20Effect=20=E2=80=BA=20Pathfinder=20us?= =?UTF-8?q?es=20the=20glyph=20outlines=20of=20type=20in=20the=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect › Pathfinder left type in the group out of all ten operations, on the canvas, in Expand Appearance and in exports. The text outliner returns type as a group with one path per run. `leaves` in crates/effects/src/group.rs took that group as one leaf, and `geometry` gives no path for a group. A 100 x 100 pt square grouped with a 100 pt "H" inside it kept all of its 10,000 pt² after Subtract and Expand Appearance. In the shape modes a member group with type in front also lost its paint, because it took the style of the outline group, which has none. `leaves` now takes the paths and compound paths of the outline art at every depth, as it does for a member group, and gives each one the type's opacity and blend mode. The same square keeps 8,684.88 pt² after Subtract and Expand Appearance, its 10,000 pt² less the glyph's 1,315.12 pt². Docs: docs/target-app-parity.md. --- crates/effects/src/group.rs | 85 +++++++++++++++++++++++++++--- crates/engine/src/cmd/effectcmd.rs | 23 ++++++++ docs/target-app-parity.md | 2 + 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 96e670a58..78d51dca8 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -46,7 +46,9 @@ pub fn has_pathfinder(n: &Node) -> bool { && n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id)) } -/// Converts objects that aren't paths (e.g. text) to an outline path node, if possible. +/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and +/// compound paths in that art, at every depth of its groups, take part in the Pathfinder operation +/// with the object's opacity and blend mode. pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option; fn leaves(n: &Node, hook: Option, out: &mut Vec) { @@ -61,8 +63,14 @@ fn leaves(n: &Node, hook: Option, out: &mut Vec) { } } _ => { - if let Some(p) = hook.and_then(|h| h(n)) { - out.push(p); + // Every path of the outline art, at any depth, takes the object's opacity and blend + // mode. + if let Some(art) = hook.and_then(|h| h(n)) { + let first = out.len(); + leaves(&art, None, out); + for l in out.iter_mut().skip(first) { + (l.opacity, l.blend) = (n.opacity, n.blend); + } } } } @@ -281,9 +289,9 @@ pub fn evaluate_container(n: &Node) -> Option { #[cfg(test)] mod tests { use super::*; - use vectorcraft_color::{Color, Paint}; - use vectorcraft_doc::{Effect, NodeId}; - use vectorcraft_geom::{Rect, shapes}; + use vectorcraft_color::{BlendMode, Color, Paint}; + use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; + use vectorcraft_geom::{Point, Rect, shapes}; fn sq(id: u64, x: f64, color: Color) -> Arc { Arc::new(Node::path( @@ -319,6 +327,71 @@ mod tests { assert_eq!(div.len(), 3); } + /// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either + /// side of x = 100. + fn type_h() -> Node { + let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() }; + Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style)))) + } + + /// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it. + fn square_and(front: Option, effect: &str) -> Node { + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red); + let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect()); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g + } + + /// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. + fn results(g: &Node) -> Vec> { + pathfinder_children(g, crate::text_outliner()).unwrap() + } + + #[test] + fn type_changes_the_result_of_every_pathfinder_effect() { + let glyphs = crate::outline_text(&type_h()).unwrap(); + let b = glyphs.geometric_bounds().unwrap(); + assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); + // `measure` gives the number of results and their total area. + let measure = |front: Option, effect: &str| { + let r = results(&square_and(front, effect)); + (r.len(), r.iter().map(|n| area(n)).sum::()) + }; + let unchanged: Vec<_> = PATHFINDER_EFFECTS + .iter() + .map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id))) + .filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0) + .collect(); + assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}"); + // The shape modes take the glyph's whole area: the part inside the square and the part outside. + let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum(); + let with_type = |id: &str| measure(Some(type_h()), id).1; + assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}"); + assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); + // Outline art nested one group deeper takes part as well. + let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); + let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); + assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); + } + + #[test] + fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { + let mut t = type_h(); + (t.opacity, t.blend) = (0.5, BlendMode::Multiply); + let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); + let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal); + let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply); + let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect(); + assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}"); + // In the shape modes a group with the type in front is one shape, styled like the type. + let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); + let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green); + let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]); + let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect(); + assert_eq!(added, [glyph]); + } + #[test] fn a_random_transform_on_a_group_moves_its_members_together() { let mut g = group_with("distort.transform"); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 292a85b97..4d7108ad7 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -840,6 +840,29 @@ mod tests { assert_eq!(node(&s, a).appearance.effects.len(), 1); } + #[test] + fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() { + let (mut s, a) = session_with_rect(); + let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap()); + let glyphs = effects::outline_text(&node(&s, t)).unwrap(); + let b = glyphs.geometric_bounds().unwrap(); + assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); + let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); + let glyph_area = area(&glyphs); + s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); + // Live: the renderer leaves the glyph's place in the square empty. + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); + let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count(); + assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}"); + // Expanded: the group holds the square minus the glyph. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let left = area(&node(&s, g)); + assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}"); + } + #[test] fn pathfinder_effect_on_a_group_renders_live_and_expands() { let (mut s, a) = session_with_rect(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 831d9b869..07d2424ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -504,6 +504,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged. - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). - **Simplify feedback and component selection (#1272):** the existing tolerance dialog previews from the original interaction snapshot and shows original/new anchor counts. Preview off and Cancel restore the document and selection; OK is one Undo. A refit or Add Anchor Points clears component indexes only on paths whose geometry changes, keeping their objects selected whole and unchanged paths' components intact. Engine and real-egui frame regressions cover document/selection restoration and grouped paths.