From faea85f7729a3d3886ea3b2a8f81ad9584a49450 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 13:02:28 -0800 Subject: [PATCH 1/4] Fix #6125: raster exports keep a Screen glow on the bare page A raster export filled its background into the page before the art, so blend modes read it as art underneath: an Outer Glow in Screen mode, the default, turned white on a white background and was missing from PNG and JPEG files while the canvas showed it. Blending against nothing also differed from the canvas for Difference and the other modes. The background now goes under the finished art, in frames and in object thumbnails. A blend mode reads only the art below it. A white object under a Difference object still inverts. Page Isolated Blending no longer changes a raster export on a bare page. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/formats.rs | 2 +- crates/engine/src/tests/file.rs | 21 ++++++++++++++ crates/render/src/ink.rs | 2 +- crates/render/src/renderer.rs | 37 +++++++++++++++++-------- crates/render/src/tests/knockout.rs | 10 +++++-- crates/render/src/tests/objectfx.rs | 34 +++++++++++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 ++ 8 files changed, 93 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 573a9ee16..23aeb83bb 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -125,7 +125,7 @@ const BACKGROUND: FormatOption = FormatOption { name: "background", ty: "string", default: "\"transparent\"", - description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)", + description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent); drawn under the finished art, so blend modes do not read it", }; const ANTI_ALIAS: FormatOption = FormatOption { diff --git a/crates/engine/src/tests/file.rs b/crates/engine/src/tests/file.rs index 1e3a4b923..5fb568a23 100644 --- a/crates/engine/src/tests/file.rs +++ b/crates/engine/src/tests/file.rs @@ -447,6 +447,27 @@ fn export_selection_keeps_the_effects_in_the_crop() { } } +/// An Outer Glow at its default Screen mode on the bare page is in a PNG on white and in a JPEG, +/// and in a transparent PNG (#6125). +#[test] +fn a_screen_outer_glow_is_in_raster_exports() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); + let mut pixel = |p: serde_json::Value| { + let v = s.execute("document.export", &p).unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8(); + // 2 pt past the square's stroke, on the right. + *img.get_pixel(122, 110) + }; + for p in [json!({"format": "png", "background": "white"}), json!({"format": "jpg"})] { + let px = pixel(p.clone()); + assert!(px[0] > 235 && px[2] < 235, "{p}: yellow on white: {px:?}"); + } + let px = pixel(json!({"format": "png", "background": "transparent"})); + assert!(px[3] > 0 && px[2] < 40, "the glow is in the transparent image: {px:?}"); +} + /// A group's own drop shadow is inside the crop of the group's selection export too. #[test] fn export_selection_keeps_a_groups_shadow_in_the_crop() { diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index c8df531a8..2d67db207 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -176,7 +176,7 @@ impl Ink { peniko::Color::from_rgba8(r, g, b, a) } - fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { + pub(crate) fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { match self.base() { Ink::Display | Ink::RichBlack => [r, g, b, a], _ => { diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e8df56215..2056ccf59 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -232,7 +232,9 @@ impl Renderer { self.live.sweep(g); } - /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. + /// Draw frame `f` (`w`×`h` pixels): the artboards and the page's art, then the background + /// under them. The art blends only with itself, as it does on the canvas, where the paper is + /// drawn apart from it: a Screen glow on the bare page stays visible on a white background (#6125). fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { let (threads, opts) = (self.threads, f.opts); let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); @@ -247,11 +249,6 @@ impl Renderer { ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; - if let Some(bg) = opts.background { - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(f.ink.fixed(bg)); - ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); - } if opts.artboards && !opts.outline { // Paper: white on screen, no ink on the ink planes. ctx.set_transform(f.view); @@ -278,7 +275,11 @@ impl Renderer { if self.contexts.len() > 2 { self.contexts.remove(0); } - pm.data_as_u8_slice().to_vec() + let mut pixels = pm.data_as_u8_slice().to_vec(); + if let Some(bg) = opts.background { + over_background(&mut pixels, f.ink.fixed8(bg)); + } + pixels } /// The page's art: the layers (or the pattern being edited), inside the page group when the @@ -392,10 +393,6 @@ impl Renderer { let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); let mut ctx = single_threaded_context(w, w); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - if let Some(bg) = background { - ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); - ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); - } let symbols = doc.symbol_boxes().quick(); let frame = Frame { mt: false, @@ -415,6 +412,22 @@ impl Renderer { self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); self.end_frame(); - Some(Rendered { width: w as u32, height: w as u32, pixels: pm.data_as_u8_slice().to_vec() }) + let mut pixels = pm.data_as_u8_slice().to_vec(); + if let Some(bg) = background { + over_background(&mut pixels, bg); + } + Some(Rendered { width: w as u32, height: w as u32, pixels }) + } +} + +/// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). +fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { + let alpha = u32::from(a); + let under = [u32::from(r) * alpha / 255, u32::from(g) * alpha / 255, u32::from(b) * alpha / 255, alpha]; + for p in pixels.as_chunks_mut::<4>().0 { + let rest = 255 - u32::from(p[3]); + for (v, bg) in p.iter_mut().zip(under) { + *v = (u32::from(*v) + (bg * rest + 127) / 255).min(255) as u8; + } } } diff --git a/crates/render/src/tests/knockout.rs b/crates/render/src/tests/knockout.rs index 2a7e51e81..1f740b6b3 100644 --- a/crates/render/src/tests/knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -116,15 +116,21 @@ fn page_knockout_and_isolated_blending() { Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off; assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED)); - // Page Isolated Blending: a Difference object no longer blends with the white under the page. + // A Difference object on the bare page keeps its color: the background goes under the + // finished art (#6125), with Page Isolated Blending or without it. let mut d = Document::new(100.0, 100.0); let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); red.opacity = 1.0; red.blend = Blend::Difference; d.insert(Some(d.layers[0].id), 0, red).unwrap(); - assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50)); + assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "the bare page: {:?}", render(&d).pixel(30, 50)); d.page_isolate = true; assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); + // A white object under it is art: the Difference object inverts it. + let id = d.alloc_id(); + let white = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + d.insert(Some(d.layers[0].id), 0, white).unwrap(); + assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white object: {:?}", render(&d).pixel(30, 50)); } #[test] diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index ef8972c3a..e7d8742d2 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -771,6 +771,40 @@ 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])); } +/// 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)); + n.appearance.effects.push(fx(id, params)); + doc_with(Document::new(100.0, 100.0), n) +} + +/// A glow on the bare page is drawn before the background goes under the art: the pixel is the +/// transparent render's over that color, also for a background other than white (#6125). +#[test] +fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(27, 50); + for (bg, name) in [([255, 255, 255, 255], "white"), ([0, 0, 255, 255], "blue")] { + let p = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(27, 50); + let a = u32::from(clear[3]); + let want: Vec = (0..3).map(|c| (u32::from(clear[c]) * a + u32::from(bg[c]) * (255 - a)) / 255).collect(); + assert!(a > 0 && (0..3).all(|c| u32::from(p[c]).abs_diff(want[c]) <= 2), "{name}: {p:?}, want {want:?}"); + assert_eq!(p[3], 255, "{name}: opaque"); + } +} + +/// An Inner Glow blends with the object's own fill whatever is under the page: white Screen +/// lightens a black square, red Screen leaves a white one white, and red Normal shows on it. +#[test] +fn an_inner_glow_blends_with_its_own_fill_on_a_background() { + let red = |mode: &str| json!({"color": "#ff0000", "mode": mode, "opacity": 100}); + let dark = render(&glowing_square(Color::BLACK, "stylize.innerGlow", json!({"color": "#ffffff", "opacity": 100}))).pixel(32, 50); + assert!(dark[0] > 20 && dark[0] == dark[1], "the white glow lightens the black edge: {dark:?}"); + assert_eq!(render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("screen"))).pixel(32, 50), [255, 255, 255, 255]); + let normal = render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("normal"))).pixel(32, 50); + assert!(normal[0] == 255 && normal[1] < 245, "red on the white edge: {normal:?}"); +} + #[test] fn a_strokes_own_glow_paints_around_that_stroke_only() { let mut d = Document::new(100.0, 100.0); diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0..2eb57aca8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -779,7 +779,7 @@ point (72 times that value in ppi); `options.ppi` wins over either scale option. default 72 ppi still use 72 ppi; use `--ppi 300` for print output from those documents. Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file, except in GIF, WebP and -Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused), `antiAlias` (`none`, `art`, `type`: text +Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused; drawn under the finished art, so blend modes do not read it), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and, for PNG, `interlaced`. `width` or `height` sets the size of each file in pixels: each artboard (with `useArtboards`, each file), `rect` or art bounds gets the scale that makes it that wide or high, within 0.01–64 pixels per point, and the file stores the resolution that results, as with `ppi`. `width` wins over `height`, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2effc1142..f7682a17a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -592,6 +592,8 @@ since 2026-10-01. - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. +- **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`. + - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`. The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`. From e1b73f38bc6e5c6c435238efb776d153375abeb3 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:07:52 +0530 Subject: [PATCH 2/4] G2: Effect Gallery thumbnails and an in-dialog preview Co-authored-by: Cursor --- ROADMAP.md | 8 +- crates/ui-egui/src/dialogs/effect_gallery.rs | 270 ++++++++++++++++++- docs/effects-parity.md | 9 +- docs/gaps.md | 9 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- 6 files changed, 286 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fbe233400..e83a01efe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -83,7 +83,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | Strokes, brushes, width profiles | 88% | 8–15 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 | | Live vector effects | 85% | 4–8 | -| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 88% | — | | 3D and Materials | ~25% | 30–55 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | @@ -131,8 +131,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap 2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. 3. **Launch and stability on real machines** (G10): 20–35 h. 4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. -5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h. -6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). +5. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2). ## Progress log @@ -226,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index 4fe560bb5..03c862171 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -1,15 +1,31 @@ //! Effect → Effect Gallery: browse Photoshop-style raster filters by category, build a stack, and //! apply it as separate appearance effects in one undo step. +//! +//! Each filter has a thumbnail, and the dialog has its own preview, as the +//! [Filter Gallery](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html) +//! describes. Both are an original drawing made for VectorCraft (a small landscape), passed through +//! the filter. The thumbnails use each filter's defaults. The preview uses the visible stack, or +//! the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are +//! unchanged. + +use std::collections::HashMap; use egui; use serde_json::{Map, Value, json}; -use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS}; +use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS, PixelSpace}; +use vectorcraft_geom::{Affine, Point}; use super::{DialogSpec, effect, form}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; +/// Thumbnail of one filter, in pixels. The drawing is original; the filter is this app's. +const THUMB: usize = 36; +/// In-dialog preview, in pixels, drawn at twice this size. +const PREVIEW_W: usize = 120; +const PREVIEW_H: usize = 84; + /// Dialog kind for the Effect Gallery. pub const KIND: &str = "effectGallery"; @@ -248,6 +264,196 @@ fn effect_label(id: &str) -> String { vectorcraft_effects::effect_info(id).map(|e| tl!(e.label).trim_end_matches('…').to_string()).unwrap_or_else(|| id.to_string()) } +/// Where the preview rasters sit: one document point per pixel, origin at the top-left. +fn preview_space(w: usize, h: usize) -> PixelSpace { + let (wf, hf) = (w as f64, h as f64); + PixelSpace { + to_doc: Affine::IDENTITY, + px: 1.0, + center: Point::new(wf / 2.0, hf / 2.0), + channels: vectorcraft_effects::pixel::Channels::Rgb, + line: 1.0, + extent: 0.5 * wf.hypot(hf), + } +} + +fn byte(v: f32) -> u8 { + if !v.is_finite() { + return 0; + } + v.round().clamp(0.0, 255.0) as u8 +} + +/// An original landscape for the gallery (sky, sun, hills, a house, a river). Not a copied picture. +fn sample_drawing(w: usize, h: usize) -> Vec { + let mut out = vec![0u8; w.saturating_mul(h).saturating_mul(4)]; + if w == 0 || h == 0 || out.len() != w * h * 4 { + return out; + } + for y in 0..h { + for x in 0..w { + let xf = x as f32 / w as f32; + let yf = y as f32 / h as f32; + let mut rgb = [90.0 + 90.0 * (1.0 - yf), 150.0 + 50.0 * (1.0 - yf), 200.0 + 40.0 * yf]; + let (dx, dy) = (xf - 0.78, yf - 0.22); + if dx * dx + dy * dy < 0.012 { + rgb = [252.0, 206.0, 64.0]; + } + let hill = 0.58 + 0.08 * (xf * std::f32::consts::TAU).sin(); + if yf > hill { + rgb = [46.0, 110.0 + 40.0 * (1.0 - (yf - hill).clamp(0.0, 1.0)), 52.0]; + } + if (0.16..0.40).contains(&xf) && (0.46..0.70).contains(&yf) { + rgb = [196.0, 64.0, 48.0]; + } + let roof = 0.46 - (xf - 0.28).abs() * 1.15; + if (0.12..0.44).contains(&xf) && yf > roof && yf < 0.48 { + rgb = [92.0, 48.0, 36.0]; + } + if (0.22..0.32).contains(&xf) && (0.52..0.62).contains(&yf) { + rgb = [255.0, 236.0, 150.0]; + } + if yf > 0.80 && (yf * 18.0 + xf * 9.0).sin() > -0.15 { + rgb = [36.0, 78.0, 196.0]; + } + let i = (y * w + x) * 4; + out[i] = byte(rgb[0]); + out[i + 1] = byte(rgb[1]); + out[i + 2] = byte(rgb[2]); + out[i + 3] = 255; + } + } + out +} + +/// Run one Photoshop-style filter on a premultiplied RGBA buffer. Leaves it unchanged when `id` +/// is not one of those filters or the buffer is the wrong size. +fn apply_pixel(id: &str, params: &Map, data: &mut [u8], w: usize, h: usize) { + let Some(n) = w.checked_mul(h).and_then(|n| n.checked_mul(4)) else { return }; + if n != data.len() || w == 0 || h == 0 { + return; + } + let effect = vectorcraft_doc::Effect { id: id.to_string(), params: Value::Object(params.clone()), visible: true }; + let Some(vectorcraft_effects::RasterFx::Pixel(fx)) = vectorcraft_effects::raster_effects(std::slice::from_ref(&effect)).into_iter().next() else { + return; + }; + fx.apply(data, w, h, &preview_space(w, h)); +} + +fn filtered(id: &str, params: &Map, w: usize, h: usize) -> Vec { + let mut data = sample_drawing(w, h); + apply_pixel(id, params, &mut data, w, h); + data +} + +/// The drawing with `jobs` applied in order (the visible stack, or the filter being browsed). +fn preview_rgba(jobs: &[(String, Map)], w: usize, h: usize) -> Vec { + let mut data = sample_drawing(w, h); + for (id, params) in jobs { + apply_pixel(id, params, &mut data, w, h); + } + data +} + +fn thumb_pixels(id: &str) -> Vec { + static CACHE: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + if let Some(px) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(id).cloned() { + return px; + } + // The filter runs outside the lock: a thumbnail is a few thousand pixels, and two threads + // computing the same one just keep one copy. + let px = filtered(id, &default_params(id), THUMB, THUMB); + CACHE.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string(), px.clone()); + px +} + +/// Straight RGBA for egui, from the filters' premultiplied bytes. +fn color_image(data: &[u8], w: usize, h: usize) -> egui::ColorImage { + let mut pixels = Vec::with_capacity(w.saturating_mul(h)); + let (rows, _) = data.as_chunks::<4>(); + for p in rows { + let a = u32::from(p[3]); + let (r, g, b) = if a == 0 { + (0, 0, 0) + } else if a == 255 { + (p[0], p[1], p[2]) + } else { + let s = |c: u8| ((u32::from(c) * 255 + a / 2) / a).min(255) as u8; + (s(p[0]), s(p[1]), s(p[2])) + }; + pixels.push(egui::Color32::from_rgba_unmultiplied(r, g, b, p[3])); + } + if pixels.len() != w.saturating_mul(h) { + return egui::ColorImage::filled([w.max(1), h.max(1)], egui::Color32::TRANSPARENT); + } + egui::ColorImage::new([w, h], pixels) +} + +fn thumb_tex(ctx: &egui::Context, id: &str) -> egui::TextureHandle { + let key = egui::Id::unique("effect-gallery-thumbs"); + if let Some(tex) = ctx.data(|d| d.get_temp::>(key).and_then(|m| m.get(id).cloned())) { + return tex; + } + let tex = ctx.load_texture(format!("effect-gallery-thumb-{id}"), color_image(&thumb_pixels(id), THUMB, THUMB), egui::TextureOptions::NEAREST); + ctx.data_mut(|d| { + d.get_temp_mut_or_insert_with(key, HashMap::new).insert(id.to_string(), tex.clone()); + }); + tex +} + +/// What the in-dialog preview shows: the visible stack, or the browsed filter when nothing is stacked. +fn preview_jobs(d: &Dialog, stack: &[StackEntry], browse: &str) -> Vec<(String, Map)> { + if !stack.is_empty() { + return stack.iter().filter(|e| e.visible).map(|e| (e.effect.clone(), e.params.clone())).collect(); + } + if browse.is_empty() { + return Vec::new(); + } + let params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(browse) { browse_params(d) } else { default_params(browse) }; + vec![(browse.to_string(), params)] +} + +fn preview_key(jobs: &[(String, Map)]) -> String { + let rows: Vec = jobs.iter().map(|(id, params)| json!({ "effect": id, "params": params })).collect(); + serde_json::to_string(&Value::Array(rows)).unwrap_or_default() +} + +fn preview_tex(ctx: &egui::Context, key: &str, rgba: &[u8]) -> egui::TextureHandle { + let id = egui::Id::unique("effect-gallery-preview"); + if let Some(tex) = ctx.data(|d| d.get_temp::<(String, egui::TextureHandle)>(id).filter(|(k, _)| k == key).map(|(_, tex)| tex)) { + return tex; + } + let tex = ctx.load_texture("effect-gallery-preview", color_image(rgba, PREVIEW_W, PREVIEW_H), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key.to_string(), tex.clone()))); + tex +} + +fn preview_area(ui: &mut egui::Ui, d: &Dialog, stack: &[StackEntry], browse: &str) { + let t = Tokens::get(ui.ctx()); + widgets::subheader(ui, tl!("Preview")); + let jobs = preview_jobs(d, stack, browse); + let key = preview_key(&jobs); + let rgba = preview_rgba(&jobs, PREVIEW_W, PREVIEW_H); + let tex = preview_tex(ui.ctx(), &key, &rgba); + #[cfg(test)] + PREVIEWS_PAINTED.with(|c| c.set(c.get() + 1)); + let (img_w, img_h) = (PREVIEW_W as f32 * 2.0, PREVIEW_H as f32 * 2.0); + ui.horizontal(|ui| { + let spare = (ui.available_width() - img_w).max(0.0); + ui.add_space(spare / 2.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(img_w, img_h), egui::Sense::hover()); + ui.painter().rect_filled(rect, 2.0, t.panel_darker); + let inner = rect.shrink(4.0); + ui.painter().image(tex.id(), inner, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + }); + let caption = jobs.iter().map(|(id, _)| effect_label(id)).collect::>().join(", "); + if !caption.is_empty() { + ui.label(egui::RichText::new(caption).color(t.text_dim)); + } + ui.add_space(8.0); +} + fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let cat = category_ix(d); @@ -263,6 +469,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if stack_sel >= stack.len() && !stack.is_empty() { stack_sel = stack.len() - 1; } + preview_area(ui, d, &stack, &sel); + let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel); if !param_effect.is_empty() { widgets::subheader(ui, tl!("Settings")); @@ -293,12 +501,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(8.0); ui.vertical(|ui| { widgets::subheader(ui, tl!("Filters")); - ui.set_width(220.0); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.set_width(248.0); + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { for e in &effects { let picked = e.id == sel; let name = tl!(e.label).trim_end_matches('…').to_string(); - let resp = ui.add(egui::Button::selectable(picked, name)); + let image = egui::Image::from_texture(&thumb_tex(ui.ctx(), e.id)).fit_to_exact_size(egui::vec2(THUMB as f32, THUMB as f32)); + #[cfg(test)] + THUMBS_PAINTED.with(|c| c.set(c.get() + 1)); + let resp = ui.add(egui::Button::selectable(picked, (image, name)).min_size(egui::vec2(200.0, THUMB as f32 + 4.0))); if resp.clicked() { set_selected(d, e.id); set_stack_focus(d, false); @@ -418,6 +629,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } +#[cfg(test)] +thread_local! { + static THUMBS_PAINTED: std::cell::Cell = const { std::cell::Cell::new(0) }; + static PREVIEWS_PAINTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { use serde_json::json; @@ -476,4 +693,49 @@ mod tests { assert!(!n.appearance.effects[1].visible); assert_eq!(st.history.undo.len(), steps + 1); } + + #[test] + fn gallery_shows_thumbnails_and_an_in_dialog_preview() { + let plain = super::sample_drawing(super::THUMB, super::THUMB); + let mut seen = Vec::new(); + for cat in 0..super::CATEGORIES.len() { + for e in super::effects_in_category(cat) { + let thumb = super::thumb_pixels(e.id); + assert_eq!(thumb.len(), super::THUMB * super::THUMB * 4, "{}", e.id); + assert_ne!(thumb, plain, "{} thumbnail is the untouched drawing", e.id); + seen.push(thumb); + } + } + assert!(seen.len() > 2); + assert_ne!(seen[0], seen[1], "two filters share one thumbnail"); + + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + app.run("ui.effectGalleryDialog", json!({})).unwrap(); + super::THUMBS_PAINTED.with(|c| c.set(0)); + super::PREVIEWS_PAINTED.with(|c| c.set(0)); + frame(&mut app); + let thumbs = super::THUMBS_PAINTED.with(|c| c.get()); + let previews = super::PREVIEWS_PAINTED.with(|c| c.get()); + let artistic = super::effects_in_category(0).len() as u32; + assert!(thumbs >= artistic, "painted {thumbs} thumbnails, Artistic has {artistic}"); + assert!(previews >= 1, "the dialog has a preview area"); + + let d = app.ui.dialog.as_ref().unwrap(); + let stack = super::stack_of(d); + let jobs = super::preview_jobs(d, &stack, &super::selected_id(d)); + assert_eq!(jobs.len(), 1, "an empty stack previews the filter being browsed"); + let shown = super::preview_rgba(&jobs, super::PREVIEW_W, super::PREVIEW_H); + assert_ne!(shown, super::sample_drawing(super::PREVIEW_W, super::PREVIEW_H)); + + let soft = super::filtered("artistic.cutout", &super::default_params("artistic.cutout"), super::PREVIEW_W, super::PREVIEW_H); + let hard = super::filtered( + "artistic.cutout", + &serde_json::from_value(json!({"levels": 8, "edgeSimplicity": 0, "edgeFidelity": 3})).unwrap(), + super::PREVIEW_W, + super::PREVIEW_H, + ); + assert_ne!(soft, hard, "the preview follows the filter's settings"); + } } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 8f90ce5f4..d21cc2192 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -12,7 +12,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Group | Illustrator | VectorCraft | % | Remaining (h) | |---|---:|---:|---:|---:| | Illustrator effects (vector and stylize) | 50 | 50 | 100% | — | -| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) | +| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | — | | 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | @@ -48,12 +48,12 @@ its resolution, PDF export warns once per object with the requested and the lowe | Stylize | Glowing Edges | 1/1 | — | | Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | — | | Video | De-Interlace, NTSC Colors | 2/2 | — | -| Effect Gallery | the combined browser and stack dialog | 1/1 (Effect › Effect Gallery…, #2883: six categories, per-filter settings, ordered stack with add/reorder/hide/remove, live canvas preview, one undo step; `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`) | filter thumbnails; a preview area inside the dialog ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | +| Effect Gallery | the combined browser and stack dialog | 1/1 (Effect › Effect Gallery…, #2883: six categories, per-filter settings, ordered stack with add/reorder/hide/remove, live canvas preview, one undo step; a thumbnail for each filter and a preview area inside the dialog, both an original landscape drawing passed through the filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html); `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`, `gallery_shows_thumbnails_and_an_in_dialog_preview`) | — | The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter (see [calibration](target-app-parity.md#calibration)). Every Photoshop-style filter is in the catalogue, and the Effect -Gallery dialog shipped (#2883); its filter thumbnails and in-dialog preview area are the 1–2 h that remain. +Gallery dialog shipped (#2883), with a thumbnail for each filter and a preview area inside the dialog. Both use an original landscape drawing passed through the filter; the canvas preview and the one-undo OK are unchanged. Film Grain (`artistic.filmGrain`, Effect › Artistic) adds stable monochrome noise to shadows and midtones, with Grain, Highlight Area and Intensity sliders, live preview and appearance editing. @@ -193,6 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). | | 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | | 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index c5bb5c5b3..0939dbf3d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -94,7 +94,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) | | 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) | | 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) | -| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 1–2 | no | [effects-parity.md](effects-parity.md) | +| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | | 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 11–21 | no | [target-app-parity.md](target-app-parity.md) | @@ -512,7 +512,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G2 Photoshop-style raster effects and the Effect Gallery - **Done:** Effect › Texture › Texturizer › Load Texture… (stored grayscale `textureImage`, Loaded Texture in the Texture menu), matching Glass. -- **Missing:** the Effect Gallery's filter thumbnails and a preview area inside the dialog ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); all 57 Photoshop-style filters are in the catalogue. Measured in +- **Done:** the Effect Gallery's filter thumbnails and in-dialog preview ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Each thumbnail is an original landscape drawing passed through that filter at its defaults; the preview area shows the same drawing through the visible stack, or through the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are unchanged (`dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`). All 57 Photoshop-style filters are in the catalogue. Measured in [effects-parity.md](effects-parity.md). - **Done:** the Effect Gallery (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step (`dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`). - **Done:** Effect › Sketch › Conté Crayon (`sketch.conteCrayon`: Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; original visual approximation, defaults unverified), the last missing filter. @@ -520,7 +520,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** the pixel-filter pipeline and all 57 Photoshop-style filters (Artistic, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Sketch, Stylize, Texture, Video), with live preview and editable native settings. Smart Blur supports Edge Only and Overlay Edge modes. Per-filter detail: [effects-parity.md](effects-parity.md). -- **Estimate:** 1–2 h (Effect Gallery thumbnails and in-dialog preview). +- **Estimate:** 0 h. ## G13 Illustrator 2026 (30.x) additions @@ -853,6 +853,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | | 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. | | 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3022637f0..19369b9fa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -113,7 +113,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l 2. **`.ai` files from real users** (G9): layer structure (#951). 10–20 h. 3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h. 4. **Core-tool bugs** (G11): SVG units reverting to points (#864) and a Transform effect in a fill's or stroke's own effects (#885). Shape Builder regions (#937), Asset Export ghost borders (#983 / #1037) and the stalling bezier drag preview (#834) are fixed. 8–15 h. -5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h. +5. **Then:** advanced type (G4) and the Illustrator 2026 additions (G13), then 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2). ## Next @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | | 2026-11-09 | minor | M7: Auto Spell Check (#4936); the dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6346f0020..e9e8e193e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -221,7 +221,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | +| Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | @@ -1907,6 +1907,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | | 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. | From 39d538dadfe42dd4a1971809053b6695226b2f2f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 13:48:07 -0800 Subject: [PATCH 3/4] Canvas: Cmd/Ctrl-wheel input finer than a whole notch zooms With Scroll Sideways with Cmd/Ctrl + Wheel on, wheel() turned all Cmd/Ctrl wheel motion into a sideways scroll unless it arrived as a pinch event. A wheel turn now zooms about the pointer once it holds an event from a trackpad source, point deltas from an unnamed source in the desktop app, or line deltas that are not whole notches. Whole notches keep scrolling sideways, and the option's default is unchanged. The same rule applies with Zoom with Mouse Wheel on. egui-winit reports a wheel source only on macOS, so the delta decides elsewhere. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/ui-egui/src/canvas/tests.rs | 85 +++++++++++++++++++++++++++--- crates/ui-egui/src/canvas/view.rs | 38 +++++++++++-- docs/mcp.md | 8 +-- docs/target-app-parity.md | 2 +- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index df8e53de1..74693a1ca 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4645,7 +4645,7 @@ fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() { /// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the /// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch /// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain -/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl +/// one right after it scrolls two notches in all, and a wheel gesture during which Cmd/Ctrl /// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that /// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with /// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms. @@ -4747,16 +4747,16 @@ fn cmd_wheel_scrolls_sideways_preference() { (zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3, "on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})" ); - // A trackpad gesture starts with no modifier, and Cmd is pressed and released during it: - // egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling - // sideways until then. + // A gesture egui names as a wheel starts with no modifier, and Cmd is pressed and released + // during it: egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps + // scrolling sideways until then. let before = *app.view().unwrap(); let swipe = |phase, modifiers| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 6.0), phase, modifiers, - source: egui::MouseWheelSource::Unknown, + source: egui::MouseWheelSource::Wheel, }; app.synthetic.extend([ egui::Event::PointerMoved(at), @@ -4772,7 +4772,7 @@ fn cmd_wheel_scrolls_sideways_preference() { let scroll = (after.center - before.center) * after.zoom; assert!( (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0, - "on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", + "on: a wheel gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", after.zoom / before.zoom ); // An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui @@ -4820,6 +4820,79 @@ fn cmd_wheel_scrolls_sideways_preference() { assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer"); } +/// With Scroll Sideways with Cmd/Ctrl + Wheel on, Cmd/Ctrl with a touchpad zooms about the pointer +/// while whole line notches scroll sideways: a trackpad or point source, line deltas smaller than +/// a notch, and a whole notch later in the same turn. With Zoom with Mouse Wheel on, Cmd/Ctrl with +/// a touchpad zooms too. +#[test] +fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let mut t = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + t += 1.0 / 60.0; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(t), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let ev = |unit, dy: f32, source| egui::Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source, + }; + // Sends each event on its own frame, then lets egui finish and end the turn: + // → (zoom ratio, scroll in points, how far the point under `at` moved). + let mut turn = |app: &mut VectorcraftApp, events: Vec| { + let before = *app.view().unwrap(); + run(app, vec![egui::Event::PointerMoved(at)]); + for e in events { + run(app, vec![e]); + } + for _ in 0..40 { + run(app, vec![]); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + use egui::{MouseWheelSource as S, MouseWheelUnit as U}; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.0005 && moved < 1e-6; + let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64)| (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0; + assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); + // A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line. + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a line delta smaller than a notch zooms"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)])), "several small deltas zoom"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), "a whole notch in the same turn zooms"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)])), "point deltas of an unnamed source zoom"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "a trackpad scroll zooms"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)])), "its momentum zooms"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "a whole notch scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)])), "a turn of two notches scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)])), "a part of a line from a named wheel scrolls sideways"); + // A turn ends when the wheel goes idle, so the next whole notch scrolls sideways again. + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a touchpad turn"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "the notch after it scrolls sideways"); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "with Zoom with Mouse Wheel: a small line delta zooms"); + assert!( + zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), + "with Zoom with Mouse Wheel: a whole notch in the same turn zooms" + ); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "with Zoom with Mouse Wheel: a trackpad scroll zooms"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "with Zoom with Mouse Wheel: a whole notch scrolls sideways"); +} + /// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms /// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and /// Cmd-wheel alone scrolls sideways. diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 39bc80545..3ed6583ae 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -65,6 +65,9 @@ pub(super) struct WheelTurn { /// When the last wheel event came (`InputState::time`), and whether a trackpad sent it. last: f64, trackpad: bool, + /// A wheel event of this turn came from a touchpad ([`fine_steps`]), so Cmd/Ctrl zooms for the + /// rest of the turn, also through a whole notch. + fine: bool, /// The zoom (natural log) wheel notches still have to glide in. pub(super) glide: f64, } @@ -351,11 +354,26 @@ struct ThumbDrag { max: f32, } +/// Whether a wheel event comes from a touchpad and not from a mouse wheel's notches. egui names +/// the source on the Mac and reports `Unknown` elsewhere. There, point deltas (Wayland) and line +/// deltas that are not whole notches (Windows and X11) count as a touchpad. A browser reports a +/// mouse wheel in points, so the web build counts only line deltas that are not whole notches. +fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool { + let notch = |d: f32| (d - d.round()).abs() < 1e-3; + match source { + egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum => true, + egui::MouseWheelSource::Wheel => false, + egui::MouseWheelSource::Unknown if unit == egui::MouseWheelUnit::Point => !cfg!(target_arch = "wasm32"), + egui::MouseWheelSource::Unknown => !notch(delta.x) || !notch(delta.y), + } +} + /// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option /// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls /// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the -/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. Cmd/Ctrl +/// with a touchpad ([`fine_steps`]) zooms under either preference. `line` and `page`: points /// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated /// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does /// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at @@ -385,7 +403,7 @@ pub(super) fn wheel( // `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held // Alt keeps zooming until it ends, also after Alt is let go. for e in &i.events { - if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } = e { // A wheel event that comes after egui's scroll timeout ends the turn first, as // `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else // 0.15 s), also when no frame ran in between. @@ -393,9 +411,11 @@ pub(super) fn wheel( if i.time - turn.last > timeout { turn.gesture = false; turn.held = egui::Modifiers::NONE; + turn.fine = false; } turn.last = i.time; turn.trackpad = *source == egui::MouseWheelSource::Trackpad; + turn.fine |= fine_steps(*unit, *delta, *source); match phase { egui::TouchPhase::Start => { turn.gesture = true; @@ -413,6 +433,7 @@ pub(super) fn wheel( egui::TouchPhase::End | egui::TouchPhase::Cancel => { turn.gesture = false; turn.held = egui::Modifiers::NONE; + turn.fine = false; } } } @@ -420,6 +441,7 @@ pub(super) fn wheel( if !i.is_scrolling() { turn.gesture = false; turn.held = egui::Modifiers::NONE; + turn.fine = false; } let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); // Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without @@ -427,7 +449,7 @@ pub(super) fn wheel( // default speed, which the app keeps. The canvas scrolls sideways by those points instead. // A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the // fingers' alone, and the canvas leaves it as it is. - if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() { + if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && !turn.fine && i.multi_touch().is_none() { let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product(); let wheel_zoom = zoom / pinch; if wheel_zoom.is_finite() && wheel_zoom > 0.0 { @@ -461,9 +483,15 @@ pub(super) fn wheel( let mut scroll = pan; for e in &i.events { match e { - egui::Event::MouseWheel { unit, delta, modifiers, .. } => { + egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => { let d = points(unit, *delta); - if modifiers.command { + // The turn ends after egui's scroll timeout, as above. + if i.time - turn.last > 0.150 { + turn.fine = false; + } + turn.last = i.time; + turn.fine |= fine_steps(*unit, *delta, *source); + if modifiers.command && !turn.fine { scroll.x += d.x + d.y; } else if modifiers.shift { scroll.y += d.x + d.y; diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0..15aadc5b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3995,9 +3995,11 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. -`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while -`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac -trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a +`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways +instead of zooming while `zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel +and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer. Under either +wheel preference, Cmd/Ctrl zooms about the pointer for the rest of a wheel turn once the turn has a trackpad event, +point deltas from an unnamed source in the desktop app, or line deltas that are not whole notches. With `zoomWithMouseWheel` off, once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's `ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2effc1142..2a0032e35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -786,7 +786,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). - - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. + - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. Under either wheel preference, Cmd/Ctrl with a touchpad zooms about the pointer. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). From 5eb6b62632f834de4e977e134b83517d15e35c45 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 14:13:08 -0800 Subject: [PATCH 4/4] Flatten: blend modes do not read the white under the art Flatten Transparency composited each region over white, so a blend mode on the bare page read that white. Raster renders now draw their background under the finished art, and the flattened color no longer matched them: a Color Burn object on the bare page flattened to white and rendered in its own color. The region now composites the art alone and puts the white under it. - Tests that relied on the white under the art now draw art under the style (`every_builtin_style_paints_visibly_on_a_rectangle`) or compare only whole-pixel pans with a fresh render (`a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again`). - New tests cover every blend mode on white and blue backgrounds, a thumbnail and the ink planes with a Screen glow, and `over_background` at background and pixel alpha 0, 128 and 255. --- crates/engine/src/cmd/flatten.rs | 2 +- crates/engine/src/cmd/flatten/apply.rs | 20 +++++------- crates/engine/src/tests/file.rs | 4 +-- crates/engine/src/tests/stylelib.rs | 7 ++++- crates/render/src/fx/tests.rs | 8 +++-- crates/render/src/renderer.rs | 2 +- crates/render/src/tests/knockout.rs | 42 ++++++++++++++++++++++++++ crates/render/src/tests/objectfx.rs | 24 +++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 +- 10 files changed, 91 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ba99c4beb..2a12eb9c5 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -5,7 +5,7 @@ //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by -//! [`composite`]), over white (or over nothing with Preserve Alpha); in CMYK documents their inks +//! [`composite`]), with white under the finished art (or nothing with Preserve Alpha); in CMYK documents their inks //! composite, plane by plane ([`cmyk_planes`]), and the colour is CMYK. Each region becomes a path //! filled with that flat colour. Where gradients, patterns, images, opacity masks or raster //! effects reach there is no single colour: those regions are rendered into one image per group, diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 2b0b1d072..c70f4dca6 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,10 +1,10 @@ use super::analysis::{Scratch, outlined_text, overlapping, reach}; -use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE}; +use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, ColorMode, Document, EngineError, FillLayer, FillRule, ImageBlob, ImageObject, Inks, - MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, separate, - shape_node, shapes, unique_key, + MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, composite, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, + separate, shape_node, shapes, unique_key, }; // ---------- plan ---------- @@ -235,18 +235,14 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool if alone[3] < 0.5 / 255.0 { return None; } - let start = if o.preserve_alpha { CLEAR } else { WHITE }; - let px = if o.preserve_alpha { - alone - } else { - c.top = None; - c.eval(elems, start) - }; + // The white goes under the finished art, as under the renderer's: blend modes read only the art. + let on_page = |p: Px| if o.preserve_alpha { p } else { composite(BlendMode::Normal, WHITE, p) }; + let px = on_page(alone); let rgb = [px[0], px[1], px[2]]; // The K plane composites the same way. let k = cmyk.then(|| { c.plane = 1; - c.eval(elems, start)[0] + on_page(c.eval(elems, CLEAR))[0] }); // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3); @@ -266,7 +262,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool } /// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out -/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and +/// again, or over nothing with Preserve Alpha. The image and /// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. diff --git a/crates/engine/src/tests/file.rs b/crates/engine/src/tests/file.rs index 5fb568a23..6467fe091 100644 --- a/crates/engine/src/tests/file.rs +++ b/crates/engine/src/tests/file.rs @@ -462,10 +462,10 @@ fn a_screen_outer_glow_is_in_raster_exports() { }; for p in [json!({"format": "png", "background": "white"}), json!({"format": "jpg"})] { let px = pixel(p.clone()); - assert!(px[0] > 235 && px[2] < 235, "{p}: yellow on white: {px:?}"); + assert!(px[0] > 235 && px[1] > 235 && px[2] < 235, "{p}: yellow on white: {px:?}"); } let px = pixel(json!({"format": "png", "background": "transparent"})); - assert!(px[3] > 0 && px[2] < 40, "the glow is in the transparent image: {px:?}"); + assert!(px[3] > 0 && px[0] > 235 && px[1] > 235 && px[2] < 40, "the glow is yellow in the transparent image: {px:?}"); } /// A group's own drop shadow is inside the crop of the group's selection export too. diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 6a93b80a3..31ceb2434 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -251,11 +251,16 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { let (_, lib) = cmd::stylelib::library(&s, b.id).unwrap(); for g in &lib.styles { run(&mut s, "file.new", json!({"width": 100, "height": 100})); + // Art under the rectangle: a blend mode shows only against art, not the bare page. + let under = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [under.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [under.0]})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); - let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); + let bare = img.pixel(0, 0); + let painted = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) != bare).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); } } diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index a2b8473ac..01ddd0309 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -182,8 +182,8 @@ fn shadow_rasters_keep_their_node_and_a_byte_budget() { /// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole /// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes, /// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel -/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and -/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow +/// keeps the raster and places it at the nearest whole pixel, as a full-size raster kept whole is +/// placed. A 100 pt glow at 500% (scale 2) and a 2 pt glow /// round the page at 800% (scale 3, near the narrowest blur made coarser). #[test] fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { @@ -210,7 +210,9 @@ fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn"); let mut fresh = Renderer::new(); fresh.threads = 2; - assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + if pan.0.fract() == 0.0 && pan.1.fract() == 0.0 { + assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + } } } } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 2056ccf59..88eeb4533 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -421,7 +421,7 @@ impl Renderer { } /// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). -fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { +pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { let alpha = u32::from(a); let under = [u32::from(r) * alpha / 255, u32::from(g) * alpha / 255, u32::from(b) * alpha / 255, alpha]; for p in pixels.as_chunks_mut::<4>().0 { diff --git a/crates/render/src/tests/knockout.rs b/crates/render/src/tests/knockout.rs index 1f740b6b3..4043173ae 100644 --- a/crates/render/src/tests/knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -133,6 +133,48 @@ fn page_knockout_and_isolated_blending() { assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white object: {:?}", render(&d).pixel(30, 50)); } +/// On a white or blue background, every blend mode on the bare page gives the transparent +/// render over that color (#6125). +#[test] +fn no_blend_mode_reads_the_background() { + for mode in Blend::ALL { + let mut d = Document::new(100.0, 100.0); + let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + red.blend = mode; + d.insert(Some(d.layers[0].id), 0, red).unwrap(); + let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(30, 50); + for bg in [[255, 255, 255, 255], [0, 0, 255, 255]] { + let got = + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(30, 50); + let a = i32::from(clear[3]); + let want = [0, 1, 2].map(|c| (i32::from(clear[c]) * a + i32::from(bg[c]) * (255 - a)) / 255); + assert!((0..3).all(|c| (i32::from(got[c]) - want[c]).abs() <= 3) && got[3] == 255, "{mode:?} on {bg:?}: {got:?}, want {want:?}"); + } + } +} + +/// Premultiplied pixels of alpha 0, 128 and 255 over backgrounds of alpha 0, 128 and 255. +#[test] +fn over_background_composites_premultiplied_pixels() { + let over = |p: [u8; 4], a: u8| { + let mut px = p.to_vec(); + crate::renderer::over_background(&mut px, [200, 100, 0, a]); + [px[0], px[1], px[2], px[3]] + }; + const CLEAR: [u8; 4] = [0; 4]; + const HALF: [u8; 4] = [64, 10, 100, 128]; + const SOLID: [u8; 4] = [9, 8, 7, 255]; + assert_eq!(over(CLEAR, 0), CLEAR); + assert_eq!(over(HALF, 0), HALF); + assert_eq!(over(SOLID, 0), SOLID); + assert_eq!(over(CLEAR, 128), [100, 50, 0, 128]); + assert_eq!(over(HALF, 128), [114, 35, 100, 192]); + assert_eq!(over(SOLID, 128), SOLID); + assert_eq!(over(CLEAR, 255), [200, 100, 0, 255]); + assert_eq!(over(HALF, 255), [164, 60, 100, 255]); + assert_eq!(over(SOLID, 255), SOLID); +} + #[test] fn page_knockout_looks_the_same_when_another_object_blends() { // With Page Knockout Group on, a Multiply square on the page makes the renderer draw the page diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index e7d8742d2..fe1e5ee1d 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -784,6 +784,7 @@ fn glowing_square(fill: Color, id: &str, params: Value) -> Document { fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() { let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(27, 50); + assert!(clear[0] > 20 && clear[0] == clear[1] && clear[2] == 0, "the glow is yellow: {clear:?}"); for (bg, name) in [([255, 255, 255, 255], "white"), ([0, 0, 255, 255], "blue")] { let p = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(27, 50); let a = u32::from(clear[3]); @@ -793,6 +794,29 @@ fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() { } } +/// A thumbnail with a background shows a Screen glow on the bare page, opaque (#6125). +#[test] +fn a_screen_outer_glow_is_in_a_thumbnail_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let n = d.layers[0].children().unwrap()[0].clone(); + let img = Renderer::new().render_node_thumbnail(&d, &n, 100, Some([255; 4])).unwrap(); + let pixels = img.pixels.as_chunks::<4>().0; + assert!(pixels.iter().all(|p| p[3] == 255), "every pixel is opaque"); + assert!(pixels.iter().any(|p| p[0] > 235 && p[1] > 235 && p[2] < 235), "the glow shows as yellow on white"); +} + +/// The ink planes put the background under the art too: on a white background a Screen glow on +/// the bare page prints yellow ink, and the bare page around it prints none (#6125). +#[test] +fn a_screen_outer_glow_prints_ink_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let opts = RenderOptions { background: Some([255; 4]), ..Default::default() }; + let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &opts); + let at = |x: usize, y: usize| &inks[(y * 100 + x) * 4..][..4]; + assert!(at(27, 50)[2] > 10, "yellow ink in the glow: {:?}", at(27, 50)); + assert_eq!(at(5, 50), [0; 4], "no ink on the bare page"); +} + /// An Inner Glow blends with the object's own fill whatever is under the page: white Screen /// lightens a black square, red Screen leaves a white one white, and red Normal shows on it. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 2eb57aca8..3eda1d344 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2872,7 +2872,7 @@ where the layout puts it. `object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are. Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over -white; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, +white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7682a17a..b742a48e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -592,7 +592,7 @@ since 2026-10-01. - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. -- **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`. +- **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`.