Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility

Geometry effects on a group or layer, and effects on a symbol instance,
blend or repeat, outline the type inside it. outline_nested_text
replaced each type object with a plain group of its outlines, so the
type lost its opacity, blend mode, opacity mask, knockout shape
option, own effects and perspective, and hidden type was drawn,
exported and made visible by Expand Appearance.

outline_nested_text now replaces type with the art that as_art makes
for it, which is also what reshape makes for type with geometry
effects of its own. That art has the type's own geometry effects and
perspective applied and keeps its name, transparency, opacity mask
and other effects. as_art also keeps the object's visibility and
knockout shape option, so hidden type with a geometry effect of its
own is left out of exports. Type in a clip group's clipping path still
becomes its plain outlines and now keeps its visibility.
This commit is contained in:
dotCooCoo
2026-10-16 02:21:47 +07:00
committed by bflatastic
parent 8e58b9a23f
commit 5473e3a5c5
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.