From fa46998006f8a93d88628954cb49a2d93ffca619 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 05:04:13 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Effects:=20Effect=20=E2=80=BA=20Rasterize?= =?UTF-8?q?=20draws=20an=20object=20as=20pixels=20while=20its=20paths=20st?= =?UTF-8?q?ay=20editable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Effect menu had no Rasterize. Object › Rasterize replaces the object with an image, so its paths can no longer be edited. Effect › Rasterize… (`rasterize`) is a live effect with Object › Rasterize's options: color model, resolution (the document's raster effects resolution by default), white or transparent background, anti-aliasing, Create Clipping Mask and Add Around Object. The canvas draws the object as square pixels over its bounds, and its paths, type and other art stay as they are. The effects above it in the stack, shadows included, are part of its image, and shadows below it are cast by the image. When Rasterize is an object's only raster effect, Expand Appearance and PDF and SVG export give the image Object › Rasterize gives with the same options. Object › Rasterize now measures the art as drawn, with its geometry effects, brush art and raster effects, so a Transform effect that moves the art no longer leaves the image empty. Its Create Clipping Mask clips the image to the art as drawn. The canvas no longer panics drawing a pixel effect such as Gaussian Blur on an object about 65,500 screen pixels wide: the raster of such an object is at most 32,768 pixels a side, and the object is drawn into it at a lower resolution. Docs: docs/mcp.md, docs/effects-parity.md, docs/target-app-parity.md, ROADMAP. --- ROADMAP.md | 2 +- crates/effects/src/lib.rs | 14 +- crates/effects/src/raster.rs | 285 ++++++++- crates/effects/src/tests_scale.rs | 4 + crates/engine/src/cmd/effectcmd.rs | 9 +- crates/engine/src/cmd/menucmds.rs | 107 ++-- crates/engine/src/cmd/rasterfx.rs | 58 +- crates/engine/src/tests_rastersettings.rs | 153 ++++- crates/render/src/fx.rs | 575 +++++++++++++++++-- crates/render/src/lib.rs | 15 + crates/render/src/tests_rasterize.rs | 423 ++++++++++++++ crates/svg/src/css.rs | 2 +- crates/svg/src/export.rs | 4 +- crates/ui-egui/src/dialogs/effect.rs | 4 + crates/ui-egui/src/dialogs/raster_effects.rs | 92 ++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 22 +- crates/ui-egui/src/tests_labels.rs | 12 + docs/effects-parity.md | 4 +- docs/mcp.md | 18 + docs/target-app-parity.md | 3 +- 39 files changed, 1707 insertions(+), 118 deletions(-) create mode 100644 crates/render/src/tests_rasterize.rs diff --git a/ROADMAP.md b/ROADMAP.md index 522d5ffc9..62175078d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89ba2d491..b3eb5e717 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -5,7 +5,7 @@ //! //! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble, //! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order. -//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the +//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the //! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern, //! Unsharp Mask and Glowing Edges) are described by //! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond @@ -68,7 +68,7 @@ pub use group::{ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RasterFx, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ @@ -150,6 +150,7 @@ fn lengths_of(id: &str) -> Lengths { "texture.patchwork" => always(&["squareSize"]), "texture.stainedGlass" => always(&["cellSize"]), "sketch.halftonePattern" => always(&["size"]), + RASTERIZE => always(&["addAround"]), _ => Lengths::default(), } } @@ -614,6 +615,13 @@ pub fn effect_catalog() -> Vec { "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", json!({}), )); + v.push(r( + RASTERIZE, + "Rasterize…", + &["Effect"], + "{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes", + json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}), + )); v } @@ -763,7 +771,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) { /// Is `id` a raster (painted) effect? pub fn is_raster(id: &str) -> bool { - matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") + matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE) || PIXEL_EFFECTS.contains(&id) } diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 31cc56976..e9a51fc43 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,15 +1,19 @@ //! Raster (painted) effects: descriptions for the renderer. use serde_json::Value; -use vectorcraft_doc::Effect; use vectorcraft_doc::color::{BlendMode, Color}; +use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; +use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::Rect; +use vectorcraft_geom::{Point, Rect}; use crate::merged_params; -use crate::pixel::{self, PixelFx}; +use crate::pixel::{self, PixelFx, PixelSpace}; use crate::util::*; +/// Effect › Rasterize's id. +pub const RASTERIZE: &str = "rasterize"; + /// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses /// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur). #[derive(Clone, Debug, PartialEq)] @@ -48,6 +52,8 @@ pub enum RasterFx { }, /// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…). Pixel(PixelFx), + /// Effect › Rasterize: the object drawn as an image of square pixels. + Rasterize(Rasterize), } impl RasterFx { @@ -71,6 +77,278 @@ impl RasterFx { RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0, RasterFx::GaussianBlur { radius } => 1.5 * radius, RasterFx::Pixel(p) => p.outset(bounds), + RasterFx::Rasterize(r) => r.outset(), + } + } + /// This effect with a Rasterize that takes the document's resolution at `ppi` ([`Rasterize::at_document_ppi`]). + pub fn at_document_ppi(&self, ppi: f64) -> RasterFx { + match self { + RasterFx::Rasterize(r) => RasterFx::Rasterize(r.at_document_ppi(ppi)), + fx => fx.clone(), + } + } + /// What this effect paints over when what it paints around covers `bounds`: Rasterize's image + /// ([`Rasterize::image`], `line` the document's raster grid), `bounds` grown by + /// [`Self::outset`] for the others. + pub fn spread(&self, bounds: Rect, line: f64) -> Rect { + match self { + RasterFx::Rasterize(r) => r.image(bounds, line), + fx => { + let o = fx.outset(bounds); + bounds.inflate(o, o) + } + } + } +} + +/// Effect › Rasterize's options, read from its parameters. The image covers the object's bounds +/// (as far as the effects before it spread the object) and `add_around` points around them, in +/// pixels `72 / ppi` points wide whose first one starts at that area's top-left corner, as Object +/// › Rasterize makes its image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rasterize { + /// Pixels per inch (1 to 2400); `None` takes Document Raster Effects Settings › Resolution. + pub ppi: Option, + /// The image's color model. + pub color_model: RasterColorModel, + /// A white background: the image is opaque. Otherwise it keeps its transparency. + pub white: bool, + /// Smooth edges (Art Optimized or Type Optimized). Off, each pixel takes the art at its center. + pub anti_alias: bool, + /// Type Optimized: smooth edges with type fitted to the pixels. + pub hinted: bool, + /// The image is clipped to the art's outline. + pub clipping_mask: bool, + /// Room around the art, in points (0 to [`MAX_ADD_AROUND`]). + pub add_around: f64, +} + +impl Rasterize { + /// The options in the effect's parameters `p` (defaults merged in). + fn parse(p: &Value) -> Self { + let ppi = (!flag(p, "useDocumentResolution", true)).then(|| num(p, "resolution", 72.0).clamp(1.0, 2400.0)); + let color_model = match text(p, "colorModel", "document").to_ascii_lowercase().as_str() { + "grayscale" | "gray" => RasterColorModel::Grayscale, + "bitmap" => RasterColorModel::Bitmap, + _ => RasterColorModel::Document, + }; + Self { + ppi, + color_model, + white: text(p, "background", "transparent").eq_ignore_ascii_case("white"), + anti_alias: !text(p, "antiAlias", "art").eq_ignore_ascii_case("none"), + hinted: text(p, "antiAlias", "art").eq_ignore_ascii_case("type"), + clipping_mask: flag(p, "clippingMask", false), + add_around: num(p, "addAround", 0.0).clamp(0.0, MAX_ADD_AROUND), + } + } + + /// The width of a pixel of the image in document units: 72 / `ppi`, or `line` (72 / the + /// document's raster effects resolution) when it takes the document's. + pub fn cell(&self, line: f64) -> f64 { + self.ppi.map_or(line, |ppi| 72.0 / ppi) + } + + /// These options at `ppi` when they take the document's resolution. + pub fn at_document_ppi(self, ppi: f64) -> Self { + Self { ppi: self.ppi.or(Some(ppi)), ..self } + } + + /// How far beyond the object's bounds the image reaches: the room around it and, at a known + /// resolution, one pixel more for the last column and row, which are whole pixels. + pub fn outset(&self) -> f64 { + self.add_around + self.ppi.map_or(0.0, |ppi| 72.0 / ppi) + } + + /// How far (document units) from a pixel the content that decides it lies: across its pixel + /// of the image (72 points when the resolution is the document's, not known here). + pub fn reach(&self) -> f64 { + self.cell(72.0) * std::f64::consts::SQRT_2 + } + + /// The image of art over `bounds`: `bounds` and the room around them, in whole pixels + /// ([`Self::cell`] of `line`) from that area's top-left corner. + pub fn image(&self, bounds: Rect, line: f64) -> Rect { + let cell = self.cell(line); + let area = bounds.inflate(self.add_around, self.add_around); + let (cols, rows) = ((area.width() / cell).ceil().max(1.0), (area.height() / cell).ceil().max(1.0)); + Rect::new(area.x0, area.y0, area.x0 + cols * cell, area.y0 + rows * cell) + } + + /// Give `pixels` (premultiplied RGBA8), the art drawn on the image's own grid, the background + /// and then the color model, as Object › Rasterize does. + pub fn finish(&self, pixels: &mut [u8]) { + for p in pixels.as_chunks_mut::<4>().0 { + if self.white { + let under = 255 - p[3]; + *p = p.map(|c| c.saturating_add(under)); + } + } + RasterEffectsSettings { color_model: self.color_model, ..Default::default() }.finish_pixels(pixels); + } + + /// Draw `image` into `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as + /// `space` says: `image` is `cols` × `rows` premultiplied RGBA8 pixels covering `rect` in the + /// document. In a view that isn't turned, each pixel of `data` takes the mean of the image over + /// its area (transparent outside the image); otherwise the mean of the image at up to 8 × 8 + /// points across it. Does nothing when the sizes don't match. + #[allow(clippy::too_many_arguments)] + pub fn compose(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, image: &[u8], cols: usize, rows: usize, rect: Rect) { + let (px, rest) = data.as_chunks_mut::<4>(); + let (src, src_rest) = image.as_chunks::<4>(); + let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n); + if !fits(px.len(), w, h, rest.len()) || !fits(src.len(), cols, rows, src_rest.len()) || !(rect.width() > 0.0 && rect.height() > 0.0) { + return; + } + let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height()); + // Document → image pixels. + let to_image = |p: Point| ((p.x - rect.x0) * sx, (p.y - rect.y0) * sy); + let at = |i: f64, j: f64| { + (i >= 0.0 && j >= 0.0 && i < cols as f64 && j < rows as f64).then(|| src.get(j as usize * cols + i as usize).copied()).flatten() + }; + let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0); + // Points across a raster pixel in a turned view: about one an image pixel. + let n = (space.px * sx.max(sy)).ceil().clamp(1.0, 8.0) as usize; + for (k, p) in px.iter_mut().enumerate() { + let (x, y) = ((k % w) as f64, (k / w) as f64); + let mut acc = [0.0f64; 4]; + let weight = if straight { + let (a, b) = (to_image(space.to_doc * Point::new(x, y)), to_image(space.to_doc * Point::new(x + 1.0, y + 1.0))); + let (u0, u1, v0, v1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1)); + let (i0, i1) = (u0.floor().max(0.0), u1.ceil().min(cols as f64)); + let (j0, j1) = (v0.floor().max(0.0), v1.ceil().min(rows as f64)); + let mut j = j0; + while j < j1 { + let mut i = i0; + while i < i1 { + let share = (u1.min(i + 1.0) - u0.max(i)).max(0.0) * (v1.min(j + 1.0) - v0.max(j)).max(0.0); + if let Some(q) = at(i, j) { + for (s, v) in acc.iter_mut().zip(q) { + *s += share * f64::from(v); + } + } + i += 1.0; + } + j += 1.0; + } + (u1 - u0) * (v1 - v0) + } else { + for t in 0..n * n { + let (fx, fy) = (((t % n) as f64 + 0.5) / n as f64, ((t / n) as f64 + 0.5) / n as f64); + let (i, j) = to_image(space.to_doc * Point::new(x + fx, y + fy)); + if let Some(q) = at(i.floor(), j.floor()) { + for (s, v) in acc.iter_mut().zip(q) { + *s += f64::from(v); + } + } + } + (n * n) as f64 + }; + *p = if weight > 0.0 { acc.map(|s| (s / weight).round().clamp(0.0, 255.0) as u8) } else { [0; 4] }; + } + } + + /// Turn `data`, `w` × `h` premultiplied RGBA8 pixels of the object lying in the document as + /// `space` says, into its image over `bounds` (what the object covers after the effects before + /// this one), when the image is too large to draw on its own grid. Each pixel of the image more + /// than a raster pixel wide takes the mean of the raster pixels whose centers lie in it + /// (anti-aliased) or the raster pixel at its center (not anti-aliased). Smaller pixels are left + /// as drawn. Then the background and the color model apply as Object › Rasterize applies them. + /// What lies outside the image becomes transparent, and in a view that isn't turned the raster + /// pixels along its edges keep the share of them it covers. Does nothing when the sizes don't + /// match. The renderer draws the content with hard edges when it goes into Rasterize with + /// anti-aliasing off and image pixels at least a raster pixel wide. + pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, bounds: Rect) { + let cell = self.cell(space.line); + let (px, rest) = data.as_chunks_mut::<4>(); + if w == 0 || h == 0 || !rest.is_empty() || w.checked_mul(h) != Some(px.len()) || !(cell.is_finite() && cell > 0.0 && space.px > 0.0) { + return; + } + let area = self.image(bounds, space.line); + let (cols, rows) = ((area.width() / cell).round().max(1.0), (area.height() / cell).round().max(1.0)); + // The pixel of the image (column, row) that a raster pixel's center lies in. + let pixel_at = |x: usize, y: usize| { + let p = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5); + let (c, r) = (((p.x - area.x0) / cell).floor(), ((p.y - area.y0) / cell).floor()); + (c >= 0.0 && r >= 0.0 && c < cols && r < rows).then_some((c, r)) + }; + let wide = cell / space.px; + // The image's pixels the raster covers: columns c0..c1, rows r0..r1. + let seen = space.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let (c0, c1) = (((seen.x0 - area.x0) / cell).floor().clamp(0.0, cols), ((seen.x1 - area.x0) / cell).ceil().clamp(0.0, cols)); + let (r0, r1) = (((seen.y0 - area.y0) / cell).floor().clamp(0.0, rows), ((seen.y1 - area.y0) / cell).ceil().clamp(0.0, rows)); + let span = (c1 - c0) as usize; + let slot = |(c, r): (f64, f64)| ((r - r0) as usize).saturating_mul(span).saturating_add((c - c0) as usize); + // Anti-aliased: the sums of each pixel's raster pixels (premultiplied) and their count, + // which saturate past 16 million raster pixels to an image pixel. They are kept for the + // whole grid when it has no more pixels than the raster, else for the pixels the raster + // reaches (a turned view of a narrow raster). + let grid = span.saturating_mul((r1 - r0) as usize); + let whole = grid <= px.len(); + let mut dense: Vec<[u32; 5]> = vec![]; + let mut sparse: std::collections::HashMap = std::collections::HashMap::new(); + if self.anti_alias && wide > 1.0 { + if whole { + dense = vec![[0; 5]; grid]; + } + for (i, p) in px.iter().enumerate() { + let Some(k) = pixel_at(i % w, i / w).map(slot) else { continue }; + let s = if whole { dense.get_mut(k) } else { Some(sparse.entry(k).or_default()) }; + if let Some(s) = s { + for (acc, v) in s.iter_mut().zip(p) { + *acc = acc.saturating_add(u32::from(*v)); + } + s[4] = s[4].saturating_add(1); + } + } + } + let sum = |k: usize| if whole { dense.get(k) } else { sparse.get(&k) }; + // Not anti-aliased: the raster as drawn, to read each pixel's center from. + let drawn = (!self.anti_alias && wide > 1.0).then(|| px.to_vec()); + let to_raster = space.to_doc.inverse(); + let model = RasterEffectsSettings { color_model: self.color_model, ..Default::default() }; + // In a view that isn't turned, the raster pixels along the image's edges take the share of + // them it covers; otherwise those whose center lies in it are in it. + let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0); + let image = to_raster.transform_rect_bbox(area); + let cover = + |x: f64, y: f64| (image.x1.min(x + 1.0) - image.x0.max(x)).clamp(0.0, 1.0) * (image.y1.min(y + 1.0) - image.y0.max(y)).clamp(0.0, 1.0); + for (i, p) in px.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + let (at, share) = if straight { + // The image pixel nearest the raster pixel's center. + let q = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5); + let at = (((q.x - area.x0) / cell).floor().clamp(0.0, cols - 1.0), ((q.y - area.y0) / cell).floor().clamp(0.0, rows - 1.0)); + (Some(at), cover(x as f64, y as f64)) + } else { + (pixel_at(x, y), 1.0) + }; + let Some(at) = at.filter(|_| share > 0.0) else { + *p = [0; 4]; + continue; + }; + // An image pixel's color, over the share of the raster pixel the image covers. (A raster + // pixel left as drawn holds art the image covers whole.) + let made = if let Some(s) = sum(slot(at)).filter(|s| s[4] > 0) { + Some([0, 1, 2, 3].map(|k| ((u64::from(s[k]) + u64::from(s[4] / 2)) / u64::from(s[4])).min(255) as u8)) + } else { + drawn.as_ref().map(|drawn| { + let center = to_raster * Point::new(area.x0 + (at.0 + 0.5) * cell, area.y0 + (at.1 + 0.5) * cell); + let (x, y) = (center.x.floor(), center.y.floor()); + let inside = x >= 0.0 && y >= 0.0 && x < w as f64 && y < h as f64; + if inside { drawn.get(y as usize * w + x as usize).copied().unwrap_or_default() } else { [0; 4] } + }) + }; + if let Some(v) = made { + *p = if share < 1.0 { v.map(|c| (f64::from(c) * share).round() as u8) } else { v }; + } + // White goes under the art first, over the image's share of the pixel, then the color + // model applies, as in Object › Rasterize. + if self.white { + let under = ((255.0 * share).round() as u8).saturating_sub(p[3]); + *p = p.map(|c| c.saturating_add(under)); + } + model.finish_pixels(p); } } } @@ -133,6 +411,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { }, "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, "blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, + RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)), id => RasterFx::Pixel(pixel::parse(id, &p)?), }) }) diff --git a/crates/effects/src/tests_scale.rs b/crates/effects/src/tests_scale.rs index 420a273a5..6e2c71689 100644 --- a/crates/effects/src/tests_scale.rs +++ b/crates/effects/src/tests_scale.rs @@ -23,6 +23,10 @@ fn scale_effect_scales_distances_only() { scale_effect(&mut e, 2.0); assert_eq!((&e.params["x"], &e.params["y"], &e.params["blur"]), (&json!(6.0), &json!(8.0), &json!(10.0))); assert_eq!(e.params["opacity"], json!(75.0)); + // Rasterize's Add Around Object is a distance; its resolution isn't. + let mut r = new_effect(RASTERIZE, &json!({"addAround": 10, "resolution": 150})).unwrap(); + scale_effect(&mut r, 2.0); + assert_eq!((&r.params["addAround"], &r.params["resolution"]), (&json!(20.0), &json!(150))); // Outline Stroke without a weight follows its stroke's: nothing to scale. let mut o = Effect { id: "path.outlineStroke".into(), params: json!(null), visible: true }; scale_effect(&mut o, 2.0); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1781f3355..e7e70d16d 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}", can_expand, expand_appearance ), @@ -421,9 +421,10 @@ fn is_container(n: &Node) -> bool { } /// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at -/// the document's raster effects resolution, with whether they all paint below it (shadows and -/// outer glows: the image then holds just them). A group's or layer's image leaves out its -/// members' raster effects, which are expanded with them. `None` when it has none. +/// the document's raster effects resolution or a Rasterize effect's finer one, with whether they +/// all paint below it (shadows and outer glows: the image then holds just them). A group's or +/// layer's image leaves out its members' raster effects, which are expanded with them. `None` +/// when it has none. fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { let fx = rasterfx::raster_fx(n); if fx.is_empty() { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 465e38851..8f159d2e4 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -425,21 +425,49 @@ fn render_png( Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height)) } -fn rasterize(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.rasterize"; - let roots = selected_roots(s)?; - let st = s.doc()?; - let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi); +/// The image Object › Rasterize makes of some art, before it goes in a document +/// ([`rasterize_art`]). +pub(crate) struct Rasterized { + png: Vec, + width: u32, + height: u32, + /// Image pixels → document. + xf: Affine, + /// The art's outline, with Create Clipping Mask. + clip: Option<(PathData, vectorcraft_geom::FillRule)>, +} + +impl Rasterized { + /// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's + /// outline when it has one. + pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node { + let key = unique_key(d, "raster"); + d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png)); + let id = d.alloc_id(); + let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() }; + let node = Node::new(id, NodeKind::Image(Box::new(image))); + match self.clip { + Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node), + None => node, + } + } +} + +/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command +/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws +/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. +pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p: &Value, cmd: &str) -> Result { + let ppi = f64_or(p, "ppi", doc.raster_effects_ppi); if !(1.0..=2400.0).contains(&ppi) { - return Err(bad(C, "ppi must be between 1 and 2400")); + return Err(bad(cmd, "ppi must be between 1 and 2400")); } // The document's raster effects settings, changed by the params. - let mut look = st.doc.raster_effects.clone(); + let mut look = doc.raster_effects.clone(); let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]); let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { match k.as_str() { - "background" => background = fileio::background(v).map_err(|e| bad(C, e))?, + "background" => background = fileio::background(v).map_err(|e| bad(cmd, e))?, "antiAlias" => { anti_alias = match v { Value::Bool(b) => { @@ -449,50 +477,65 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { AntiAlias::None } } - v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?, + v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(cmd, e))?, } } - "padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?, - "colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?, - "clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?, + "padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, cmd, k)?, + "colorModel" => look.color_model = super::rasterfx::color_model(v, doc.color_mode, cmd)?, + "clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(cmd, "clippingMask must be true or false"))?, _ => {} } } let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() }; let pad = look.add_around; - let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad); - let scale = ppi / 72.0; - // Snap to whole pixels. - let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); - let nodes: Vec = roots - .iter() - .filter_map(|id| st.doc.node(*id).cloned()) + let nodes: Vec = nodes + .into_iter() .map(|mut n| { n.visible = true; n }) .collect(); - // Create Clipping Mask: the image is clipped to the art's outline. + // What the art draws, its geometry effects applied. + let b = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::drawn_bounds(doc, n))); + let b = b.ok_or_else(|| bad(cmd, "selection has no bounds"))?.inflate(pad, pad); + let scale = ppi / 72.0; + // Snap to whole pixels. + let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); + // Create Clipping Mask: the image is clipped to the art's outline with its geometry effects + // applied, as on the canvas. let clip = look .clipping_mask - .then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))) + .then(|| { + let outlines = nodes.iter().filter_map(|n| { + let (bp, rule) = vectorcraft_render::drawn_outline(doc, n)?; + let mut p = Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(Arc::new(p)) + }); + vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), outlines.collect())) + }) .flatten() .map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)); - let tmp = isolated_doc(&st.doc, nodes); - let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; + let tmp = isolated_doc(doc, nodes); + let (png, width, height) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + Ok(Rasterized { png, width, height, xf, clip }) +} + +fn rasterize(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.rasterize"; + let roots = selected_roots(s)?; + let st = s.doc()?; + let nodes: Vec = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect(); + let image = rasterize_art(&st.doc, nodes, p, C)?; + let (w, h) = (image.width, image.height); if roots.is_empty() { return Err(bad(C, "nothing selected")); } let id = s.edit("Rasterize", |d, sel| { - let key = unique_key(d, "raster"); - d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); - let id = d.alloc_id(); - let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let mut node = - Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); - if let Some((path, rule)) = clip { - node = super::rasterfx::clip_group(d, path, rule, node); - } + let node = image.into_node(d); let id = d.wrap(&roots, |_| node)?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index fc3170464..f0b02eb20 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -6,11 +6,13 @@ //! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the //! effect: the image (with the object's own area knocked out) goes under the untouched vector //! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it -//! with the image. Expand Appearance makes its images with the same helpers. +//! with the image. An object whose only raster effect is a Rasterize of its own is replaced with +//! the image Object › Rasterize makes with that effect's options. Expand Appearance makes its +//! images with the same helpers. //! //! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style -//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those -//! into images the same way ([`flatten_pixel_effects`]). +//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying +//! one of those into images the same way ([`flatten_pixel_effects`]). use std::sync::Arc; @@ -273,11 +275,15 @@ fn below_images(d: &mut Document, n: &Node) -> Option> { } /// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d` -/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays -/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds -/// just them; with `members` too, a group's or layer's image leaves out its members' raster -/// effects (they get images of their own). +/// rendered at the document's raster effects resolution, or at a Rasterize effect's finer one (an +/// object whose only raster effect is its own Rasterize gets [`rasterize_effect_image`]'s image), +/// without `n`'s transparency (that stays on the object). With `below` (they all paint below it: +/// shadows and outer glows) the image holds just them; with `members` too, a group's or layer's +/// image leaves out its members' raster effects (they get images of their own). pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { + if !below && let Some(image) = rasterize_effect_image(d, n) { + return Some(image); + } let mut whole = n.clone(); (whole.opacity, whole.blend) = (1.0, Default::default()); whole.extras_mut().mask = None; @@ -291,10 +297,37 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo strip_raster(&mut b, false); b }); - let scale = effects_scale(d); + // A Rasterize effect at a finer resolution than the document's is rendered at its own. + let scale = raster_fx(n) + .iter() + .filter_map(|fx| if let RasterFx::Rasterize(r) = fx { r.ppi } else { None }) + .fold(effects_scale(d), |s, ppi| s.max(ppi / 72.0)); effect_image(d, &whole, bare.as_ref(), scale) } +/// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when +/// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n` +/// without that effect and without its transparency, which stays on the object. `None` otherwise, +/// or when the image can't be made. +fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { + let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None }; + let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?; + let p = json!({ + "ppi": r.ppi.unwrap_or(d.raster_effects_ppi), + "colorModel": r.color_model.id(), + "background": if r.white { "white" } else { "transparent" }, + "antiAlias": if !r.anti_alias { "none" } else if r.hinted { "type" } else { "art" }, + "clippingMask": r.clipping_mask, + "addAround": r.add_around, + }); + let mut whole = n.clone(); + whole.appearance.effects.remove(at); + (whole.opacity, whole.blend) = (1.0, Default::default()); + whole.extras_mut().mask = None; + let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?; + Some(image.into_node(d)) +} + /// `v` (an object without its raster effects) replaced by `image` of the object with them: the /// image takes its place with its id, name and transparency (a layer keeps the image as its only /// member). @@ -393,7 +426,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { enum Which { /// Every raster effect (PDF). All, - /// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects). + /// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their + /// raster effects). Pixel, } @@ -402,7 +436,7 @@ fn imaged(n: &Node, which: Which) -> bool { let fx = raster_fx(n); match which { Which::All => !fx.is_empty(), - Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))), + Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))), } } @@ -499,8 +533,8 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { } /// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp -/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the -/// other raster effects live), or `None` when there are none. +/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its +/// filters draw the other raster effects live), or `None` when there are none. pub fn flatten_pixel_effects(doc: &Document) -> Option { flatten(doc, &mut FlattenReport::new(Which::Pixel, doc)) } diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index 577c801cd..428142e2b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -1,5 +1,6 @@ //! Effect → Document Raster Effects Settings (`document.rasterEffectsSettings`): the settings, the -//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults. +//! raster effect images they make (PDF export, Expand Appearance), the Rasterize defaults, and +//! Effect › Rasterize's image. use serde_json::{Value, json}; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; @@ -144,6 +145,156 @@ fn rasterize_takes_the_document_settings_as_defaults() { assert!(s.execute("object.rasterize", &json!({"colorModel": "lab"})).is_err()); } +/// An image Object › Rasterize or Expand Appearance made, `n` or the image in its clip group: +/// (width, height, where its pixels lie, its PNG, the clipping path's geometry). +fn rasterized(d: &Document, n: &vectorcraft_doc::Node) -> (u32, u32, vectorcraft_geom::Affine, Vec, Option) { + let (image, clip) = match &n.kind { + NodeKind::Group { children, clip: true } => (&*children[1], children[0].path_data().cloned()), + _ => (n, None), + }; + let NodeKind::Image(im) = &image.kind else { panic!("an image: {:?}", image.kind_label()) }; + (im.width, im.height, im.xf, d.images[&im.key].bytes.to_vec(), clip) +} + +/// A rectangle of 40 × 30 pt with its default stroke and a red fill, selected. +fn red_rectangle(s: &mut Session) -> NodeId { + let r = id(&s.execute("shape.rectangle", &json!({"x": 50.5, "y": 50.25, "width": 40, "height": 30})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + r +} + +#[test] +fn the_rasterize_effect_keeps_the_path_and_expands_to_the_image_object_rasterize_makes() { + let cases = [ + ( + json!({"useDocumentResolution": false, "resolution": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}), + json!({"ppi": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}), + ), + // At the document's resolution (300 ppi here), clipped to the art's outline. + ( + json!({"clippingMask": true, "antiAlias": "type"}), + json!({"ppi": 300, "clippingMask": true, "antiAlias": "type", "background": "transparent"}), + ), + ]; + for (effect, object) in cases { + let mut s = session(); + s.execute(C, &json!({"resolution": "high"})).unwrap(); + let r = red_rectangle(&mut s); + let before = s.doc().unwrap().doc.node(r).unwrap().path_data().cloned(); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": effect, "ids": [r.0]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let n = d.node(r).unwrap(); + assert_eq!((n.path_data().cloned(), n.appearance.effects[0].id.as_str()), (before, "rasterize"), "the path stays as it was"); + // Saved and read back. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(back.node(r).unwrap().appearance.effects, n.appearance.effects); + // Object › Rasterize of the same rectangle. + let mut o = session(); + o.execute(C, &json!({"resolution": "high"})).unwrap(); + red_rectangle(&mut o); + let made = id(&o.execute("object.rasterize", &object).unwrap()); + let od = &o.doc().unwrap().doc; + let want = rasterized(od, od.node(made).unwrap()); + // PDF export draws that image in its place. + let flat = crate::flatten_raster_effects(&d).unwrap(); + assert_eq!(rasterized(&flat, &flat.layers[0].children().unwrap()[0]), want, "{effect}"); + // So does Expand Appearance. + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(rasterized(d, d.node(r).unwrap()), want, "{effect}"); + } +} + +#[test] +fn the_rasterize_effect_follows_edits_to_the_path() { + let mut s = session(); + let r = red_rectangle(&mut s); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap(); + let width = |s: &Session| { + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + rasterized(&flat, &flat.layers[0].children().unwrap()[0]).0 + }; + let before = width(&s); + // Direct Selection moves the right-hand anchors 20 pt: the image widens with the path. + s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 1], [0, 2]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 20, "dy": 0})).unwrap(); + let n = s.doc().unwrap().doc.node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Path { .. }) && n.appearance.effects.len() == 1); + assert_eq!(width(&s), before + 20, "72 ppi: a pixel a point"); +} + +#[test] +fn rasterize_measures_the_art_its_geometry_effects_draw() { + let mut s = session(); + let r = red_rectangle(&mut s); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + let (width, _, xf, ..) = rasterized(d, d.node(r).unwrap()); + // The rectangle as the Transform effect draws it: 100 pt to the right, 41 pt wide with its stroke. + assert_eq!((xf.translation().x, width), (150.0, 41)); +} + +#[test] +fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() { + let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind { + NodeKind::Group { children, clip: true } => children[0].geometric_bounds(), + _ => None, + }; + let moved = |s: &mut Session, n: NodeId| { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [n.0]})).unwrap(); + }; + // Expand Appearance clips the image to the rectangle drawn 100 pt to the right, from 150.5 to + // 190.5 pt. + let mut s = session(); + let r = red_rectangle(&mut s); + moved(&mut s, r); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"clippingMask": true}, "ids": [r.0]})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let b = clip(&s, r).expect("a clip group"); + assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}"); + // Object › Rasterize clips its image there too. + let mut s = session(); + let r = red_rectangle(&mut s); + moved(&mut s, r); + let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap()); + let b = clip(&s, made).expect("a clip group"); + assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}"); + // Type's image is clipped to its glyphs, 100 pt right of where they are without the effect. + let glyphs = |effect: bool| { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 50, "y": 100, "text": "H", "size": 40})).unwrap()); + if effect { + moved(&mut s, t); + } + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap()); + clip(&s, made).expect("a clip group") + }; + let (plain, shifted) = (glyphs(false), glyphs(true)); + assert!((shifted.x0 - plain.x0 - 100.0).abs() < 1e-6 && (shifted.x1 - plain.x1 - 100.0).abs() < 1e-6, "{plain:?} {shifted:?}"); +} + +#[test] +fn object_rasterize_takes_in_everything_an_effect_stack_paints() { + let mut s = session(); + let r = red_rectangle(&mut s); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 4}, "ids": [r.0]})).unwrap(); + let live = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 10}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": live, "ids": [r.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let made = id(&s.execute("object.rasterize", &json!({"ppi": 72, "background": "transparent"})).unwrap()); + let d = &s.doc().unwrap().doc; + let (width, height, xf, png, _) = rasterized(d, d.node(made).unwrap()); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + // 41 pt of rectangle and stroke, and 6 pt of blur and 10 pt of white on each side: white from + // the first column to the last. + assert_eq!((width, xf.translation().x), (73, 34.0)); + assert_eq!((img.get_pixel(0, height / 2).0, img.get_pixel(width - 1, height / 2).0), ([255; 4], [255; 4])); +} + #[test] fn raster_settings_round_trip_and_old_files_load() { let mut d = Document::new(10.0, 10.0); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 23352a280..01075f5ae 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -17,6 +17,12 @@ //! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back //! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't //! change. +//! - Rasterize: the content drawn again on the image's own grid, as Object › Rasterize draws it +//! ([`Renderer::rasterize_image`]), then into the content's raster +//! ([`effects::Rasterize::compose`]; an image too large for that is made from the content's +//! raster, [`effects::Rasterize::apply`]), clipped to its outline with Create Clipping Mask. The +//! effects before it in the stack, and the shadows and glows after it, are drawn around it by +//! splitting the stack there ([`Renderer::raster_fx`]). //! //! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its //! cached shadows, glows and filtered content at its new place instead of running the effects @@ -108,6 +114,41 @@ struct Content<'a> { /// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and /// Inner Glow is skipped). outline: Option<(&'a BezPath, FillRule)>, + /// What Rasterize makes its image over: the object's [`drawn_bounds`] as far as the effects + /// before it spread them (`None`: `reach`). + bounds: Option, + /// Where the image of the Rasterize first in these effects is kept for the effects after a + /// split ([`Renderer::raster_fx`]), which draw this content more than once. + kept: Option<&'a Kept>, +} + +/// The image of the last Rasterize before a split in an effect stack ([`Renderer::raster_fx`]). It +/// is made the first time the effects after the split draw the content and drawn from here the +/// other times, so the content before the split is drawn once. +struct Kept { + /// Where the effects after the split take the content from: the visible area grown by how + /// far they paint. + window: Rect, + /// The view and pixel size of the split's frame, when the image is over + /// [`Renderer::rasterize_image`]'s limits: the content is then drawn in that frame. + frame: Option<(Affine, f64)>, + image: std::cell::RefCell>, +} + +#[derive(Clone)] +enum KeptImage { + /// The image on its own grid, as [`Renderer::rasterize_image`] makes it. + Grid(std::rc::Rc<(Vec, usize, usize)>), + /// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the + /// Rasterize applied, as the first draw made it, and the transform from its pixels to + /// document space. + Raster(Arc, Affine), +} + +/// The [`Content::bounds`] of `n` under raster effects `rfx`: its [`drawn_bounds`] when `rfx` +/// holds a Rasterize. +fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option { + rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| drawn_bounds(symbols, n)).flatten() } /// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). @@ -155,7 +196,7 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] { pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { return cull_bounds(symbols, &art).map(|b| { - let o = effects::outset(&n.appearance.effects, b); + let o = outset(symbols, &n.appearance.effects, b); b.inflate(o, o) }); } @@ -176,11 +217,173 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { let s = n.appearance.outset(); r.map(|r| { let r = r.inflate(s, s); - let o = effects::outset(&n.appearance.effects, r); + // The fills' and strokes' own raster effects, then the object's. + let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b)); + let o = outset(symbols, &n.appearance.effects, r); r.inflate(o, o) }) } +/// Does `item` paint (as [`Renderer::paint_items`] draws it)? +fn paints(item: &AppearanceItem) -> bool { + item.visible() && !item.paint().is_none() +} + +/// The bounds of what `n` draws, its own and its members' geometry effects applied and the raster +/// effects of its members and of its fills and strokes included (each fill's and stroke's +/// spreading what it paints, [`item_bounds`]), with strokes measured as +/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects +/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over. +pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = art_bounds(symbols, n)?; + let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty()); + Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r))) +} + +/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path +/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects, +/// 3D art, a brushed stroke and an item that draws no path. +fn item_bounds(n: &Node, item: &AppearanceItem) -> Option { + let stroke = matches!(item, AppearanceItem::Stroke(_)); + if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) { + return None; + } + let g = effected_path(n, &node_bezpath(n)?); + let fx = item_effects(item); + if effects::has_geometry(fx) { + let ig = apply(fx, &g, &GeomContext::of(n).item(item)); + let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o)); + } + let one = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() }; + (!g.elements().is_empty()).then(|| one.stroked_bounds(g.bounding_box(), || g.clone())) +} + +/// `b` grown by how far raster effects `fx` paint past it, each spreading what the ones before it +/// paint (Rasterize at the document's raster effects resolution where it takes it). +fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect { + let ppi = doc_raster_ppi(symbols.document()); + effects::raster_effects(fx).iter().fold(b, |r, e| e.at_document_ppi(ppi).spread(r, 72.0 / ppi)) +} + +/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of +/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). +fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = shape_bounds(symbols, n); + let brushed = vectorcraft_brush::has_brush(n) + .then(|| vectorcraft_brush::expand(symbols.document(), n)) + .flatten() + .and_then(|art| art.visual_bounds_with(symbols)); + let b = vectorcraft_geom::union_opt(b, brushed)?; + let pad = if matches!(n.kind, NodeKind::SymbolInstance { .. }) { crate::brush_fx::pad(symbols, n) } else { 0.0 }; + Some(b.inflate(pad, pad)) +} + +/// [`art_bounds`] without brush art. +fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + // The visible members of `g` (a Shaper group's first member is its source art, which paints + // nothing). + let union = |g: &Node| { + let members = g.children()?.get(usize::from(g.extras().shaper.is_some())..).unwrap_or_default(); + members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) + }; + match &n.kind { + _ if effects::has_container_appearance(n) => { + let art = effects::evaluate_container(n); + let art = art.as_ref().unwrap_or(n); + if art.clips() { art.visual_bounds_with(symbols) } else { union(art) } + } + NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n), + _ if has_fx(n) => { + // A 3D effect's art. + if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { + return drawn_bounds(symbols, &art); + } + let g = effected_path(n, &node_bezpath(n)?); + // The path as the fills and strokes without geometry effects of their own paint it + // (as every fill and stroke would when none paints). + let mut plain = n.appearance.clone(); + plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i)))); + let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints); + let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone())); + let ctx = GeomContext::of(n); + for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) { + let ig = apply(item_effects(item), &g, &ctx.item(item)); + let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + r = vectorcraft_geom::union_opt(r, (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o))); + } + r + } + // As [`cull_bounds_in`] measures them, without the object's own raster effects: the + // reshaped art (type's outlines measured one by one with the effects of its own fills and + // strokes), grown by how far the effects in a symbol's art and a live object's content + // paint past it. + _ if has_object_fx(n) => { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + _ => None, + }; + let reshaped = effects::reshape(n, symbol.as_deref()); + let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::Text(_)) => { + r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) + } + Some(r) => r.visual_bounds_with(symbols), + None => None, + } + .or_else(|| n.visual_bounds_with(symbols))?; + let a = match &reshaped { + Some(r) => reshaped_art_effects_reach(symbols, n, r), + None => art_effects_reach(symbols, n), + }; + let a = a.max(live_content_reach(symbols, n)); + Some(b.inflate(a, a)) + } + _ => { + let b = n.visual_bounds_with(symbols)?; + let a = art_effects_reach(symbols, n).max(live_content_reach(symbols, n)); + Some(b.inflate(a, a)) + } + } +} + +/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop +/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster +/// effects inside the art. +pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { + if let Some(art) = effects::crop_marks_art(n) { + return drawn_bounds(symbols, &art); + } + drawn_bounds(symbols, n).map(|b| spread(symbols, &n.appearance.effects, b)) +} + +/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping +/// Mask to: a path's or compound path's geometry with its object-level geometry effects applied, +/// the outline of the art [`Renderer::fx_art`] makes for an object with object effects, and with +/// Crop Marks the object's own. For other objects it is [`effects::clip_outline`]. +pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { + if let Some(art) = effects::crop_marks_art(n) { + return drawn_outline(doc, art.children()?.first()?); + } + if has_object_fx(n) { + let art = Renderer::new().fx_art(doc, &Arc::new(n.clone()), false); + return effects::clip_outline(&art); + } + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => { + Some((effected_path(n, &node_bezpath(n)?), *rule)) + } + _ => effects::clip_outline(n), + } +} + +/// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint +/// ([`spread`]). +fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { + let s = spread(symbols, fx, b); + [b.x0 - s.x0, b.y0 - s.y0, s.x1 - b.x1, s.y1 - b.y1].into_iter().fold(0.0, f64::max) +} + /// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in /// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are /// measured by their art from `symbols`. @@ -207,7 +410,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) }?; - let o = effects::outset(&n.appearance.effects, b); + let o = outset(symbols, &n.appearance.effects, b); Some(b.inflate(o, o)) } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { @@ -237,7 +440,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio }; let a = a.max(live_content_reach(symbols, n)); let b = b.inflate(a, a); - let o = effects::outset(&n.appearance.effects, b); + let o = outset(symbols, &n.appearance.effects, b); Some(b.inflate(o, o)) } NodeKind::SymbolInstance { .. } => { @@ -349,10 +552,43 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { /// cropped to the view when its effects only look nearby, else rendered at a lower resolution. const PIXEL_BUDGET: f64 = 8.4e6; +/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each +/// draws the content again inside the one after it. +const MAX_RASTERIZE: usize = 16; + +/// The columns and rows of the image Rasterize `r` makes over `rect` (whole pixels of the image, +/// `line` points wide at the document's raster effects resolution). `None` when the image would +/// hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than [`crate::MAX_RASTER_SIDE`]. +fn image_size(r: &effects::Rasterize, rect: Rect, line: f64) -> Option<(u16, u16)> { + let cell = r.cell(line); + let (cols, rows) = ((rect.width() / cell).round(), (rect.height() / cell).round()); + let side = f64::from(crate::MAX_RASTER_SIDE); + (cols >= 1.0 && rows >= 1.0 && cols <= side && rows <= side && cols * rows <= PIXEL_BUDGET).then_some((cols as u16, rows as u16)) +} + +#[cfg(test)] +thread_local! { + /// How many times a Rasterize has drawn the content before it on this thread. + pub(crate) static RASTERIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a +/// number). +fn doc_raster_ppi(doc: &Document) -> f64 { + Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) +} + /// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the /// content itself), in stack order. fn content_changing(fx: &&RasterFx) -> bool { - matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_)) + matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_) | RasterFx::Rasterize(_)) +} + +/// Is the content drawn with hard edges for `chain` into a raster of pixels `px` wide (document +/// units): does it go first through a Rasterize with anti-aliasing off whose pixels are no +/// smaller? (Smaller ones show as the share of each raster pixel they cover, as smooth edges do.) +fn hard_edged(chain: &[&RasterFx], px: f64) -> bool { + matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px) } /// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when @@ -361,6 +597,7 @@ fn looks_around(fx: &RasterFx) -> Option { match fx { RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius), RasterFx::Pixel(p) => p.reach(), + RasterFx::Rasterize(r) => Some(r.reach()), _ => Some(0.0), } } @@ -436,6 +673,23 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc ctx.set_transform(Affine::IDENTITY); } +/// Draw `image` with its pixels placed by `xf` (pixels to device pixels). On whole pixels at full +/// size it is drawn as [`draw_scaled`] draws it. +fn draw_placed(ctx: &mut RenderContext, image: std::sync::Arc, xf: Affine) { + let [a, b, c, d, e, f] = xf.as_coeffs(); + let near = |v: f64, to: f64| (v - to).abs() < 1e-6; + if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { + return draw_scaled(ctx, image, (e.round(), f.round()), 1.0); + } + let (w, h) = (image.width() as f64, image.height() as f64); + ctx.set_transform(xf); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }; + ctx.reset_paint_transform(); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} + /// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to /// the view-space position of `anchor` (a document point). pub(crate) struct ShadowEntry { @@ -933,7 +1187,10 @@ impl Renderer { // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); - let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)) }; + let bounds = rasterize_bounds(f.symbols, n, &rfx); + // Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint. + let reach = bounds.map_or(reach, |b| reach.union(b)); + let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; let mut items = items; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); @@ -967,12 +1224,16 @@ impl Renderer { let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; match cull_bounds(f.symbols, &art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { - // Feather and Inner Glow clip to the art's outline. - let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. })); + // Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline. + let clipped = rfx.iter().any(|x| { + matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask) + }); let region = if clipped { self.clip_of(&art) } else { None }; // A symbol's art can change while the instance doesn't: no cached shadows. let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); - let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; + let bounds = rasterize_bounds(f.symbols, a, &rfx); + let reach = bounds.map_or(reach, |b| reach.union(b)); + let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); ctx.set_transform(Affine::IDENTITY); } @@ -1087,6 +1348,102 @@ impl Renderer { rfx: &[RasterFx], paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { + // Only the last MAX_RASTERIZE Rasterize effects are drawn. + let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count(); + let capped: Vec; + let rfx = if count > MAX_RASTERIZE { + let mut left_out = count - MAX_RASTERIZE; + capped = rfx + .iter() + .filter(|fx| { + let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_)); + left_out -= usize::from(out); + !out + }) + .cloned() + .collect(); + &capped[..] + } else { + rfx + }; + // Rasterize at the document's raster effects resolution takes it here. + let resolved: Vec; + let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) { + let ppi = doc_raster_ppi(f.doc); + resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect(); + &resolved[..] + } else { + rfx + }; + // Rasterize makes an image of everything the effects before it paint, shadows and glows + // included, and the shadows and glows after it paint around that image. When some come + // after the last Rasterize, the stack splits after it, else when any effect comes before + // it, the stack splits before it; the effects after the split paint around the content + // drawn with those before it. Rasterize then comes first in its part. + if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) { + let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx)); + let (_, rest) = rfx.split_at(at + 1); + let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) }; + if let Some(split) = split { + let (before, after) = rfx.split_at(split); + let line = 72.0 / doc_raster_ppi(f.doc); + let (mut reach, mut spread) = (content.reach, content.bounds); + // After a Rasterize on white without a clipping mask, the silhouette is its image, + // and it stays the silhouette through the effects after it (as in `pixel_content`). + let mut image = None; + // Whether the image of a Rasterize before the split is over `rasterize_image`'s + // limits. Such an image is kept only as far as the view needs it, so the effects + // after the split are not cached. + let (mut over, mut last_over) = (false, false); + for fx in before { + if let RasterFx::Rasterize(r) = fx { + last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none(); + over |= last_over; + } + let o = fx.outset(reach); + reach = reach.inflate(o, o); + spread = spread.map(|b| fx.spread(b, line)); + if let RasterFx::Rasterize(r) = fx + && r.white + && !r.clipping_mask + { + image = Some(spread.unwrap_or(reach).to_path(0.1)); + } + } + // When the split comes after the Rasterize, the shadows and glows after it and the + // content each draw the content before the split. The first draw makes the image, + // and the others draw the one it keeps. + let window = after.iter().fold(f.visible, |w, fx| { + let o = fx.outset(w); + w.inflate(o, o) + }); + let frame = last_over.then_some((f.view, f.px)); + let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) }); + let inner = Content { + node: content.node, + owner: content.owner, + cache: None, + reach: content.reach, + outline: content.outline, + bounds: content.bounds, + kept: kept.as_ref(), + }; + let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); + // When the split comes before the Rasterize, the part from it on draws the content + // once, and its image is the one kept for a split above. + let outer = Content { + node: content.node, + owner: content.owner, + cache: content.cache.filter(|_| !over), + reach, + outline, + bounds: spread, + kept: content.kept.filter(|_| split == at), + }; + self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint)); + return; + } + } let reach = content.reach; // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); @@ -1128,7 +1485,7 @@ impl Renderer { // works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel // and looked blocky as the view moved by parts of a pixel (#755). let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0)); - if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) { + if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) { self.pixel_content(ctx, f, content, rfx, paint); } else { paint(self, ctx, f); @@ -1170,11 +1527,15 @@ impl Renderer { } } - /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the - /// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can - /// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels: - /// larger content is cropped to the view when every effect only looks nearby, else rendered - /// at a lower resolution. A whole raster at full resolution is cached while the object, the + /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the + /// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over + /// all it can reach, filtered on the CPU and drawn back. A Rasterize first in the chain draws the + /// content on its image's own grid instead ([`Self::rasterize_image`]) when that image is within + /// the limits below; content with a [`Kept`] image draws that image after its first draw. The + /// raster holds at most [`PIXEL_BUDGET`] pixels and [`crate::MAX_RASTER_SIDE`] a side: content + /// over the budget is cropped to the view (content with a [`Kept`] image to its window) when + /// every effect only looks nearby, and content still over either limit is rendered at a lower + /// resolution. A whole raster at full resolution is cached while the object, the /// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and /// while interacting after the content only moved ([`Self::move_pixel_content`]). fn pixel_content( @@ -1186,26 +1547,42 @@ impl Renderer { paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect(); + // When the first draw of this content kept its raster, that raster is drawn. + if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) { + draw_placed(ctx, image, f.view * place); + return; + } + // Content kept with an image over the limits is drawn in the split's frame, over where the + // effects after the split take it from, then placed in this one. + let call = f.view; + let split_frame; + let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) { + Some((window, (view, px))) => { + split_frame = Frame { view, px, ..*f }; + (&split_frame, view.transform_rect_bbox(window)) + } + None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)), + }; // Where the result can paint: each effect spreads what the ones before it painted. let reach = chain.iter().fold(content.reach, |r, fx| { let o = fx.outset(r); r.inflate(o, o) }); let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0); - let (sw, sh) = (ctx.width() as f64, ctx.height() as f64); - if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) { + if !(full.area().is_finite() && full.x1 > screen.x0 && full.y1 > screen.y0 && full.x0 < screen.x1 && full.y0 < screen.y1) { return; } let fits = full.area() <= PIXEL_BUDGET; // Device pixels from a pixel to the content deciding it (`None`: anywhere). let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px)); let region = match margin { - Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)), + Some(m) if !fits => full.intersect(screen.inflate(m, m)), _ => full, }; let (x0, y0) = (region.x0.floor(), region.y0.floor()); let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0)); - let side = u16::MAX as f64; + // (vello_cpu panics drawing near `u16::MAX` pixels a side.) + let side = f64::from(crate::MAX_RASTER_SIDE); let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side)); // The cached raster, when the whole content was rendered at full size at this zoom. @@ -1219,12 +1596,12 @@ impl Renderer { Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, }; // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). - let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); + let ppi = doc_raster_ppi(f.doc); // What the content reads (as the raster kept for it found it, else found now), with the - // channels the effects run on and, under De-Interlace, the resolution. + // channels the effects run on and, under De-Interlace or Rasterize, the resolution. let inputs = key.map(|key| { let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads); - let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))); + let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_))); self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; @@ -1249,14 +1626,44 @@ impl Renderer { let to_doc = view.inverse(); let px = f.px / k; let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; - let mut off = f.offscreen_context(wu, hu); + let line = 72.0 / ppi; let before = self.stats.moved; - self.inside_layer(|r| paint(r, &mut off, &frame)); + // A Rasterize first in the chain draws the content on its image's own grid instead, unless + // that image is too large: the content is drawn once. + let mut rasterized = match chain.first() { + Some(RasterFx::Rasterize(r)) => { + let rect = r.image(content.bounds.unwrap_or(content.reach), line); + let kept = content.kept.and_then(|k| match &*k.image.borrow() { + Some(KeptImage::Grid(image)) => Some(image.clone()), + _ => None, + }); + let image = kept.or_else(|| { + let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new); + if let (Some(k), Some(image)) = (content.kept, &image) { + *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone())); + } + image + }); + image.map(|image| (image, rect)) + } + _ => None, + }; + let mut pm = vello_cpu::Pixmap::new(wu, hu); + if rasterized.is_none() { + #[cfg(test)] + if matches!(chain.first(), Some(RasterFx::Rasterize(_))) { + RASTERIZED.with(|n| n.set(n.get() + 1)); + } + let mut off = f.offscreen_context(wu, hu); + if hard_edged(&chain, px) { + off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); + } + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + off.render(&mut pm, &mut self.resources); + } // Content drawn over moved rasters makes a moved raster. let moved = self.stats.moved > before; - off.flush(); - let mut pm = vello_cpu::Pixmap::new(wu, hu); - off.render(&mut pm, &mut self.resources); // What the raster is made from, for its object once it moves (`move_pixel_content`). let made = key.map(|_| Made { pixels: pm.data_as_u8_slice().to_vec(), @@ -1266,18 +1673,41 @@ impl Renderer { outline: feather_outline(&chain, content, content.reach.origin()), }); let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height())); - let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent }; + let space = effects::PixelSpace { to_doc, px, center, channels, line, extent }; let (wz, hz) = (wu as usize, hu as usize); - for fx in chain { + // What the content covers after the effects run so far: Rasterize makes its image over it. + let mut bounds = content.bounds.unwrap_or(content.reach); + // The silhouette Feather and Create Clipping Mask clip to: the content's, or after a Rasterize + // on white without a clipping mask, its image. + let mut image_outline: Option = None; + for (i, fx) in chain.into_iter().enumerate() { + let before = bounds; + bounds = fx.spread(before, space.line); + let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); match fx { RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px), RasterFx::Feather { radius } => { effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px); - if let Some((g, rule)) = content.outline { + if let Some((g, rule)) = outline { self.clip_pixels(&mut pm, &frame, g, rule); } } RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space), + RasterFx::Rasterize(r) => { + match rasterized.take().filter(|_| i == 0) { + Some((image, rect)) => { + let (pixels, cols, rows) = &*image; + r.compose(pm.data_as_u8_slice_mut(), wz, hz, &space, pixels, *cols, *rows, rect) + } + None => r.apply(pm.data_as_u8_slice_mut(), wz, hz, &space, before), + } + if let Some((g, rule)) = outline.filter(|_| r.clipping_mask) { + self.clip_pixels(&mut pm, &frame, g, rule); + } + if r.white && !r.clipping_mask { + image_outline = Some(bounds.to_path(0.1)); + } + } _ => {} } } @@ -1291,7 +1721,15 @@ impl Renderer { self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); } - draw_scaled(ctx, image, (x0, y0), k); + // An image over the limits was drawn at a frame's resolution: its raster is kept. + match content.kept.filter(|kept| kept.image.borrow().is_none()) { + Some(kept) => { + let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k); + *kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place)); + if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) } + } + None => draw_scaled(ctx, image, (x0, y0), k), + } } /// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame @@ -1301,7 +1739,7 @@ impl Renderer { /// (and the same Feather outline against its anchor), and whose pixels are the pixels of /// `content` drawn into a raster placed as that one was against its anchor. The raster is kept /// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under - /// De-Interlace takes none. + /// De-Interlace, or under a Rasterize first in the chain, takes none. #[allow(clippy::too_many_arguments)] fn move_pixel_content( &mut self, @@ -1313,7 +1751,8 @@ impl Renderer { inputs: &Inputs, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { - if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) { + if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) || matches!(chain.first(), Some(RasterFx::Rasterize(_))) + { return; } let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin()); @@ -1337,6 +1776,9 @@ impl Renderer { let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); let frame = Frame { mt: false, view, visible, ..*f }; let mut off = f.offscreen_context(w, h); + if hard_edged(chain, f.px) { + off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); + } self.inside_layer(|r| paint(r, &mut off, &frame)); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); @@ -1352,17 +1794,19 @@ impl Renderer { } /// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the - /// color settings, the edge smoothing, the knockout `n` inherits and, while `n` holds the - /// isolated group, isolation mode; the pattern definitions, symbols and brush library it - /// reads; for images and placed documents their pixels and files, the layer's Dim Images and - /// the options they are drawn with; for type the fonts, whether overprinting shows - /// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the - /// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or - /// pattern art and an object in the document's symbol or pattern art has one. + /// color settings, the edge smoothing, the document's raster effects resolution (which a + /// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and, + /// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and + /// brush library it reads; for images and placed documents their pixels and files, the + /// layer's Dim Images and the options they are drawn with; for type the fonts, whether + /// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution + /// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and + /// when it shows symbol or pattern art and an object in the document's symbol or pattern art + /// has one. pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs { let doc = self.doc_inputs(f.doc); let mut h = DefaultHasher::new(); - (doc.cms, f.opts.anti_alias.threshold(), self.knockout).hash(&mut h); + (doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h); if let Some((isolated, path)) = self.isolation_path(f) && n.id != isolated && path.contains(&n.id) @@ -1455,6 +1899,42 @@ impl Renderer { } } + /// The image Effect › Rasterize `r` makes of the content `paint` draws over `rect` (whole + /// pixels of the image): drawn on the image's own grid as Object › Rasterize draws it (with + /// hard edges when `r` doesn't anti-alias), then given `r`'s background and color model. `None` + /// when it would hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than + /// [`crate::MAX_RASTER_SIDE`]. → (pixels, columns, rows) + fn rasterize_image( + &mut self, + f: &Frame, + r: &effects::Rasterize, + rect: Rect, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) -> Option<(Vec, usize, usize)> { + let line = 72.0 / doc_raster_ppi(f.doc); + let (w, h) = image_size(r, rect, line)?; + #[cfg(test)] + RASTERIZED.with(|n| n.set(n.get() + 1)); + let cell = r.cell(line); + let view = Affine::scale(1.0 / cell) * Affine::translate(-rect.origin().to_vec2()); + // The effect's own anti-aliasing, whatever the view or the export draws with. + let anti_alias = match (r.anti_alias, r.hinted) { + (false, _) => crate::AntiAlias::None, + (true, true) => crate::AntiAlias::Type, + (true, false) => crate::AntiAlias::Art, + }; + let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; + let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; + let mut off = frame.offscreen_context(w, h); + self.inside_layer(|s| paint(s, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + let mut pixels = pm.data_as_u8_slice().to_vec(); + r.finish(&mut pixels); + Some((pixels, usize::from(w), usize::from(h))) + } + /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { let mut m = frame.offscreen_context(pm.width(), pm.height()); @@ -1553,8 +2033,17 @@ impl Renderer { let outset = n.appearance.outset(); let orule = if item.is_fill() { rule } else { FillRule::NonZero }; // Each item's shadows get their own cache slots. - let content = - Content { node: n, owner: None, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; + // Rasterize makes its image over the item's silhouette. + let bounds = rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| outline.bounding_box()); + let content = Content { + node: n, + owner: None, + cache: Some((j + 1) << 8), + reach: ib.inflate(outset, outset), + outline: Some((&outline, orule)), + bounds, + kept: None, + }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib)); ctx.set_transform(Affine::IDENTITY); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 556e91693..b2639af5a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -64,6 +64,19 @@ pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } } +/// Bounds of what `n` draws with its own and its members' geometry effects applied and with the +/// reach of its own and its members' raster effects, measured as [`Node::visual_bounds_with`] +/// measures art without effects: what Object › Rasterize makes its image over. +pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { + fx::painted_reach(&doc.symbol_boxes(), n) +} + +/// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with +/// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. +pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { + fx::drawn_outline(doc, n) +} + /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). pub const MAX_RASTER_SIDE: u32 = 32_768; @@ -2395,6 +2408,8 @@ mod tests_objectfx; #[cfg(test)] mod tests_placed_document; #[cfg(test)] +mod tests_rasterize; +#[cfg(test)] mod tests_setup; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/render/src/tests_rasterize.rs b/crates/render/src/tests_rasterize.rs new file mode 100644 index 000000000..d317cb507 --- /dev/null +++ b/crates/render/src/tests_rasterize.rs @@ -0,0 +1,423 @@ +//! Effect › Rasterize on the canvas: the art drawn as square pixels of the effect's resolution +//! over its bounds and the room around them, on white or on nothing, its paths left as they are. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Effect, Node}; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath, shapes}; + +use super::*; + +/// A red shape (`path`, over 10..30 pt) with the effects `fx` (id, params), in a document whose +/// raster effects resolution is `ppi`. +fn doc(path: PathData, fx: &[(&str, Value)], ppi: f64) -> Document { + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = ppi; + let l = d.layers[0].id; + let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + n.appearance.effects = fx.iter().map(|(id, params)| Effect { id: (*id).into(), params: params.clone(), visible: true }).collect(); + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// A right triangle over 10..30 pt whose long side runs diagonally. +fn triangle() -> PathData { + let corner = |x, y| Anchor::corner(Point::new(x, y)); + PathData::single(SubPath::new(vec![corner(10.0, 10.0), corner(30.0, 10.0), corner(10.0, 30.0)], true)) +} + +/// `d` drawn at `zoom` device pixels a point on a transparent canvas `size` pixels square. +fn draw(d: &Document, zoom: f64, size: u32) -> Rendered { + Renderer::new().render(d, size, size, Affine::scale(zoom), &RenderOptions { background: None, artboards: false, ..Default::default() }) +} + +fn pixel(r: &Rendered, x: usize, y: usize) -> [u8; 4] { + let i = (y * r.width as usize + x) * 4; + [r.pixels[i], r.pixels[i + 1], r.pixels[i + 2], r.pixels[i + 3]] +} + +/// Whether each of the `n` × `n` squares `side` device pixels wide from (`at`, `at`) is one color. +fn squares_are_flat(r: &Rendered, at: usize, side: usize, n: usize) -> bool { + (0..n * n).all(|k| { + let (x0, y0) = (at + (k % n) * side, at + (k / n) * side); + let first = pixel(r, x0, y0); + (0..side * side).all(|i| pixel(r, x0 + i % side, y0 + i / side) == first) + }) +} + +#[test] +fn rasterize_draws_the_art_in_pixels_of_its_resolution() { + // (params, the document's resolution, device pixels a point, device pixels an image pixel) + let cases = [ + (json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 10.0, 10), + (json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 2.0, 2), + (json!({"useDocumentResolution": false, "resolution": 300}), 72.0, 25.0, 6), + (json!({}), 150.0, 25.0, 12), + ]; + for (params, ppi, zoom, side) in cases { + let at = (10.0 * zoom) as usize; + // The whole image pixels inside the 20 pt triangle's bounds. + let n = (20.0 * zoom) as usize / side; + let canvas = (40.0 * zoom) as u32; + let rasterized = draw(&doc(triangle(), &[("rasterize", params.clone())], ppi), zoom, canvas); + assert!(squares_are_flat(&rasterized, at, side, n), "{params}"); + // Along the diagonal an image pixel takes the mean of the art it covers. + let partly = (0..n).map(|k| pixel(&rasterized, at + k * side, at + (n - 1 - k) * side)[3]).filter(|a| *a > 0 && *a < 255).count(); + assert!(partly > n / 2, "{params}: {partly} of {n}"); + // Without the effect the diagonal edge runs through those squares. + assert!(!squares_are_flat(&draw(&doc(triangle(), &[], ppi), zoom, canvas), at, side, n)); + } +} + +#[test] +fn rasterize_is_drawn_where_only_its_image_reaches_the_view() { + // At 1 ppi a pixel of the image is 72 pt wide: the 20 pt square's image runs to 82 pt. + let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 1.0); + // A view of 40..80 pt across, where only the image's white reaches. + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&d, 40, 40, Affine::translate((-40.0, 0.0)), &opts); + assert_eq!(pixel(&r, 20, 20)[3], 255); + // A blur of 4 pt (6 pt of spread) and 10 pt of white around it: the white reaches 46 pt. + let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"background": "white", "addAround": 10}))]; + let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0); + let r = Renderer::new().render(&d, 40, 40, Affine::translate((-45.0, 0.0)), &opts); + assert_eq!(pixel(&r, 0, 20), [255; 4]); + // A fill's own Rasterize with 100 pt of white around: the white reaches 130 pt. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + n.appearance.items[0].effects_mut().push(Effect { + id: "rasterize".into(), + params: json!({"background": "white", "addAround": 100}), + visible: true, + }); + let r = Renderer::new().render(&d, 40, 40, Affine::translate((-115.0, 0.0)), &opts); + assert_eq!(pixel(&r, 5, 20), [255; 4]); +} + +#[test] +fn art_that_paints_nothing_takes_no_room_in_the_image() { + // A second fill, hidden, under a Gaussian Blur that would spread 15 pt. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + let mut hidden = n.appearance.items[0].clone(); + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden { + f.visible = false; + f.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 10}), visible: true }); + } + n.appearance.items.push(hidden.clone()); + // A third, hidden too, offset 5 pt outward. + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden { + f.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 5}), visible: true }]; + } + n.appearance.items.push(hidden); + let r = draw(&d, 10.0, 400); + assert_eq!((pixel(&r, 105, 105), pixel(&r, 95, 200)[3]), ([255, 0, 0, 255], 0)); + // Nor does a hidden member of a group. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0); + let mut away = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), Appearance::default()); + away.visible = false; + let l = d.layers[0].id; + let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + members.push(Arc::new(away)); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0); + // Nor does a Shaper group's source art, its first member, which paints nothing. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0); + let none = Appearance::basic(Paint::None, Paint::None, 0.0); + let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), none); + let l = d.layers[0].id; + let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + members.insert(0, Arc::new(source)); + let mut group = Node::group(d.alloc_id(), members); + group.extras_mut().shaper = Some(Box::new(vectorcraft_doc::shaper::ShaperSpec { faces: vec![], edges: vec![] })); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0); + // Nor does the path where its only fill's own Transform effect moves the fill away from it. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 40}), visible: true }); + } + let r = draw(&d, 10.0, 800); + assert_eq!((pixel(&r, 600, 200), pixel(&r, 200, 200)[3]), ([255, 0, 0, 255], 0)); +} + +#[test] +fn rasterize_covers_the_share_of_a_screen_pixel_its_image_covers() { + // A 1 pt square from x = 10.25 at 72 ppi and 100%: its image pixel covers a quarter of + // screen column 11, on white and on nothing. + let square = || shapes::rectangle(Rect::new(10.25, 10.0, 11.25, 11.0)); + for (params, want) in [(json!({}), [64, 0, 0, 64]), (json!({"background": "white"}), [64, 0, 0, 64])] { + let r = draw(&doc(square(), &[("rasterize", params.clone())], 72.0), 1.0, 20); + assert_eq!(pixel(&r, 11, 10), want, "{params}"); + assert_eq!(pixel(&r, 12, 10)[3], 0, "{params}"); + } +} + +#[test] +fn objects_wider_than_a_raster_can_be_are_drawn_with_their_pixel_effects() { + // 65,530 pt across at 100%: wider than the rasters the effects are made in can safely be. + let bar = || shapes::rectangle(Rect::new(0.0, 10.0, 65_530.0, 11.0)); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + for fx in [("rasterize", json!({"background": "white"})), ("blur.gaussian", json!({"radius": 1}))] { + let r = Renderer::new().render(&doc(bar(), std::slice::from_ref(&fx), 72.0), 40, 20, Affine::translate((-65_500.0, 0.0)), &opts); + assert!(pixel(&r, 10, 10)[3] > 0, "{}", fx.0); + } +} + +#[test] +fn a_stack_of_ten_thousand_rasterize_effects_is_drawn() { + let fx = vec![("rasterize", json!({"background": "white"})); 10_000]; + let r = draw(&doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0), 1.0, 40); + assert_eq!(pixel(&r, 20, 20), [255, 0, 0, 255]); +} + +#[test] +fn a_stack_of_rasterize_effects_with_shadows_draws_the_content_of_each_once() { + let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})); + let glow = ("stylize.outerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#00ff00"})); + // Five Rasterize effects, each followed by a drop shadow and an outer glow: on a 20 pt square + // at 72 ppi, and on a 300 pt square at 2,400 ppi, whose images are too large for the canvas to + // draw on their own grid. + for (ppi, side, zoom) in [(72.0, 20.0, 1.0), (2400.0, 300.0, 0.1)] { + let rasterize = ("rasterize", json!({"useDocumentResolution": false, "resolution": ppi})); + let fx: Vec<_> = (0..5).flat_map(|_| [rasterize.clone(), shadow.clone(), glow.clone()]).collect(); + let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side)), &fx, 72.0); + let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get); + let before = drawn(); + let r = draw(&d, zoom, 40); + assert_eq!(drawn() - before, 5, "{ppi} ppi"); + assert_eq!(pixel(&r, 15, 15), [255, 0, 0, 255], "{ppi} ppi"); + } +} + +#[test] +fn a_new_document_resolution_redraws_a_rasterized_member_of_a_blurred_group() { + let mut d = doc(triangle(), &[("rasterize", json!({}))], 72.0); + let l = d.layers[0].id; + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 0.5}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 400, 400, Affine::scale(10.0), &opts); + d.raster_effects_ppi = 18.0; + let again = r.render(&d, 400, 400, Affine::scale(10.0), &opts); + assert_eq!(again.pixels, Renderer::new().render(&d, 400, 400, Affine::scale(10.0), &opts).pixels); +} + +#[test] +fn an_export_draws_the_image_with_the_effects_own_anti_aliasing() { + // A supersampled Art Optimized export draws its art with hard edges and then averages them; + // the effect's image keeps its smooth diagonal. + let d = doc(triangle(), &[("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))], 72.0); + let opts = RenderOptions { background: None, artboards: false, anti_alias: AntiAlias::Art, supersample: true, ..Default::default() }; + let r = Renderer::new().render_region_with(&d, Rect::new(0.0, 0.0, 40.0, 40.0), 10.0, &opts); + let partly = (0..20).map(|k| pixel(&r, 105 + k * 10, 105 + (19 - k) * 10)[3]).filter(|a| *a > 0 && *a < 255).count(); + assert!(partly > 10, "{partly} of 20"); +} + +#[test] +fn rasterize_without_anti_aliasing_has_hard_edges_at_any_zoom() { + let fx = [("rasterize", json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}))]; + for zoom in [10.0, 1.0] { + let r = draw(&doc(triangle(), &fx, 72.0), zoom, (40.0 * zoom) as u32); + let alphas: Vec = r.pixels.chunks(4).map(|p| p[3]).collect(); + assert!(alphas.iter().all(|a| *a == 0 || *a == 255), "zoom {zoom}"); + assert!(alphas.contains(&255)); + } + // A bar 0.6 pt wide covers the center of the image pixel it starts, which it then fills, and + // which at 50% covers half a screen pixel: it shows there, partly covering it. + let bar = || shapes::rectangle(Rect::new(10.3, 10.0, 10.9, 30.0)); + assert_eq!(pixel(&draw(&doc(bar(), &fx, 72.0), 10.0, 400), 105, 200)[3], 255); + let half = pixel(&draw(&doc(bar(), &fx, 72.0), 0.5, 20), 5, 10)[3]; + assert!(half > 0 && half < 255, "{half}"); + // At 100% that image pixel, 10.3..11.3 pt, covers 70% of screen column 10 and 30% of column 11. + let full = draw(&doc(bar(), &fx, 72.0), 1.0, 40); + assert_eq!((pixel(&full, 10, 20)[3], pixel(&full, 11, 20)[3]), (178, 77)); +} + +#[test] +fn rasterize_keeps_what_an_earlier_effect_spreads_beyond_the_bounds() { + let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))]; + let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400); + // 1.5 pt left of the triangle's bounds, the blur still shows. + assert!(pixel(&r, 85, 150)[3] > 0); + // So does a blur of the fill's own. + let mut d = doc(triangle(), &fx[1..], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + n.appearance.items[0].effects_mut().push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 4}), visible: true }); + assert!(pixel(&draw(&d, 10.0, 400), 85, 150)[3] > 0); + // A fill's own Add Around that stays inside a 40 pt stroke takes no room past the stroke. + let mut d = doc(shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), &[("rasterize", json!({"background": "white"}))], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + n.appearance.items[0].effects_mut().push(Effect { id: "rasterize".into(), params: json!({"addAround": 10}), visible: true }); + n.appearance.items[1] = vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 40.0)); + let r = draw(&d, 2.0, 200); + assert_eq!((pixel(&r, 156, 100)[3], pixel(&r, 170, 100)[3]), (255, 0)); +} + +#[test] +fn rasterize_and_shadows_paint_in_stack_order() { + let square = || shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)); + let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 5, "y": 5, "blur": 0, "color": "#0000ff"})); + let gray = ("rasterize", json!({"colorModel": "grayscale"})); + // Below the square's corner, only the shadow paints. + let first = draw(&doc(square(), &[shadow.clone(), gray.clone()], 72.0), 10.0, 400); + let [r, g, b, a] = pixel(&first, 330, 330); + assert!(a == 255 && r == g && g == b, "the shadow is rasterized in grayscale: {:?}", [r, g, b, a]); + let after = draw(&doc(square(), &[gray.clone(), shadow.clone()], 72.0), 10.0, 400); + assert_eq!(pixel(&after, 330, 330), [0, 0, 255, 255], "a shadow after Rasterize keeps its color"); + // A shadow after Rasterize is the image's: a circle's square image on white casts a square shadow. + let circle = shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0)); + let cast = draw(&doc(circle, &[("rasterize", json!({"background": "white"})), shadow.clone()], 72.0), 10.0, 400); + assert_eq!(pixel(&cast, 320, 160), [0, 0, 255, 255]); + // Between two Rasterize effects, the second one takes it in. + let between = draw(&doc(square(), &[("rasterize", json!({})), shadow.clone(), gray], 72.0), 10.0, 400); + let [r, g, b, _] = pixel(&between, 330, 330); + assert!(r == g && g == b && b > 0, "{:?}", [r, g, b]); + // A member's shadow is part of a group's art. + let mut d = doc(square(), std::slice::from_ref(&shadow), 72.0); + let l = d.layers[0].id; + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]); + // So are a member's Crop Marks: the top right mark runs from x 39 to 57 at y 10. + let mut d = doc(square(), &[("cropMarks", json!({}))], 72.0); + let l = d.layers[0].id; + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + let r = draw(&d, 10.0, 400); + assert!((95..105).any(|y| pixel(&r, 395, y)[3] > 0)); + // So is the shadow in a symbol's art, for its instance. + let mut d = Document::new(100.0, 100.0); + let mut art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + art.appearance.effects.push(Effect { id: shadow.0.into(), params: shadow.1.clone(), visible: true }); + d.symbols.push(vectorcraft_doc::Symbol { name: "Shadowed".into(), art: Arc::new(art) }); + let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shadowed".into(), xf: Affine::translate((20.0, 20.0)) }); + instance.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, instance).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]); +} + +#[test] +fn rasterize_takes_in_the_art_of_a_3d_effect() { + let square = || shapes::rectangle(Rect::new(30.0, 30.0, 50.0, 50.0)); + let extrude = ("threeD.extrude", json!({})); + let solid = draw(&doc(square(), std::slice::from_ref(&extrude), 72.0), 10.0, 800); + // Pixels the extruded art covers outside the square. + let outside: Vec<(usize, usize)> = (0..800 * 800) + .map(|i| (i % 800, i / 800)) + .filter(|(x, y)| !((300..500).contains(x) && (300..500).contains(y)) && pixel(&solid, *x, *y)[3] == 255) + .collect(); + assert!(!outside.is_empty()); + let rasterized = draw(&doc(square(), &[extrude, ("rasterize", json!({}))], 72.0), 10.0, 800); + assert!(outside.iter().all(|(x, y)| pixel(&rasterized, *x, *y)[3] > 0)); +} + +#[test] +fn rasterize_takes_in_brush_art_wider_than_the_stroke() { + // A 1 pt line under the 10 pt. Oval brush, which paints about 2 pt from its center line. + let mut d = Document::new(200.0, 100.0); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut().unwrap().brush = Some("10 pt. Oval".into()); + let mut n = Node::path(d.alloc_id(), shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap); + n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() }); + assert!(pixel(&r, 100, 52)[3] > 128, "{:?}", pixel(&r, 100, 52)); +} + +#[test] +fn rasterize_takes_in_type_that_its_own_fills_effects_move() { + // "IIII" is set at 40 pt from x 5 pt with a red fill of its own under the characters, and a + // Transform effect on that fill moves it 100 pt to the right. + let typed = |fx: &[Effect]| { + let mut d = Document::new(200.0, 100.0); + let style = vectorcraft_doc::text::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = vectorcraft_doc::text::TextObject::point(Point::new(5.0, 60.0), "IIII", style); + let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Text(Box::new(text))); + let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + fill.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 100}), visible: true }); + n.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(fill)); + n.appearance.set_contents_at(1); + n.appearance.effects = fx.to_vec(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() }) + }; + // The pixels right of x 100 that the moved fill paints. + let moved = |r: &Rendered| (0..200 * 100).filter(|i| i % 200 >= 100 && pixel(r, i % 200, i / 200)[0] > 128).count(); + assert!(moved(&typed(&[])) > 50); + let rasterize = Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true }; + assert!(moved(&typed(&[rasterize])) > 50); +} + +#[test] +fn rasterize_fills_its_area_on_white_and_add_around_grows_it() { + let circle = || shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0)); + let at = |params: Value| draw(&doc(circle(), &[("rasterize", params)], 72.0), 10.0, 400); + let white = at(json!({"background": "white"})); + // Inside the bounds, outside the circle: white; outside the bounds: nothing. + assert_eq!((pixel(&white, 105, 105), pixel(&white, 200, 200)), ([255; 4], [255, 0, 0, 255])); + assert_eq!(pixel(&white, 95, 200)[3], 0); + let clear = at(json!({"background": "transparent"})); + assert_eq!((pixel(&clear, 105, 105)[3], pixel(&clear, 200, 200)), (0, [255, 0, 0, 255])); + // Add Around Object: 2 pt more on every side. + let around = at(json!({"background": "white", "addAround": 2})); + assert_eq!((pixel(&around, 85, 200), pixel(&around, 75, 200)[3]), ([255; 4], 0)); + // A second Rasterize makes its image over the first one's whole pixels, no larger. + let twice = draw(&doc(circle(), &[("rasterize", json!({})), ("rasterize", json!({"background": "white"}))], 72.0), 10.0, 400); + assert_eq!((pixel(&twice, 105, 105), pixel(&twice, 95, 200)[3], pixel(&twice, 305, 200)[3]), ([255; 4], 0, 0)); + // A Feather or an Inner Glow after a Rasterize on white works on the image's square edge. + let white = ("rasterize", json!({"background": "white"})); + let feathered = draw(&doc(circle(), &[white.clone(), ("stylize.feather", json!({"radius": 1}))], 72.0), 10.0, 400); + assert!(pixel(&feathered, 106, 106)[3] > 0); + let glow = ("stylize.innerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#ff0000", "source": "edge"})); + let glowing = draw(&doc(circle(), &[white.clone(), glow.clone()], 72.0), 10.0, 400); + assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102)); + // With a Rasterize on nothing between them, the glow follows the same edge. + let glowing = draw(&doc(circle(), &[white, ("rasterize", json!({})), glow], 72.0), 10.0, 400); + assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102)); + // Create Clipping Mask: the white stays inside the circle. + let clipped = at(json!({"background": "white", "clippingMask": true})); + assert_eq!((pixel(&clipped, 105, 105)[3], pixel(&clipped, 200, 200)), (0, [255, 0, 0, 255])); + // Inside the outline the white shows where the art paints nothing: a circle stroked only. + let mut ring = doc(circle(), &[], 72.0); + let l = ring.layers[0].id; + let n = Arc::make_mut(&mut ring.children_mut(Some(l)).unwrap()[0]); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into(); + n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white", "clippingMask": true}), visible: true }); + let ring = draw(&ring, 10.0, 400); + assert_eq!((pixel(&ring, 200, 200), pixel(&ring, 105, 105)[3]), ([255; 4], 0)); + // A stroke 1 pt wide widens the image by half a point a side, as it widens the visual bounds. + let mut stroked = doc(circle(), &[], 72.0); + let n = Arc::make_mut(&mut stroked.children_mut(Some(l)).unwrap()[0]); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into(); + n.appearance.effects.push(Effect { + id: "rasterize".into(), + params: json!({"background": "white", "resolution": 720, "useDocumentResolution": false}), + visible: true, + }); + let stroked = draw(&stroked, 10.0, 400); + assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0)); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index aef875a40..cebea6d34 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -591,7 +591,7 @@ impl Rules<'_> { } RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))), RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"), - RasterFx::Pixel(_) => art.unsupported("pixel effects"), + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => art.unsupported("pixel effects"), } } if !shadows.is_empty() { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b51dffe43..412d63c94 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1617,7 +1617,7 @@ impl Writer<'_> { let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); let mut opened = 0; for f in fx.iter().rev() { - if matches!(f, RasterFx::Pixel(_)) { + if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); continue; } @@ -1678,7 +1678,7 @@ impl Writer<'_> { sd(*radius) ), RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), - RasterFx::Pixel(_) => continue, + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, }; // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. let mode = match f { diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index c103da554..32003305f 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -158,6 +158,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { free_distort::fields(app, ui, d); values_changed(d) } + None if id == vectorcraft_effects::RASTERIZE => { + super::raster_effects::effect_fields(app, ui, d); + values_changed(d) + } None => { let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); diff --git a/crates/ui-egui/src/dialogs/raster_effects.rs b/crates/ui-egui/src/dialogs/raster_effects.rs index b24a293ca..b21e284ee 100644 --- a/crates/ui-egui/src/dialogs/raster_effects.rs +++ b/crates/ui-egui/src/dialogs/raster_effects.rs @@ -2,7 +2,8 @@ //! anti-alias, clipping mask, room around the art and spot colour options. //! //! Fields: what `document.rasterEffectsSettings` reports (and takes back). OK runs it with them: -//! one undo step. On an error the dialog stays open. +//! one undo step. On an error the dialog stays open. [`effect_fields`] lays out the options of +//! Effect › Rasterize… with the same widgets. use serde_json::{Value, json}; @@ -94,6 +95,67 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The options of Effect › Rasterize… in its effect dialog, with this dialog's widgets: Color +/// Model, Resolution (a preset, Use Document Raster Effects Resolution or Other) and Background, +/// then an Anti-aliasing menu, Create Clipping Mask and Add … Around Object. +pub(super) fn effect_fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let own = if app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk) { "CMYK" } else { "RGB" }; + choice(ui, d, "colorModel", tl!("Color Model:"), &[("document", own), ("grayscale", tl!("Grayscale")), ("bitmap", tl!("Bitmap"))]); + ui.add_space(4.0); + let (ppi, document) = (d.f64("resolution", 72.0), d.bool("useDocumentResolution")); + let preset = RESOLUTIONS.iter().position(|(p, _)| (*p - ppi).abs() < 1e-9); + let mut labels: Vec<&str> = RESOLUTIONS.iter().map(|(_, l)| tl!(l)).collect(); + labels.push(tl!("Use Document Raster Effects Resolution")); + labels.push(tl!("Other")); + let shown = if document { RESOLUTIONS.len() } else { preset.unwrap_or(RESOLUTIONS.len() + 1) }; + widgets::label_row(ui, tl!("Resolution:"), LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "rasterize-resolution", labels.get(shown).copied().unwrap_or_default(), &labels, 260.0) { + d.fields.insert("useDocumentResolution".into(), json!(i == RESOLUTIONS.len())); + if let Some((p, _)) = RESOLUTIONS.get(i) { + d.fields.insert("resolution".into(), json!(p)); + } else if i > RESOLUTIONS.len() && preset.is_some() { + // Other moves the value 1 ppi off the preset, so its own field shows. + d.fields.insert("resolution".into(), json!(ppi.round() + 1.0)); + } + } + if !document + && preset.is_none() + && let Some(v) = widgets::plain_field(ui, "rasterize-ppi", ppi, " ppi", 0, 80.0) + { + d.fields.insert("resolution".into(), json!(v)); + } + }); + ui.add_space(6.0); + widgets::label_row(ui, tl!("Background:"), LABEL, |ui| { + let cur = d.str("background"); + for (id, label) in [("white", tl!("White")), ("transparent", tl!("Transparent"))] { + if widgets::radio(ui, label, cur == id, true) { + d.fields.insert("background".into(), json!(id)); + } + ui.add_space(12.0); + } + }); + ui.add_space(10.0); + widgets::subheader(ui, tl!("Options")); + widgets::label_row(ui, tl!("Anti-aliasing:"), LABEL, |ui| { + use vectorcraft_render::AntiAlias; + let aa = AntiAlias::from_id(&d.str("antiAlias")).unwrap_or(AntiAlias::Art); + if let Some(i) = widgets::dropdown(ui, "rasterize-aa", aa.label(), &AntiAlias::ALL.map(AntiAlias::label), 170.0) { + d.fields.insert("antiAlias".into(), json!(AntiAlias::ALL.get(i).copied().unwrap_or(AntiAlias::Art).id())); + } + }); + check(ui, d, "clippingMask", tl!("Create Clipping Mask")); + let unit = app.session.general_unit(); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Add")).color(t.text)); + if let Some(v) = widgets::num_field(ui, "rasterize-add-around", Some(d.f64("addAround", 0.0)), unit, 80.0) { + d.fields.insert("addAround".into(), json!(v)); + } + ui.label(egui::RichText::new(tl!("Around Object")).color(t.text)); + }); +} + #[cfg(test)] mod tests { use serde_json::json; @@ -149,4 +211,32 @@ mod tests { let st = app.session.active().unwrap(); assert!(st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Image(_))))); } + + #[test] + fn rasterize_effect_dialog_previews_and_ok_applies_its_options_as_one_undo_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let steps = app.session.active().unwrap().history.undo.len(); + crate::menus::invoke(&mut app, "effect.dialog", json!({"effect": "rasterize"})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["useDocumentResolution"].as_bool()), ("effect", Some(true))); + frame(&mut app); + assert!(app.session.in_interaction(), "previews"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in + [("useDocumentResolution", json!(false)), ("resolution", json!(200)), ("background", json!("white")), ("antiAlias", json!("none"))] + { + d.fields.insert(k.into(), v); + } + // Other resolutions show their own field. + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let (e, p) = (&n.appearance.effects[0], &n.appearance.effects[0].params); + assert_eq!((e.id.as_str(), &p["resolution"], &p["background"], &p["antiAlias"]), ("rasterize", &json!(200), &json!("white"), &json!("none"))); + assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "the rectangle stays a path"); + assert_eq!(st.history.undo.len(), steps + 1); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9acbb54a9..58356e1c2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -770,6 +770,7 @@ Document open password Парола за отваряне на документ Document profile Профил на документ Document Raster Effects Settings Настройки на ефектите за растеризиране в документа + Use Document Raster Effects Resolution Използване на разделителната способност от настройките на ефектите за растеризиране в документа Document Raster Effects Settings Dialog Диалогов прозорец за настройки на ефектите за растеризиране в документа Document Raster Effects Settings… Настройки на ефектите за растеризиране в документа… Document Setup Настройка на документ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b289026ba..529161842 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3356,6 +3356,7 @@ theme Appearance Motiv Save Print Preset Uložit přednastavení tisku Print Tiling Pages Stránky dlaždic tisku Document Raster Effects Settings Nastavení rastrových efektů dokumentu + Use Document Raster Effects Resolution Použít rozlišení z nastavení rastrových efektů dokumentu Artwork Colors Barvy kresby Randomize Colors Náhodné barvy Recolor Artwork Přebarvit kresbu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0dd943820..723dbf1af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -771,6 +771,7 @@ Document open password Kennwort zum Öffnen des Dokuments Document profile Dokumentprofil Document Raster Effects Settings Dokument-Rastereffekt-Einstellungen + Use Document Raster Effects Resolution Auflösung der Dokument-Rastereffekt-Einstellungen verwenden Document Raster Effects Settings Dialog Dialogfeld „Dokument-Rastereffekt-Einstellungen“ Document Raster Effects Settings… Dokument-Rastereffekt-Einstellungen… Document Setup Dokument einrichten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 56703aea0..f1dc57e7b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -743,6 +743,7 @@ Document open password Κωδικός ανοίγματος εγγράφου Document profile Προφίλ εγγράφου Document Raster Effects Settings Ρυθμίσεις εφέ ράστερ εγγράφου + Use Document Raster Effects Resolution Χρήση ανάλυσης από τις ρυθμίσεις εφέ ράστερ εγγράφου Document Raster Effects Settings Dialog Παράθυρο ρυθμίσεων εφέ ράστερ εγγράφου Document Raster Effects Settings… Ρυθμίσεις εφέ ράστερ εγγράφου… Document Setup Ρύθμιση εγγράφου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4c6b07cb3..f46de8768 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -785,6 +785,7 @@ Document open password Contraseña de apertura del documento Document profile Perfil del documento Document Raster Effects Settings Ajustes de efectos de rasterización del documento + Use Document Raster Effects Resolution Usar la resolución de los ajustes de efectos de rasterización del documento Document Raster Effects Settings Dialog Cuadro de diálogo Ajustes de efectos de rasterización del documento Document Raster Effects Settings… Ajustes de efectos de rasterización del documento… Document Setup Ajustar documento diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2750d8a0..ee29e8af8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3291,6 +3291,7 @@ theme Appearance Ulkoasu Save Print Preset Tallenna tulostuksen esiasetus Print Tiling Pages Tulosta sivujen ruudutus Document Raster Effects Settings Asiakirjan rasteritehosteiden asetukset + Use Document Raster Effects Resolution Käytä asiakirjan rasteritehosteiden asetusten tarkkuutta Artwork Colors Kuvituksen värit Randomize Colors Satunnaista värit Recolor Artwork Väritä kuvitus uudelleen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 03146f17a..eb851dd1c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -787,6 +787,7 @@ Document open password Mot de passe d’ouverture du document Document profile Profil du document Document Raster Effects Settings Paramètres des effets de pixellisation du document + Use Document Raster Effects Resolution Utiliser la résolution des paramètres des effets de pixellisation du document Document Raster Effects Settings Dialog Boîte de dialogue Paramètres des effets de pixellisation du document Document Raster Effects Settings… Paramètres des effets de pixellisation du document… Document Setup Format de document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 461070dcf..3906e2980 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1027,6 +1027,7 @@ Document open password Dokumentummegnyitási jelszó Document profile Dokumentumprofil Document Raster Effects Settings Dokumentum raszteres effektusainak beállítása + Use Document Raster Effects Resolution A dokumentum raszteres effektusainak felbontása Document Raster Effects Settings Dialog Dokumentum raszteres effektusainak beállítása – párbeszédablak Document Raster Effects Settings… Dokumentum raszteres effektusainak beállítása… Document Setup Dokumentumbeállítás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 21cfcf18a..d2034e240 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -785,6 +785,7 @@ Document open password Password di apertura del documento Document profile Profilo documento Document Raster Effects Settings Impostazioni effetti raster documento + Use Document Raster Effects Resolution Usa la risoluzione delle impostazioni effetti raster documento Document Raster Effects Settings Dialog Finestra di dialogo Impostazioni effetti raster documento Document Raster Effects Settings… Impostazioni effetti raster documento… Document Setup Imposta documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 525c112f4..72498c353 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1245,6 +1245,7 @@ Document Ink Options ドキュメントのインキオプション Document JSON ドキュメント JSON Document Raster Effects Settings ドキュメントのラスタライズ効果設定 + Use Document Raster Effects Resolution ドキュメントのラスタライズ効果設定の解像度を使用 Document Raster Effects Settings Dialog ドキュメントのラスタライズ効果設定ダイアログ Document Setup ドキュメント設定 Document Title: ドキュメントのタイトル: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6443cfcc4..866cef4e7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1240,6 +1240,7 @@ Document Ink Options 문서 잉크 옵션 Document JSON 문서 JSON Document Raster Effects Settings 문서 래스터 효과 설정 + Use Document Raster Effects Resolution 문서 래스터 효과 설정의 해상도 사용 Document Raster Effects Settings Dialog 문서 래스터 효과 설정 대화상자 Document Setup 문서 설정 Document Title: 문서 제목: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae9765d1d..faaa67f72 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3211,6 +3211,7 @@ theme Appearance Wygląd Save Print Preset Zapisz ustawienie drukowania Print Tiling Pages Strony kafelkowania wydruku Document Raster Effects Settings Ustawienia efektów rastrowych dokumentu + Use Document Raster Effects Resolution Użyj rozdzielczości z ustawień efektów rastrowych dokumentu Artwork Colors Kolory kompozycji Randomize Colors Losuj kolory Recolor Artwork Zmień kolory kompozycji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 63e725edf..17351c45b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -702,6 +702,7 @@ Document Ink Options Opções de Tinta do Documento Document JSON JSON do Documento Document Raster Effects Settings Configurações de Efeitos de Raster do Documento + Use Document Raster Effects Resolution Usar a Resolução das Configurações de Efeitos de Raster do Documento Document Raster Effects Settings Dialog Diálogo de Configurações de Efeitos de Raster do Documento Document Raster Effects Settings… Configurações de Efeitos de Raster do Documento… Document Setup Configuração do Documento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ee7c6e5cd..a069afef9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -783,6 +783,7 @@ Document open password Пароль для открытия документа Document profile Профиль документа Document Raster Effects Settings Параметры растровых эффектов документа + Use Document Raster Effects Resolution Использовать разрешение из параметров растровых эффектов документа Document Raster Effects Settings Dialog Диалоговое окно «Параметры растровых эффектов документа» Document Raster Effects Settings… Параметры растровых эффектов документа… Document Setup Параметры документа diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb6ef9f23..1929f7216 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -772,6 +772,7 @@ Document open password Lösenord för att öppna dokumentet Document profile Dokumentprofil Document Raster Effects Settings Inställningar för rastereffekter i dokument + Use Document Raster Effects Resolution Använd upplösningen från inställningarna för rastereffekter i dokument Document Raster Effects Settings Dialog Dialogruta för inställningar för rastereffekter i dokument Document Raster Effects Settings… Inställningar för rastereffekter i dokument… Document Setup Dokumentinställningar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d80b7657e..1f9a25d3f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -779,6 +779,7 @@ Document open password Пароль для відкриття документа Document profile Профіль документа Document Raster Effects Settings Параметри растрових ефектів документа + Use Document Raster Effects Resolution Використовувати роздільну здатність із параметрів растрових ефектів документа Document Raster Effects Settings Dialog Діалогове вікно «Параметри растрових ефектів документа» Document Raster Effects Settings… Параметри растрових ефектів документа… Document Setup Параметри документа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 68faa3b09..fb7446795 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -918,6 +918,7 @@ Document open password Mật khẩu mở tài liệu Document profile Hồ sơ tài liệu Document Raster Effects Settings Cài đặt hiệu ứng raster tài liệu + Use Document Raster Effects Resolution Dùng độ phân giải trong cài đặt hiệu ứng raster tài liệu Document Raster Effects Settings Dialog Hộp thoại cài đặt hiệu ứng raster tài liệu Document Raster Effects Settings… Cài đặt hiệu ứng raster tài liệu… Document Setup Thiết lập tài liệu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc23a1fcf..573edd271 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -736,6 +736,7 @@ Document open password 文档打开口令 Document profile 文档配置文档 Document Raster Effects Settings 文档栅格效果设置 + Use Document Raster Effects Resolution 使用文档栅格效果设置的分辨率 Document Raster Effects Settings Dialog 文档栅格效果设置对话框 Document Raster Effects Settings… 文档栅格化效果设置… Document Setup 文档设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f215ef7bd..af1ef53ee 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -754,6 +754,7 @@ Document open password 文件開啟密碼 Document profile 文件描述檔 Document Raster Effects Settings 文件點陣效果設定 + Use Document Raster Effects Resolution 使用文件點陣效果設定的解析度 Document Raster Effects Settings Dialog 文件點陣效果設定對話框 Document Raster Effects Settings… 文件點陣效果設定… Document Setup 文件設定 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b322a891..435b36636 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3715,7 +3715,7 @@ fn effect_menu() -> Vec { Sep, Item::Header("Vector Effects"), ]; - // Submenus (and Crop Marks, an item of its own) in the reference app's order. + // Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order. let order = [ "3D and Materials", "Color Adjustments", @@ -3724,6 +3724,7 @@ fn effect_menu() -> Vec { "Distort & Transform", "Path", "Pathfinder", + "Rasterize…", "Stylize", "SVG Filters", "Warp", @@ -3734,21 +3735,18 @@ fn effect_menu() -> Vec { // Glowing Edges). let in_vector_menu = |e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id); + let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + // No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator. + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; // The effects whose catalogue menu path is `path`. // `vector` picks the ones for the vector submenu or the ones for the raster submenu. - let items_at = |path: &[&str], vector: bool| -> Vec { - cat.iter() - .filter(|e| e.menu == path && in_vector_menu(e) == vector) - .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - // No options (Effect → Pathfinder): apply directly, like Illustrator. - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), - }) - .collect() - }; + let items_at = + |path: &[&str], vector: bool| -> Vec { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() }; for sub_name in order { if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { - out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id }))); + out.push(item(e)); continue; } let items = items_at(&["Effect", sub_name], true); diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 73fb99b42..4c421b3aa 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -68,6 +68,18 @@ fn effect_menu_lists_outline_object_between_offset_path_and_outline_stroke() { ); } +/// Effect › Rasterize…, an item of its own between Pathfinder and Stylize, opens its options. +#[test] +fn effect_menu_lists_rasterize_between_pathfinder_and_stylize() { + let app = app(); + let entries = menus::menu_entries(&app); + let at = |path: &[&str], label: Option<&str>| entries.iter().position(|e| e.path == path && label.is_none_or(|l| e.label == l)).unwrap(); + let rasterize = at(&["Effect"], Some("Rasterize…")); + assert!(at(&["Effect", "Pathfinder"], None) < rasterize && rasterize < at(&["Effect", "Stylize"], None)); + let e = &entries[rasterize]; + assert_eq!((e.command.as_deref(), e.params["effect"].as_str()), (Some("effect.dialog"), Some("rasterize"))); +} + #[test] fn effect_menu_lists_film_grain_in_artistic_before_blur() { let app = app(); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 09ac1f415..e9497ab58 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -11,7 +11,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 | 45 | 90% | 6–10 | +| Illustrator effects (vector and stylize) | 50 | 46 | 92% | 6–10 | | Photoshop effects (raster, Effect Gallery) | 57 | 30 | 53% | 22–38 | | 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | @@ -25,7 +25,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | -| Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | +| Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | diff --git a/docs/mcp.md b/docs/mcp.md index b6c980a1c..2754b4b20 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2350,6 +2350,24 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} ``` +`effect.apply {effect: "rasterize"}` (Effect › Rasterize…) draws an object as an image of square pixels while its +paths, type and other art stay editable. It takes the options of Object › Rasterize under these names: `colorModel` +(`document`, `grayscale` or `bitmap`), `useDocumentResolution` (true by default: the resolution above) or +`resolution` (1 to 2400 ppi), `background` (`transparent` by default, or `white`), `antiAlias` (`art`, `type` or +`none`), `clippingMask` and `addAround` (points). The image covers the object's bounds and the room around them, +its first pixel at their top-left corner. Both `object.rasterize` and the effect measure the art as drawn: with its +geometry effects, its brush art and its raster effects. The effects above Rasterize in the stack, shadows included, +are part of the image, and the shadows and glows below it are cast by the image. The canvas draws an image of up to +8.4 million pixels, at most 32,768 a side, on its own grid and then at the view's zoom: an image pixel wider than a +screen pixel shows as one flat square, and smaller ones are averaged. A larger image is made from the art drawn at the canvas's resolution +(lower for very large art), and its pixels no wider than a pixel of that drawing show the art as drawn. +When Rasterize is an object's only raster effect, Expand Appearance and PDF and SVG export replace the object with the +image `object.rasterize` makes with the same options. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"rasterize","params":{"useDocumentResolution":false,"resolution":150,"background":"white"}}}} +``` + ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f666994a..aa4141a3e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -74,7 +74,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; remaining: Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | -| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated; Warp Options controls verified by focused checks and original-image review | +| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 46/50, depth estimated; Warp Options controls verified by focused checks and original-image review | | Raster effects (Effect Gallery) | 4 | 49% | 27 Photoshop-style filters (14 Artistic, 13 Sketch), the Effect Gallery dialog, Load Texture | 22–38 | measured count 30/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | | 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 (other types use flat shading; one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass | @@ -650,6 +650,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions. - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`). +- **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests_rastersettings.rs`, `crates/render/src/tests_rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. From 800db5b29d1d642a7009d968d2c87cef7c47c6b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 01:39:03 -0700 Subject: [PATCH 2/2] i18n: Dutch, Danish and Indonesian rows for the Rasterize effect's resolution option main now lists nl, da and id as complete-menu catalogs, so the dialog's Use Document Raster Effects Resolution label needs a row in each. --- crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3f67eec54..77fc380fb 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3236,6 +3236,7 @@ theme Appearance Udseende Save Print Preset Gem udskriftsforudindstilling Print Tiling Pages Udskriv sideopdelte sider Document Raster Effects Settings Indstillinger for dokumentets rasteriseringseffekter + Use Document Raster Effects Resolution Brug opløsningen fra indstillingerne for dokumentets rasteriseringseffekter Artwork Colors Farver i grafik Randomize Colors Tilfældiggør farver Recolor Artwork Omfarv grafik diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 47d8f29e7..c3bd3bda8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3236,6 +3236,7 @@ theme Appearance Tampilan Save Print Preset Simpan preset cetak Print Tiling Pages Halaman pengubinan cetak Document Raster Effects Settings Pengaturan efek raster dokumen + Use Document Raster Effects Resolution Gunakan resolusi dari pengaturan efek raster dokumen Artwork Colors Warna karya seni Randomize Colors Acak warna Recolor Artwork Warnai ulang karya seni diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0d31d8e61..e7cbdebf6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3236,6 +3236,7 @@ theme Appearance Vormgeving Save Print Preset Afdrukvoorinstelling opslaan Print Tiling Pages Pagina's tegelgewijs afdrukken Document Raster Effects Settings Instellingen voor rastereffecten in document + Use Document Raster Effects Resolution Resolutie van de instellingen voor rastereffecten in document gebruiken Artwork Colors Kleuren in het artwork Randomize Colors Kleuren willekeurig maken Recolor Artwork Artwork opnieuw kleuren