Merge pull request #2073 from dotCooCoo/feat/outline-object-nested-type

Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility
This commit is contained in:
BFlat
2026-10-16 02:37:05 +07:00
committed by GitHub
7 changed files with 203 additions and 11 deletions
+34
View File
@@ -330,6 +330,40 @@ mod tests {
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
}
/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`,
/// in a group with a square and Offset Path 0, which leaves every path as it is.
fn type_in_group(style: impl FnOnce(&mut Node)) -> Node {
let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts);
let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text)));
style(&mut t);
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
let art = evaluate_container(&g).unwrap();
(*art.children().unwrap()[1]).clone()
}
#[test]
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
use vectorcraft_color::BlendMode;
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply));
// Its opacity mask, its own shadow, its knockout shape option and its visibility stay.
let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
let mask = vectorcraft_doc::OpacityMask::new(mask_art, true);
assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask));
let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true };
assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]);
assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape);
assert!(!type_in_group(|t| t.visible = false).visible);
// Its own Transform moves its outlines 30 pt before the group's effect reshapes them.
let x0 = |n: Node| n.geometric_bounds().unwrap().x0;
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true };
let moved = x0(type_in_group(|t| t.appearance.effects.push(transform)));
assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}");
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
+24 -11
View File
@@ -86,7 +86,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
outline_nested_text(&mut art);
outline_nested_text(&mut art, false);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -97,22 +97,33 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(art)
}
/// Replace type anywhere inside `n` by its outlines.
fn outline_nested_text(n: &mut Node) {
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by.
fn outline_nested_text(n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if let Some(o) = outline_text(c) {
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) => as_art(c, None),
_ => None,
};
if let Some(o) = art {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c));
outline_nested_text(Arc::make_mut(c), clipping);
}
}
}
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
/// perspective, or has no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
return None;
@@ -154,11 +165,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
out.visible = n.visible;
out.opacity = n.opacity;
out.blend = n.blend;
out.isolate = n.isolate;
// One element: its pieces never knock each other out.
out.knockout = vectorcraft_doc::Knockout::Off;
out.knockout_shape = n.knockout_shape;
out.extras_mut().mask = n.extras().mask.clone();
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(out)
@@ -281,7 +294,7 @@ fn image_count(n: &Node) -> usize {
pub(crate) fn outlined_members(n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
outline_nested_text(&mut art);
outline_nested_text(&mut art, false);
art
}
+38
View File
@@ -149,3 +149,41 @@ fn group_appearance_round_trips_and_expands() {
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
}
#[test]
fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() {
let mut s = session();
let square = id_of(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})));
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "transparency.set", json!({"ids": [t.0], "opacity": 50}));
run(&mut s, "select.set", json!({"ids": [square.0, t.0]}));
let g = id_of(&run(&mut s, "object.group", json!({})));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 0}}));
// SVG export writes the type's outlines at its opacity, and none of them while it is hidden.
let svg = |s: &Session| vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let shown = svg(&s);
assert!(!shown.contains("<text") && shown.contains("opacity=\"0.5\""), "{shown}");
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "object.hide", json!({}));
let hidden = svg(&s);
assert_eq!(hidden.matches("<path").count() + 1, shown.matches("<path").count(), "{hidden}");
// Expand Appearance keeps the type's opacity and visibility on its outlines.
run(&mut s, "select.set", json!({"ids": [g.0]}));
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, g);
let outlines = &n.children().unwrap()[1];
assert!(!matches!(outlines.kind, NodeKind::Text(_)) && (outlines.opacity - 0.5).abs() < 1e-6 && !outlines.visible, "{outlines:?}");
}
#[test]
fn hidden_type_with_a_geometry_effect_is_left_out_of_svg_export() {
let mut s = session();
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 0}, "ids": [t.0]}));
let svg = |s: &Session| vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let shown = svg(&s);
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "object.hide", json!({}));
let hidden = svg(&s);
assert_eq!((shown.matches("<path").count(), hidden.matches("<path").count()), (1, 0), "{hidden}");
}
+15
View File
@@ -74,6 +74,21 @@ fn type_in_perspective_exports_projected_outlines() {
assert!((bb.x0 - b.x0).abs() < 1e-6 && (bb.y1 - b.y1).abs() < 1e-6, "{bb:?} {b:?}");
}
#[test]
fn type_in_perspective_in_a_group_with_a_geometry_effect_exports_projected() {
let (mut s, rect) = persp_session();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
s.execute("select.set", &json!({"ids": [rect.0, id.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
// Offset Path 0 on the group leaves every path as it is.
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 0}})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
let bb = baked.node(id).unwrap().geometric_bounds().unwrap();
assert!((bb.x0 - b.x0).abs() < 1e-6 && (bb.y1 - b.y1).abs() < 1e-6, "{bb:?} {b:?}");
}
#[test]
fn moving_type_in_perspective_moves_its_projection() {
let (mut s, rect) = persp_session();
+68
View File
@@ -155,6 +155,74 @@ fn group_geometry_effects_move_and_copy_images_too() {
assert_eq!(img.pixel(5, 10), BLUE, "the path itself");
}
/// A 100×100 document holding a group of a red square (10..90) and black 40 pt type "IIII" over
/// it, the type changed by `ty` and the group changed by `group`.
fn type_group_doc(ty: impl FnOnce(&mut Node), group: impl FnOnce(&mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), solid(1.0, 0.0, 0.0));
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
ty(&mut t);
let mut g = Node::group(d.alloc_id(), vec![a, Arc::new(t)]);
group(&mut g);
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
}
#[test]
fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geometry_effects() {
// Offset Path 0 leaves every path as it is, so the group is drawn the same with it as without
// it, for type at 50 %, hidden type, and type at 50 % defining its knockout shape in a
// knockout group.
let half: fn(&mut Node) = |t| t.opacity = 0.5;
let hidden: fn(&mut Node) = |t| t.visible = false;
let shape: fn(&mut Node) = |t| (t.opacity, t.knockout_shape) = (0.5, true);
let plain: fn(&mut Node) = |_| {};
let knockout: fn(&mut Node) = |g| g.knockout = Knockout::On;
for (case, ty, group) in [("50 %", half, plain), ("hidden", hidden, plain), ("knockout shape", shape, knockout)] {
let without = render(&type_group_doc(ty, group));
let with = render(&type_group_doc(ty, |g| {
group(g);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
}));
let worst = without.pixels.iter().zip(&with.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "{case}: max channel difference {worst}");
}
}
#[test]
fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() {
// The red square is clipped by type with a Transform of its own, which the clip leaves out,
// then by a group of that type and a small square, with the type shown and hidden. Offset
// Path 0 on the clip group leaves every path as it is.
let doc = |effect: bool, grouped: bool, hidden: bool| {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
t.appearance.effects.push(fx("distort.transform", json!({"moveH": 30})));
t.visible = !hidden;
let mut clip = Arc::new(t);
if grouped {
let small = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Paint::None);
clip = Arc::new(Node::group(d.alloc_id(), vec![clip, small]));
}
let a = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), solid(1.0, 0.0, 0.0));
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, a], clip: true });
if effect {
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
}
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
};
for (grouped, hidden) in [(false, false), (true, false), (true, true)] {
let (without, with) = (render(&doc(false, grouped, hidden)), render(&doc(true, grouped, hidden)));
let worst = without.pixels.iter().zip(&with.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "grouped {grouped}, hidden {hidden}: max channel difference {worst}");
}
}
#[test]
fn a_layer_paints_its_own_fill_and_a_knockout_group_keeps_knocking_out() {
let mut d = group_doc(|_| {});
+23
View File
@@ -343,6 +343,29 @@ fn blur_on_a_symbol_instance_softens_its_edges() {
assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark");
}
#[test]
fn type_in_a_symbols_art_keeps_its_opacity_under_the_instances_effects() {
// The symbol's art is 50 % black type. Offset Path 0 and a shadow at 0 % opacity on the
// instance paint nothing of their own, so the instance is drawn the same with them as without
// them.
let doc = |effects| {
let mut d = symbol_doc(effects);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(-45.0, 10.0), "IIII", style))));
t.opacity = 0.5;
d.symbols[0].art = Arc::new(t);
d
};
let plain = render(&doc(vec![]));
let offset = fx("path.offsetPath", json!({"offset": 0}));
let shadow = fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 0, "mode": "normal"}));
for effect in [offset, shadow] {
let img = render(&doc(vec![effect.clone()]));
let worst = plain.pixels.iter().zip(&img.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "{}: max channel difference {worst}", effect.id);
}
}
#[test]
fn geometry_effects_reshape_type_through_its_outlines() {
let plain = inked_columns(&render(&text_doc(vec![])));
+1
View File
@@ -511,6 +511,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.
Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports.
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.