Effects: raster effects on a symbol instance's fill or stroke are drawn

A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other
effects that paint pixels) on a symbol instance's topmost fill now draws
on each filled shape of the symbol's art, and one on its topmost stroke
draws on each stroked shape, after any geometry effects listed before
it. The same rules as for geometry effects apply: only a visible fill or
stroke with an opacity above 0 counts, a group's own fills and strokes
in the art take the effect, and an instance inside the art passes it on.

The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export
draw it, and Object > Expand Appearance is enabled for such an instance:
a shadow or glow becomes an image under the instance, and a blur,
feather or inner glow replaces it with the image, in one undo step.

Existing documents with a raster effect on a symbol instance's fill or
stroke draw differently: before, the effect drew nothing.
This commit is contained in:
dotCooCoo
2026-11-13 21:53:15 -08:00
parent 1e4e3534f5
commit d3e520b14b
9 changed files with 182 additions and 34 deletions
+7 -4
View File
@@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -58,6 +58,7 @@ pub fn needs_bake(n: &Node) -> bool {
|| has_geometry(&n.appearance.effects)
|| n.projection().is_some()
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| has_instance_row_raster(n)
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
}
@@ -142,9 +143,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
}
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
// paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays
// type, moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
// paint a shape ([`has_item_geometry`]), and symbol instances whose active fill or stroke has a
// raster effect, which the fills and strokes of their art then carry
// ([`has_instance_row_raster`]). Type that Transform effects only move and copy stays type,
// moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n)) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
+3 -2
View File
@@ -80,8 +80,9 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects,
has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry,
has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text,
reshape,
};
pub use revolve::{
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
+29 -16
View File
@@ -162,15 +162,26 @@ fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> {
n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0)
}
/// The visible geometry effects in `fx`, in order.
fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect()
/// Is `n` a symbol instance whose active fill or stroke has a visible raster effect (a shadow, glow,
/// blur, feather or other painted effect, [`crate::is_raster`])? Its art takes that effect on each
/// fill or stroke in it that paints ([`add_instance_row_effects`]), and draws it there.
pub fn has_instance_row_raster(n: &Node) -> bool {
let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && crate::is_raster(&e.id));
matches!(n.kind, NodeKind::SymbolInstance { .. })
&& (active_fill(n).is_some_and(|f| raster(&f.effects)) || active_stroke(n).is_some_and(|s| raster(&s.effects)))
}
/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active
/// fill added after the effects of every fill in it that paints, and those of its active stroke
/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each
/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
/// The visible geometry and raster effects in `fx`, in order: those a symbol instance's active fill
/// or stroke passes on to its art.
fn row_effects_of(fx: &[Effect]) -> Vec<Effect> {
fx.iter().filter(|e| e.visible && (is_geometry(&e.id) || crate::is_raster(&e.id))).cloned().collect()
}
/// `art`, the art symbol instance `n` places, with the visible geometry and raster effects of
/// `n`'s active fill added after the effects of every fill in it that paints, and those of its
/// active stroke after the effects of every stroke in it that paints ([`active_fill`],
/// [`active_stroke`]): a Drop Shadow on the instance's fill shadows each filled shape in the art.
/// Each fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
/// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw.
/// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol
/// instance in the art takes the effects on its own active fill or stroke, or on a new fill or
@@ -180,8 +191,8 @@ fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
/// instance in the art takes them.
fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return };
let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects));
let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects));
let fill = active_fill(n).map_or(vec![], |f| row_effects_of(&f.effects));
let stroke = active_stroke(n).map_or(vec![], |s| row_effects_of(&s.effects));
if !fill.is_empty() || !stroke.is_empty() {
add_row_effects(doc, symbol, art, &fill, &stroke);
}
@@ -526,8 +537,8 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as
/// the art its members draw when the instance has geometry effects of its own
/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the
/// instance's active fill and stroke ([`add_instance_row_effects`]).
/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry and raster
/// effects of the instance's active fill and stroke ([`add_instance_row_effects`]).
/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
@@ -709,12 +720,14 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping
/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object
/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is
/// always reshaped. The fills and strokes in the art whose own Transform effects reach their
/// appearance become objects of their own ([`transform_items`]).
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts
/// or a symbol instance whose active fill or stroke has a raster effect
/// ([`has_instance_row_raster`]), or has no art (a symbol instance without `symbol_art`). A path
/// with [`has_transform_art`] is always reshaped. The fills and strokes in the art whose own
/// Transform effects reach their appearance become objects of their own ([`transform_items`]).
pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) {
let own = !geometry_effects(n).is_empty() || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n);
if !has_transform_art(n) && (!own || !needs_outline(n)) {
return None;
}
as_art(doc, n, symbol_art)
+5 -3
View File
@@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
/// fills, strokes or effects of its own or such members, other objects with effects, type with
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry
/// effect ([`effects::has_item_geometry`])?
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or
/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])?
pub(crate) fn expandable(n: &Node) -> bool {
expandable_except(n, &[])
}
@@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty()
}
_ => !n.appearance.effects.is_empty(),
}
}
+116
View File
@@ -422,6 +422,122 @@ fn a_compound_clipping_path_in_the_art_keeps_its_shape() {
assert_eq!(seen, (true, true));
}
/// A Drop Shadow 20 pt to the right on row `row` of instance `id`, solid black with no blur.
fn shadow_row(s: &mut Session, id: NodeId, row: usize) {
let params = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal"});
run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "stylize.dropShadow", "params": params}));
}
/// Is `p` dark: a shadow, not the white page or the red fill?
fn dark(p: [u8; 4]) -> bool {
p[..3].iter().all(|c| *c < 100)
}
/// A raster effect on a symbol instance's fill draws on the filled shapes of its art: the shadow
/// falls right of the square, the bounds take it in, a hidden fill draws none, and Expand
/// Appearance keeps the look in one undo step.
#[test]
fn a_symbol_instance_fill_shadow_shadows_its_filled_art() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
assert!(near(render(&s).pixel(160, 100), WHITE));
shadow_row(&mut s, id, row);
let before = render(&s);
assert!(dark(before.pixel(165, 100)), "the fill's shadow falls right of the square: {:?}", before.pixel(165, 100));
assert!(near(before.pixel(100, 100), RED), "the art draws over its shadow: {:?}", before.pixel(100, 100));
let doc = s.doc().unwrap().doc.clone();
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
assert!(b.x1 >= 169.0, "the bounds take in the shadow: {b:?}");
// A view that shows only the shadow, right of the square: the instance is still drawn.
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts);
assert!(dark(right.pixel(10, 100)), "the shadow past the square's box is drawn: {:?}", right.pixel(10, 100));
assert!(expandable(&s), "the fill's shadow can be expanded");
run(&mut s, "effect.expandAppearance", json!({}));
let ch = node(&s, id).children().cloned().expect("the instance becomes a group");
assert!(matches!(ch[0].kind, NodeKind::Image(_)), "the shadow is an image under the art: {}", ch[0].kind_label());
assert!(matches!(ch[1].kind, NodeKind::SymbolInstance { .. }), "the art stays an instance: {}", ch[1].kind_label());
no_effects_left(&ch[1]);
let after = render(&s);
assert!(differing(&before, &after, 24) < 400, "expanding changed {} pixels", differing(&before, &after, 24));
run(&mut s, "edit.undo", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back");
assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "stylize.dropShadow"));
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "visible": false}));
assert!(near(render(&s).pixel(165, 100), WHITE), "a hidden fill draws no shadow");
}
/// A raster effect on a symbol instance's stroke draws on the stroked shapes of its art only: the
/// stroke's shadow falls right of the stroke, and none falls where only the fill would cast one.
#[test]
fn a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", false);
shadow_row(&mut s, id, row);
let r = render(&s);
assert!(dark(r.pixel(170, 100)), "the stroke's shadow falls right of the stroke: {:?}", r.pixel(170, 100));
assert!(near(r.pixel(160, 100), WHITE), "the fill casts no shadow: {:?}", r.pixel(160, 100));
}
/// Geometry and raster effects on one fill of an instance: the shadow falls from the grown fill.
#[test]
fn a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, row, 10.0);
shadow_row(&mut s, id, row);
let r = render(&s);
assert!(near(r.pixel(44, 100), RED), "the fill grows by the offset: {:?}", r.pixel(44, 100));
assert!(dark(r.pixel(175, 100)), "the shadow falls from the grown fill: {:?}", r.pixel(175, 100));
}
/// An instance inside a symbol's art passes the outer instance's fill shadow on to its own art.
#[test]
fn a_nested_symbol_instance_takes_the_outer_fill_shadow() {
let mut s = session();
square(&mut s);
run(&mut s, "symbol.new", json!({"name": "Inner"}));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let row = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
shadow_row(&mut s, id, row);
assert!(dark(render(&s).pixel(165, 100)), "the inner art casts the shadow: {:?}", render(&s).pixel(165, 100));
}
/// Export draws a symbol instance's fill shadow on the filled shapes of its art: baking leaves the
/// shadow on the art's fill, not its stroke, and PDF export makes an image of it.
#[test]
fn export_draws_a_symbol_instance_fill_shadow() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
shadow_row(&mut s, id, row);
let doc = s.doc().unwrap().doc.clone();
let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked");
let n = baked.node(id).expect("the baked art keeps the instance's id").clone();
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
let shadowed = |i: &vectorcraft_doc::AppearanceItem| i.effects().iter().any(|e| e.id == "stylize.dropShadow");
let (mut fills, mut strokes) = (0, 0);
n.walk(&mut |part| {
fills += part.appearance.items.iter().filter(|i| i.is_fill() && shadowed(i)).count();
strokes += part.appearance.items.iter().filter(|i| !i.is_fill() && shadowed(i)).count();
});
assert_eq!((fills, strokes), (1, 0), "the art's fill carries the shadow");
let flat = crate::flatten_raster_effects(&doc).expect("the shadow becomes an image");
let mut image = None;
flat.node(id).expect("the flattened art keeps the instance's id").walk(&mut |part| {
if matches!(part.kind, NodeKind::Image(_)) {
image = part.geometric_bounds();
}
});
let image = image.expect("the shadow is an image");
assert!(image.x1 >= 165.0, "the image holds the shadow right of the square: {image:?}");
}
#[test]
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let mut s = session();
+12 -5
View File
@@ -356,7 +356,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
};
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r),
Some(r) if has_instance_row_effects(n) => instance_piece_bounds(symbols, r),
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
}
@@ -379,9 +379,16 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
}
/// Is `n` a symbol instance whose active fill or stroke has geometry or raster effects that the
/// fills and strokes of its art take ([`effects::has_item_geometry`],
/// [`effects::has_instance_row_raster`])?
fn has_instance_row_effects(n: &Node) -> bool {
matches!(n.kind, NodeKind::SymbolInstance { .. }) && (effects::has_item_geometry(n) || effects::has_instance_row_raster(n))
}
/// The bounds of `art`, the art [`effects::reshape`] made of a symbol instance whose active fill or
/// stroke has geometry effects ([`effects::has_item_geometry`]): its paths measured with the
/// geometry effects of their fills and strokes ([`visual_bounds`]), the symbol instances in it that
/// stroke has geometry or raster effects ([`has_instance_row_effects`]): its paths measured with the
/// effects of their fills and strokes, shadows and glows included ([`visual_bounds`]), the symbol instances in it that
/// took those effects by their own reshaped art in turn, its groups and layers with their own fills
/// and strokes by the art those paint ([`effects::evaluate_container`]), its clip groups by their
/// clipping paths and other objects as [`Node::visual_bounds_with`] measures them. Hidden objects are left out.
@@ -409,7 +416,7 @@ fn pieces_within(symbols: &SymbolBoxes, art: &Node, depth: usize, budget: &mut u
g.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces_within(symbols, c, depth, budget)))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } if has_fx(art) => visual_bounds(symbols, art),
NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && effects::has_item_geometry(art) => {
NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && has_instance_row_effects(art) => {
let doc = symbols.document();
let reshaped = doc.symbols.iter().find(|s| s.name == *symbol).and_then(|s| {
let count = s.art.count() as u64;
@@ -639,7 +646,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
};
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r),
Some(r) if has_instance_row_effects(n) => instance_piece_bounds(symbols, r),
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
+5 -3
View File
@@ -20,12 +20,14 @@ pub(crate) fn has_fx(n: &Node) -> bool {
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own
/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies
/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
/// that paints a shape ([`effects::has_item_geometry`]), a symbol instance whose active fill or
/// stroke has a raster effect ([`effects::has_instance_row_raster`]), is it a path whose Transform
/// effect copies it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]),
/// or is it a group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|| effects::has_item_geometry(n)
|| effects::has_instance_row_raster(n)
|| effects::has_transform_art(n)
|| effects::has_container_appearance(n)
|| effects::has_crop_marks(n)
+4 -1
View File
@@ -4757,7 +4757,10 @@ type, with the image under it. A group's or layer's own fills and strokes become
members are expanded too. A symbol instance whose topmost fill or topmost stroke is visible, has an opacity above 0
and has a geometry effect becomes a group of its art: that fill's effects reshape the filled shapes of the art and that
stroke's effects reshape its stroked shapes, which keep their colors, as the canvas draws them (VectorCraft's own
rule); the instance's other fills and strokes are dropped. The result is `{ids}`; when an image in the art can't be decoded, the images made from
rule); the instance's other fills and strokes are dropped. A raster effect on that fill or stroke draws on the filled
or stroked shapes of the art, and expanding makes it an image as for other objects; with no geometry effect, the
instance stays an instance over a shadow or glow image, and a blur, feather or inner glow replaces it with the image.
The result is `{ids}`; when an image in the art can't be decoded, the images made from
raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives.
A path's strokes stay strokes, as the
[Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help
+1
View File
@@ -1552,6 +1552,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render).
A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own.
A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing.
A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing.
The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values.
Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case).
Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents.