Effects: an image's Outline Object outline follows new pixels under the same key

The canvas kept the art of an image whose own fill or stroke follows the
outline of its pixels for as long as the image object stayed the same. When
the document's pixels for the image changed under the same key (Update Link
replacing a preview with the linked file's pixels), the stroke kept the old
outline while hit testing and Use Preview Bounds used the new one. The
renderer now keys that art by the document's pixels for the image too.

- vectorcraft-effects: `follows_image_pixels` names such images.
- vectorcraft-render: `reads_outside` counts them, and the resources key
  hashes the address of the document's pixels for them; their walk is not
  kept between frames.
- A test places an off-center opaque area in a smaller copy of the pixels
  under a turned and reflected placement.
- Docs: the Outline Object params text names every case that keeps the frame
  and the 50% cutoff as VectorCraft's own; docs/mcp.md's `effect.apply` entry
  for Outline Object, how long an outline is kept and the 16-bit RGBA limit;
  docs/target-app-parity.md's evidence lines.
This commit is contained in:
dotCooCoo
2026-11-12 06:04:34 -08:00
parent 1d41b403e1
commit 102ad44304
9 changed files with 99 additions and 25 deletions
+1 -1
View File
@@ -294,7 +294,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"path.outlineObject",
"Outline Object",
PATH,
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque, or the image's frame when it has no transparency",
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque (VectorCraft's own cutoff), and nothing when no pixel is; it gives the image's frame when the image has no transparency, its pixels can't be read, or the image or its outline is too large to trace",
json!({}),
),
g(
+2 -2
View File
@@ -80,8 +80,8 @@ 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, outset, raster_effects};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, 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_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,
+11
View File
@@ -184,6 +184,17 @@ fn follows_opaque_area(item: &AppearanceItem) -> bool {
item.effects().first().is_some_and(|e| e.id == "path.outlineObject")
}
/// Is `n` an image with a fill or stroke of its own that follows the outline of the image's opaque
/// area ([`image_rows`], [`opaque_area`])? The shape that fill or stroke paints then changes with
/// the image's pixels in the document while `n` stays the same.
pub fn follows_image_pixels(n: &Node) -> bool {
matches!(n.kind, NodeKind::Image(_))
&& n.appearance
.items
.iter()
.any(|i| shapes_image(i) && i.effects().iter().find(|e| gives_shape(e)).is_some_and(|e| e.id == "path.outlineObject"))
}
/// The outline of a set of pixels' opaque area, in the pixel space they decode to, with that size
/// ([`traced_opaque_area`]). `None` for pixels that have no transparency or could not be traced.
type Traced = Option<(Arc<PathData>, (u32, u32))>;
+20
View File
@@ -199,6 +199,26 @@ fn the_outline_follows_the_images_own_pixels_and_placement() {
assert!(near(b, [20.0, 30.0, 50.0, 60.0], 0.3), "{b:?}");
}
#[test]
fn an_off_center_outline_turns_and_reflects_with_a_smaller_copy_of_the_pixels() {
// A 120×60 image whose opaque area is the rectangle from (12, 6) to (40, 24), its pixels a
// copy at half size (as a linked image's preview is), turned a quarter and reflected.
let half = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(60, 30, |x, y| {
image::Rgba([255, 0, 0, if (6..20).contains(&x) && (3..12).contains(&y) { 255 } else { 0 }])
}));
let (d, mut n) = image_doc(encoded(half, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let xf = Affine::translate((100.0, 40.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(-1.0, 1.0);
if let NodeKind::Image(im) = &mut n.kind {
(im.width, im.height, im.xf) = (120, 60, xf);
}
let rows = row_bounds(&d, &n);
let want = xf.transform_rect_bbox(Rect::new(12.0, 6.0, 40.0, 24.0));
// The rectangle runs from (76, 0) to (94, 28); a flip in either direction, or the copy's
// width and height swapped, would move it.
assert!(near(want, [76.0, 0.0, 94.0, 28.0], 1e-9), "{want:?}");
assert!(rows.len() == 1 && near(rows[0], [want.x0, want.y0, want.x1, want.y1], 0.5), "{rows:?}");
}
#[test]
fn an_image_in_a_group_exports_with_its_outline() {
let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
+5 -3
View File
@@ -54,13 +54,15 @@ pub(super) fn evaluates_live(n: &Node) -> bool {
/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed),
/// a symbol instance (made from its symbol's art), an object with a 3D effect or a color
/// adjustment (shaded or recolored through the color settings), one with Vector Halftone (its
/// dots sampled from everything it draws), or one with a live-effect plug-in that paints its pieces
/// (drawn only while the swatches and patterns they name are in the document), in it or in the
/// opacity masks of the objects in it.
/// dots sampled from everything it draws), one with a live-effect plug-in that paints its pieces
/// (drawn only while the swatches and patterns they name are in the document), or an image with a
/// fill or stroke that follows the outline of its pixels ([`effects::follows_image_pixels`]: the
/// document's pixels for it can change), in it or in the opacity masks of the objects in it.
pub(super) fn reads_outside(n: &Node) -> bool {
let mut reads = false;
n.walk(&mut |c| {
reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
|| effects::follows_image_pixels(c)
|| effects::has_revolve(c)
|| effects::has_solid(c)
|| effects::has_adjustment(c)
+24 -9
View File
@@ -9,7 +9,8 @@
//! envelope around an instance of it). What the walk behind that key found is kept with the node
//! and with the document's symbols and patterns as the walk read them ([`Walk`]). A later frame
//! makes the key from what was kept while the node and the document's symbols and patterns stay
//! the same. A walk that found a placed file is not kept.
//! the same. A walk that found a placed file, or an image whose own fills or strokes follow its
//! pixels, is not kept.
//! Gradient meshes are tessellated into many small solid-colour quads (vello
//! has no mesh shading), with the density chosen from the on-screen patch size; each quad is
//! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams
@@ -44,7 +45,8 @@ type Walk = (Weak<Node>, Walked, Arc<DocInputs>);
/// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`Found`]), a
/// [`MeshEntry`] per mesh node and subdivision level, and a [`Walk`] per node whose key
/// [`Renderer::live_found`] found without finding a placed file.
/// [`Renderer::live_found`] found without finding a placed file or an image whose own fills or
/// strokes follow its pixels.
pub(crate) struct LiveCache {
expanded: FrameCache<usize, (Arc<Node>, Expanded, Found)>,
meshes: FrameCache<(usize, usize), MeshEntry>,
@@ -97,7 +99,8 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
/// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with
/// its inputs, see [`Renderer::live_found`]) reads from the document, placed files,
/// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of
/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font
/// the patterns, the reads of the placed files it shows and the document's pixels for the images
/// whose own fills or strokes follow them ([`hash_resources`]), the font
/// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the
/// plug-in revision ([`crate::effects::plugin_revision`]) and `names` (the hash of the document's
/// swatch and pattern names, [`DocInputs`]) when it shows an object with a plug-in effect, since a
@@ -105,13 +108,13 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
/// while the swatches and patterns they name are in the document, and the color settings (`cms`,
/// their address). Art is hashed by the addresses of its `Arc`s, which copies of the document
/// share: a copy of the document has the same key. It also returns weak references to the reads
/// found, and what the walk found ([`Walked`]) when it found no placed file.
/// found, and what the walk found ([`Walked`]) when it found no placed file and no such image.
fn resources_key(doc: &Document, cms: usize, names: u64, n: &Node) -> (u64, Vec<Weak<ReadDocument>>, Option<Walked>) {
let mut h = DefaultHasher::new();
let mut shown = Shown::default();
hash_resources(doc, n, &mut shown, &mut h);
let walked = Walked { hash: h.finish(), text: shown.text, plugins: shown.plugins };
(walked.key(cms, names), shown.reads, (!shown.placed).then_some(walked))
(walked.key(cms, names), shown.reads, (!shown.placed && !shown.pixels).then_some(walked))
}
/// What [`hash_resources`] found in a node: the hash of what it walked, whether it found type or
@@ -153,6 +156,10 @@ struct Shown {
text: bool,
/// Whether a placed file was found.
placed: bool,
/// Whether an image whose own fills or strokes follow its pixels was found
/// ([`crate::effects::follows_image_pixels`]): the document's pixels for it can change while
/// the walk's symbols and patterns stay the same.
pixels: bool,
/// Whether an object with a visible plug-in effect was found.
plugins: bool,
/// Weak references to the reads of the placed files found.
@@ -163,9 +170,11 @@ struct Shown {
/// tilings ([`crate::pattern::hash_tiling`]) of the patterns in the fills and strokes of its
/// objects and of type's characters, the address of each symbol instance's art, and the address of
/// the read of each placed file that [`vectorcraft_doc::PlacedDocument::art`] shows (none before
/// the file is read). It walks `n` and then, one at a time from a list, the opacity-mask, pattern
/// and symbol art it finds, each pattern and each symbol's art once ([`first_seen`]). `shown`
/// records the type, placed files, reads and plug-in effects found.
/// the file is read), and the address of the document's pixels for each image whose
/// own fills or strokes follow them ([`crate::effects::follows_image_pixels`]). It walks `n` and
/// then, one at a time from a list, the opacity-mask, pattern and symbol art it finds, each pattern
/// and each symbol's art once ([`first_seen`]). `shown` records the type, placed files, reads,
/// such images and plug-in effects found.
fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut DefaultHasher) {
let mut todo = vec![];
let mut next = Some(n);
@@ -203,6 +212,12 @@ fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut
shown.placed = true;
}
NodeKind::Text(_) => shown.text = true,
// The address of the pixels the document holds for the image, which Update Link
// replaces under the same key ([`DocInputs`] holds them weakly).
NodeKind::Image(im) if crate::effects::follows_image_pixels(c) => {
doc.images.get(&im.key).map(|b| Arc::as_ptr(&b.bytes) as usize).hash(h);
shown.pixels = true;
}
_ => {}
}
if let Some(m) = c.extras().mask.as_deref() {
@@ -442,7 +457,7 @@ impl Renderer {
/// evaluated live objects, type, symbol art or colors shaded through the color settings. The
/// key is made from the [`Walk`] kept for `n` while `doc`'s symbols and patterns are those it
/// read; `n` is walked again when they changed, and in every frame when the walk found a
/// placed file.
/// placed file or an image whose own fills or strokes follow its pixels.
pub(crate) fn live_found(&mut self, doc: &Document, n: &Arc<Node>) -> Found {
let inputs = self.doc_inputs(doc);
let at = Arc::as_ptr(n) as usize;
+21
View File
@@ -771,6 +771,27 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() {
assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255]));
}
#[test]
fn an_images_outline_object_stroke_follows_new_pixels_under_the_same_key() {
// The opaque image's stroke runs along its frame, from 18 to 22.
let mut d = stroked_image_doc(vec![fx("path.outlineObject", json!({}))], false);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!((lum(img.pixel(19, 35)), img.pixel(26, 35)), (0, [255, 0, 0, 255]));
// New pixels under the same key, as Update Link stores them, with the node unchanged: an
// opaque disc of radius 8 pixels in the middle. The stroke follows its edge at 26.5.
let disc = image::RgbaImage::from_fn(30, 30, |x, y| {
let r = (f64::from(x) + 0.5 - 15.0).hypot(f64::from(y) + 0.5 - 15.0);
image::Rgba([255, 0, 0, if r <= 8.0 { 255 } else { 0 }])
});
let mut png = vec![];
disc.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!((lum(img.pixel(19, 35)), lum(img.pixel(26, 35))), (WHITE, 0));
}
/// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document.
fn glowing_square(fill: Color, id: &str, params: Value) -> Document {
let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0));
+13 -8
View File
@@ -671,7 +671,9 @@ leaves room for a legend label's stored indents, spacing and leading.
`effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries
it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape
those outlines. On paths, compound paths and images it does not change what is drawn.
those outlines. On paths, compound paths and images it does not change what is drawn. On an image's own fill or stroke
it gives the outline of the image's opaque area, or the image's frame, as described under images' fills and strokes
below.
Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with
`isError: true` and a message. The model can read the message and retry.
@@ -3450,13 +3452,16 @@ Corners at their defaults, no area left out as noise): the edge follows anti-ali
and is fitted with curves. Outline Object gives the image's frame when the image has no transparency (a format
without alpha, or every pixel at least half opaque), when its pixels can't be read, when the outline would take more
than 100,000 anchor points, and when the image has more than 64 megapixels or its decoded pixels take more than 256 MB
(a 16-bit image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint. An
image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that covers a
pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in the outline. The
outline is traced the first time it is needed and kept while the image's pixels are loaded. These are VectorCraft's
own rules. The image's Contents row stands for the image; `appearance.addFill` and `appearance.addStroke` put a new
item right below that row, so it paints under the image. SVG, PDF, EPS and the other vector exports write those items
as paths, and `effect.expandAppearance` turns them into objects in a group with the image.
(a 16-bit RGBA image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint.
An image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that
covers a pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in
the outline. The outline is traced the first time it is needed and kept for as long as the image's pixels are loaded.
After the pixels are gone, it is dropped when another outline is traced or within the next 256 outline lookups. When
the image's pixels change (`links.update` reading the linked file), the canvas draws the outline of the new pixels.
These are VectorCraft's own rules. The image's Contents row stands for the image; `appearance.addFill` and
`appearance.addStroke` put a new item right below that row, so it paints under the image. SVG, PDF, EPS and the other
vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the
image.
Use Preview Bounds measures an image by its frame and the shapes its own fills and strokes paint.
```json
+2 -2
View File
@@ -1369,8 +1369,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`).
An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame.
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` (vectorcraft-engine).
An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box, or the outline of the image's opaque area or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds then measures an image by its frame.
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.
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).