mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(|_| {});
|
||||
|
||||
@@ -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![])));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user