From f35e9d091d8bc3c1e7adb17844d281f9dab79eac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:27:30 -0800 Subject: [PATCH] Effects: pixel filter directions turn and reflect with a Transform effect after them A Transform effect listed after Underpainting, Rough Pastels, Conte Crayon, Bas Relief, Plaster or Texturizer now turns and reflects their light direction, and after Sprayed Strokes or Graphic Pen their stroke direction. Each is set to the nearest choice of its menu, the one clockwise of it at a tie. Color Halftone's four screen angles turn and reflect with the art. Stacked Transform effects, and an object's Transform effect over a fill's own, round the direction once. A zero scale leaves the direction as it is. The canvas, export and Expand Appearance use the same values. Filters with fixed stroke or pattern axes keep them. --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/pixel.rs | 115 ++++++++++++- crates/effects/src/pixel/brushstrokes.rs | 4 +- crates/effects/src/pixel/texture.rs | 2 +- crates/effects/src/raster.rs | 27 ++- crates/effects/src/tests/rasterfollow.rs | 204 ++++++++++++++++++++++- crates/render/src/fx/tests.rs | 32 ++++ docs/target-app-parity.md | 2 +- 8 files changed, 371 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 67677656e6..d83b149377 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -267,7 +267,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are; so do the raster effects of the object's fills and strokes and, on a group, those of its members, of their fills and strokes and of their linked opacity masks' art, after their own Transform effects, including those that a member's own Transform effect with copies or random holds back), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; raster effects that transform with the art are not scaled again; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale, a Drop Shadow's offset turns, reflects and scales, a pixel filter's lightDirection or strokeDirection turns and reflects to the nearest choice (at a tie, the one clockwise of it; VectorCraft's own rule), and Color Halftone's channel angles turn and reflect with the art (0 to 360); so do the raster effects of the object's fills and strokes and, on a group, those of its members, of their fills and strokes and of their linked opacity masks' art, after their own Transform effects, including those that a member's own Transform effect with copies or random holds back), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; raster effects that transform with the art are not scaled again; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/pixel.rs b/crates/effects/src/pixel.rs index 0c159f8485..264798bceb 100644 --- a/crates/effects/src/pixel.rs +++ b/crates/effects/src/pixel.rs @@ -29,6 +29,8 @@ mod sketch_ink; mod texture; mod video; +use std::f64::consts::{PI, TAU}; + use serde_json::Value; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -331,6 +333,9 @@ pub struct PixelSpace { pub extent: f64, } +/// Color Halftone's screen angle parameters with their defaults (degrees). +const SCREEN_ANGLES: [(&str, f64); 4] = [("channel1", 108.0), ("channel2", 162.0), ("channel3", 90.0), ("channel4", 45.0)]; + /// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects. pub(crate) fn parse(id: &str, p: &Value) -> Option { let quality = |levels: [(&str, u32); 3], default: u32| { @@ -564,8 +569,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { }, "pixelate.colorHalftone" => PixelFx::ColorHalftone { max_radius: num(p, "maxRadius", 8.0).clamp(4.0, 127.0), - angles: [("channel1", 108.0), ("channel2", 162.0), ("channel3", 90.0), ("channel4", 45.0)] - .map(|(k, d)| num(p, k, d).clamp(-360.0, 360.0)), + angles: SCREEN_ANGLES.map(|(k, d)| num(p, k, d).clamp(-360.0, 360.0)), }, "pixelate.crystallize" => PixelFx::Crystallize { cell: num(p, "cellSize", 10.0).clamp(3.0, 300.0) }, "pixelate.mezzotint" => PixelFx::Mezzotint { kind: Mezzotint::parse(text(p, "type", "fineDots")) }, @@ -854,6 +858,113 @@ fn pick(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| t.clone()) } +/// The shortest that a direction's unit vector may become under a Transform effect and still give +/// a direction ([`turned_directions`]). +const MIN_TURNED: f64 = 1e-9; + +/// How close (radians) two directions' angles to a turned direction must be to count as equally +/// near ([`nearest`]). +const TIE: f64 = 1e-9; + +/// The parameters of pixel filter `id` (params `p`, defaults not merged in) that set a direction +/// on the page, as `a` turns them: `a` is the linear part of a Transform effect listed after the +/// filter, which turns, reflects and scales what the filter paints. A light direction or stroke +/// direction becomes the one nearest to where `a` takes it, the one clockwise of it when two are +/// equally near ([`snapped`]); `later` is the map the filter still goes through when it is drawn, +/// after `a`. Color Halftone's screen angles (degrees counterclockwise from the page's horizontal) +/// turn with `a`, from 0 up to 360. A direction that `a` takes to no direction (a zero scale) stays +/// as it is. Empty for filters with no such parameter. +pub(crate) fn turned_directions(id: &str, p: &Value, a: Affine, later: Affine) -> Vec<(&'static str, Value)> { + enum Kind { + Light(&'static str), + Stroke, + Screens, + } + let kind = match id { + "sketch.basRelief" => Kind::Light("bottom"), + "artistic.underpainting" | "artistic.roughPastels" | "sketch.conteCrayon" | "sketch.plaster" | "texture.texturizer" => Kind::Light("top"), + "brushStrokes.sprayedStrokes" | "sketch.graphicPen" => Kind::Stroke, + "pixelate.colorHalftone" => Kind::Screens, + _ => return Vec::new(), + }; + let p = crate::merged_params(id, p); + match kind { + Kind::Light(default) => { + let light = Light::parse(text(&p, "lightDirection", default)); + snapped(&Light::ALL, Light::toward, light, a, later, false) + .and_then(|t| name_of(&LIGHT_DIRECTIONS, &Light::ALL, t)) + .map(|name| vec![("lightDirection", Value::from(name))]) + .unwrap_or_default() + } + Kind::Stroke => { + let direction = StrokeDirection::parse(text(&p, "strokeDirection", "rightDiagonal")); + snapped(&StrokeDirection::ALL, StrokeDirection::along, direction, a, later, true) + .and_then(|t| name_of(&STROKE_DIRECTIONS, &StrokeDirection::ALL, t)) + .map(|name| vec![("strokeDirection", Value::from(name))]) + .unwrap_or_default() + } + // A screen at θ° runs along (cos θ, −sin θ) on the page (y down). + Kind::Screens => SCREEN_ANGLES + .iter() + .filter_map(|&(key, default)| { + let t = num(&p, key, default).clamp(-360.0, 360.0).to_radians(); + let v = mapped(a, Vec2::new(t.cos(), -t.sin()))?; + let degrees = ((-v.y).atan2(v.x).to_degrees() * 1e6).round() / 1e6; + Some((key, Value::from(degrees.rem_euclid(360.0) + 0.0))) + }) + .collect(), + } +} + +/// Where the linear part of `a` takes direction `v`; `None` when it takes it to no direction. +fn mapped(a: Affine, v: Vec2) -> Option { + let w = (a * v.to_point()) - (a * Point::ORIGIN); + (w.is_finite() && w.hypot() > MIN_TURNED).then_some(w) +} + +/// The entry of `all` to store for `from` (directions `dir`) once `a` turns it: the one nearest to +/// where `a` takes it ([`nearest`]). When the stored entry is still to be mapped by `later` and +/// rounded again as it is drawn, it is the entry nearest to that among those that `later` takes to +/// the entry nearest to where `later` after `a` takes `from`, so that the drawn direction is +/// rounded once. `None` when `a` takes `from` to no direction. +fn snapped(all: &[T], dir: fn(T) -> Vec2, from: T, a: Affine, later: Affine, axis: bool) -> Option { + let v = mapped(a, dir(from))?; + let turned = nearest(all, dir, v, axis)?; + if later == Affine::IDENTITY { + return Some(turned); + } + let Some(target) = mapped(later * a, dir(from)).and_then(|w| nearest(all, dir, w, axis)) else { return Some(turned) }; + let fits: Vec = all.iter().copied().filter(|&t| mapped(later, dir(t)).and_then(|w| nearest(all, dir, w, axis)) == Some(target)).collect(); + nearest(&fits, dir, v, axis).or(Some(turned)) +} + +/// The entry of `all` whose direction (`dir`, page axes with y down) is nearest to `v` by angle, +/// a direction and its opposite counting as the same with `axis`. Of two equally near, the one +/// clockwise of `v` on the page. +fn nearest(all: &[T], dir: impl Fn(T) -> Vec2, v: Vec2, axis: bool) -> Option { + let period = if axis { PI } else { TAU }; + let at = v.atan2(); + let mut best: Option<(T, f64)> = None; + for &t in all { + // How far clockwise of `v` the entry lies, within half a period either way. + let d = (dir(t).atan2() - at).rem_euclid(period); + let d = if d > period / 2.0 { d - period } else { d }; + let better = match best { + None => true, + Some((_, b)) => d.abs() < b.abs() - TIE || (d.abs() <= b.abs() + TIE && d > b), + }; + if better { + best = Some((t, d)); + } + } + best.map(|(t, _)| t) +} + +/// The value in `table` of entry `t` of `all` (the same order). +fn name_of(table: &[(&'static str, &'static str)], all: &[T], t: T) -> Option<&'static str> { + table.iter().zip(all).find(|(_, u)| **u == t).map(|((_, v), _)| *v) +} + /// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty). fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> { let (px, rest) = data.as_chunks_mut::<4>(); diff --git a/crates/effects/src/pixel/brushstrokes.rs b/crates/effects/src/pixel/brushstrokes.rs index 100d4e2497..ca5c0a9b48 100644 --- a/crates/effects/src/pixel/brushstrokes.rs +++ b/crates/effects/src/pixel/brushstrokes.rs @@ -43,7 +43,7 @@ pub enum StrokeDirection { } impl StrokeDirection { - const ALL: [StrokeDirection; 4] = + pub(super) const ALL: [StrokeDirection; 4] = [StrokeDirection::RightDiagonal, StrokeDirection::Horizontal, StrokeDirection::LeftDiagonal, StrokeDirection::Vertical]; /// The direction of `value` (a [`STROKE_DIRECTIONS`] value, any case); Right Diagonal @@ -53,7 +53,7 @@ impl StrokeDirection { } /// The unit vector along the strokes (document axes, y down). - fn along(self) -> Vec2 { + pub(super) fn along(self) -> Vec2 { match self { StrokeDirection::RightDiagonal => RIGHT, StrokeDirection::Horizontal => Vec2::new(1.0, 0.0), diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index a408454530..e041256dc0 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -137,7 +137,7 @@ pub enum Light { } impl Light { - const ALL: [Light; 8] = + pub(super) const ALL: [Light; 8] = [Light::Bottom, Light::BottomLeft, Light::Left, Light::TopLeft, Light::Top, Light::TopRight, Light::Right, Light::BottomRight]; /// The direction of `value` (a [`LIGHT_DIRECTIONS`] value, any case); Top otherwise. diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index b59d61beb2..9dcff61f0d 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -452,7 +452,7 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; let list = out.get_or_insert_with(|| effects.to_vec()); if let Some(e) = list.get_mut(i) { - follow_transform(e, a); + follow_transform(e, a, Affine::IDENTITY); } } out.map_or(Cow::Borrowed(effects), Cow::Owned) @@ -472,7 +472,7 @@ pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; let mut changed = false; for e in effects.iter_mut().filter(|e| follows(&e.id)) { - follow_transform(e, a); + follow_transform(e, a, Affine::IDENTITY); changed = true; } changed @@ -519,7 +519,7 @@ pub(crate) fn follow_contents(effects: &mut [Effect], a: Affine, outer: Affine) let det = b.determinant(); let c = if b == Affine::IDENTITY || !(det.is_finite() && det.abs() > 1e-12) { a } else { b.inverse() * a * b }; if c.is_finite() { - follow_transform(e, c); + follow_transform(e, c, b); changed = true; } } @@ -615,9 +615,11 @@ pub(crate) fn follows(id: &str) -> bool { /// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as /// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean -/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with -/// `a`. Effects that [`follows`] leaves out stay as they are. -fn follow_transform(e: &mut Effect, a: Affine) { +/// scale ([`crate::scale_effect`]), a Drop Shadow's offset turns, reflects and scales with `a`, +/// and a pixel filter's light direction, stroke direction or screen angles turn and reflect with +/// it ([`pixel::turned_directions`]; `later` is the map `e` still goes through when it is drawn). +/// Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine, later: Affine) { if !follows(&e.id) { return; } @@ -625,15 +627,22 @@ fn follow_transform(e: &mut Effect, a: Affine) { let p = merged_params(&e.id, &e.params); a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) }); + let turned = pixel::turned_directions(&e.id, &e.params, a, later); if let Some(k) = vectorcraft_doc::Scaling::factor(a) { crate::scale_effect(e, k); } - if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + let mut set = |key: &str, value: Value| { if !e.params.is_object() { e.params = Value::Object(Map::new()); } - e.params["x"] = json!(o.x); - e.params["y"] = json!(o.y); + e.params[key] = value; + }; + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + set("x", json!(o.x)); + set("y", json!(o.y)); + } + for (key, value) in turned { + set(key, value); } } diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 1ebcab785a..ae407fd438 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -7,7 +7,8 @@ use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, shapes}; use crate::geometry::{Budget, MAX_EFFECT_LINES, apply_geometry_carrying_within}; -use crate::{Carry, GeomContext, RasterFx, apply_geometry_carrying, bake_document, expand_outlined, raster_effects}; +use crate::pixel::{Light, StrokeDirection}; +use crate::{Carry, GeomContext, PixelFx, RasterFx, apply_geometry_carrying, bake_document, expand_outlined, raster_effects}; fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } @@ -378,6 +379,207 @@ fn pixel_filter_sizes_scale_with_the_transform_after_them() { assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); } +/// The light direction of the first pixel filter `effects` draw, if it has one. +fn drawn_light(effects: &[Effect]) -> Option { + raster_effects(effects).into_iter().find_map(|r| match r { + RasterFx::Pixel( + PixelFx::Underpainting { light, .. } + | PixelFx::RoughPastels { light, .. } + | PixelFx::ConteCrayon { light, .. } + | PixelFx::BasRelief { light, .. } + | PixelFx::Plaster { light, .. } + | PixelFx::Texturizer { light, .. }, + ) => Some(light), + _ => None, + }) +} + +/// The stroke direction of the first pixel filter `effects` draw, if it has one. +fn drawn_strokes(effects: &[Effect]) -> Option { + raster_effects(effects).into_iter().find_map(|r| match r { + RasterFx::Pixel(PixelFx::SprayedStrokes { direction, .. } | PixelFx::GraphicPen { direction, .. }) => Some(direction), + _ => None, + }) +} + +/// The screen angles of the first Color Halftone `effects` draw. +fn drawn_screens(effects: &[Effect]) -> [f64; 4] { + match raster_effects(effects).into_iter().next() { + Some(RasterFx::Pixel(PixelFx::ColorHalftone { angles, .. })) => angles, + other => panic!("expected a Color Halftone, got {other:?}"), + } +} + +/// A Bas Relief lit from `light`, then a Transform effect with `params`: the light it draws with. +fn relief_then(light: &str, params: Value) -> Option { + drawn_light(&[fx("sketch.basRelief", json!({ "lightDirection": light })), transform(params)]) +} + +/// A light direction before a Transform effect turns and reflects with the art (y down on the +/// page), to the nearest of the eight directions, the clockwise one at a tie. +#[test] +fn bas_relief_light_follows_rotation_and_reflection() { + assert_eq!(relief_then("top", json!({"rotate": 90})), Some(Light::Left)); + assert_eq!(relief_then("top", json!({"reflectY": true})), Some(Light::Bottom)); + assert_eq!(relief_then("top", json!({"reflectX": true})), Some(Light::Top)); + assert_eq!(relief_then("left", json!({"reflectX": true})), Some(Light::Right)); + // 22.5° clockwise lies halfway between Top and Top Right; 22.5° counterclockwise halfway + // between Top and Top Left. + assert_eq!(relief_then("top", json!({"rotate": -22.5})), Some(Light::TopRight)); + assert_eq!(relief_then("top", json!({"rotate": 22.5})), Some(Light::Top)); + assert_eq!(relief_then("top", json!({"rotate": 30})), Some(Light::TopLeft)); + // A turn of 90° counterclockwise, from each direction. + for (from, to) in [ + ("bottom", Light::Right), + ("bottomLeft", Light::BottomRight), + ("left", Light::Bottom), + ("topLeft", Light::BottomLeft), + ("top", Light::Left), + ("topRight", Light::TopLeft), + ("right", Light::Top), + ("bottomRight", Light::TopRight), + ] { + assert_eq!(relief_then(from, json!({"rotate": 90})), Some(to), "{from}"); + } + // Every filter with a light direction, and Bas Relief's default light (Bottom). + for id in ["artistic.underpainting", "artistic.roughPastels", "sketch.conteCrayon", "sketch.basRelief", "sketch.plaster", "texture.texturizer"] { + let effects = [fx(id, json!({"lightDirection": "top"})), transform(json!({"rotate": 90}))]; + assert_eq!(drawn_light(&effects), Some(Light::Left), "{id}"); + } + assert_eq!(drawn_light(&[fx("sketch.basRelief", json!({})), transform(json!({"rotate": 90}))]), Some(Light::Right)); + // The Transform effects after the filter turn it together, with one rounding at the end. + let twice = [fx("sketch.basRelief", json!({"lightDirection": "top"})), transform(json!({"rotate": -22.5})), transform(json!({"rotate": -22.5}))]; + assert_eq!(drawn_light(&twice), Some(Light::TopRight)); +} + +/// A stroke direction before a Transform effect turns and reflects with the art, to the nearest +/// of the four, a stroke and its reverse being the same, the clockwise one at a tie. +#[test] +fn stroke_axis_follows_transform() { + let strokes = |id: &str, way: Option<&str>, params: Value| { + let p = way.map_or(json!({}), |w| json!({ "strokeDirection": w })); + drawn_strokes(&[fx(id, p), transform(params)]) + }; + let sprayed = "brushStrokes.sprayedStrokes"; + let pen = "sketch.graphicPen"; + assert_eq!(strokes(sprayed, Some("horizontal"), json!({"rotate": 90})), Some(StrokeDirection::Vertical)); + assert_eq!(strokes(sprayed, Some("vertical"), json!({"rotate": 90})), Some(StrokeDirection::Horizontal)); + assert_eq!(strokes(pen, Some("rightDiagonal"), json!({"rotate": 90})), Some(StrokeDirection::LeftDiagonal)); + assert_eq!(strokes(pen, Some("rightDiagonal"), json!({"reflectX": true})), Some(StrokeDirection::LeftDiagonal)); + assert_eq!(strokes(pen, Some("leftDiagonal"), json!({"reflectY": true})), Some(StrokeDirection::RightDiagonal)); + assert_eq!(strokes(pen, None, json!({"rotate": 90})), Some(StrokeDirection::LeftDiagonal)); + assert_eq!(strokes(sprayed, Some("horizontal"), json!({"rotate": -22.5})), Some(StrokeDirection::LeftDiagonal)); + assert_eq!(strokes(sprayed, Some("horizontal"), json!({"rotate": 22.5})), Some(StrokeDirection::Horizontal)); + assert_eq!(strokes(sprayed, Some("horizontal"), json!({"rotate": -44})), Some(StrokeDirection::LeftDiagonal)); + assert_eq!(strokes(sprayed, Some("horizontal"), json!({"rotate": 180})), Some(StrokeDirection::Horizontal)); +} + +fn near4(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-6) +} + +/// Color Halftone's screen angles before a Transform effect turn and reflect with the art. +#[test] +fn halftone_channels_follow_rotation_and_reflection() { + let screens = |p: Value, transforms: &[Value]| { + let mut effects = vec![fx("pixelate.colorHalftone", p)]; + effects.extend(transforms.iter().cloned().map(transform)); + drawn_screens(&effects) + }; + let defaults = screens(json!({}), &[json!({"rotate": 90})]); + assert!(near4(defaults, [198.0, 252.0, 180.0, 135.0]), "{defaults:?}"); + let reflected = screens(json!({"channel1": 45, "channel2": 30, "channel3": 0, "channel4": 270}), &[json!({"reflectX": true})]); + assert!(near4(reflected, [135.0, 150.0, 180.0, 270.0]), "{reflected:?}"); + let flipped = screens(json!({"channel1": 45, "channel2": 30, "channel3": 0, "channel4": 270}), &[json!({"reflectY": true})]); + assert!(near4(flipped, [315.0, 330.0, 0.0, 90.0]), "{flipped:?}"); + // One Transform effect reflects, then turns; two apply in the order they are listed. + let p = json!({"channel1": 10.5, "channel2": -30, "channel3": 0, "channel4": 359.25}); + let one = screens(p.clone(), &[json!({"rotate": 30, "reflectX": true})]); + assert!(near4(one, [199.5, 240.0, 210.0, 210.75]), "{one:?}"); + let two = screens(p, &[json!({"rotate": 30}), json!({"reflectX": true})]); + assert!(near4(two, [139.5, 180.0, 150.0, 150.75]), "{two:?}"); +} + +/// Only the visible Transform effects listed after a filter that move the art once turn its +/// direction; a hidden filter turns too, and a zero scale leaves it as it is. +#[test] +fn only_earlier_visible_single_map_effects_follow() { + let relief = fx("sketch.basRelief", json!({"lightDirection": "top"})); + assert_eq!(drawn_light(&[transform(json!({"rotate": 90})), relief.clone()]), Some(Light::Top)); + for params in [ + json!({"rotate": 90, "objects": false}), + json!({"rotate": 90, "copies": 1}), + json!({"rotate": 90, "random": true}), + json!({"scaleH": 0, "scaleV": 0}), + ] { + assert_eq!(drawn_light(&[relief.clone(), transform(params.clone())]), Some(Light::Top), "{params}"); + } + let mut hidden = transform(json!({"rotate": 90})); + hidden.visible = false; + assert_eq!(drawn_light(&[relief.clone(), hidden]), Some(Light::Top)); + let zero = [fx("pixelate.colorHalftone", json!({"channel1": 20})), transform(json!({"scaleH": 0, "scaleV": 0}))]; + assert!(near4(drawn_screens(&zero), [20.0, 162.0, 90.0, 45.0]), "{:?}", drawn_screens(&zero)); + // A hidden filter kept when the Transform effect is applied draws turned once it is shown. + let mut off = relief; + off.visible = false; + let kept = crate::raster::without_geometry(&[off, transform(json!({"rotate": 90}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert_eq!(drawn_light(&[shown]), Some(Light::Left)); +} + +/// The light direction of every Bas Relief drawn on `n` and the nodes under it, from the object's +/// effects and from each fill's and stroke's. +fn lights_in(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + out.extend(drawn_light(list)); + } + }); + out +} + +/// The canvas, a fill's own effects, a group's members and export turn the light the same way. +#[test] +fn nested_and_baked_directions_match_canvas() { + let relief = || fx("sketch.basRelief", json!({"lightDirection": "top"})); + let turn = || transform(json!({"rotate": 90})); + // A fill's own Transform effect, and the object's Transform effect over its fill. + let fill = fill_square(31, vec![relief(), turn()]); + let item = fill.appearance.items.first().unwrap(); + assert_eq!(drawn_light(item.effects()), Some(Light::Left)); + let mut over = fill_square(32, vec![relief()]); + over.appearance.effects = vec![turn()]; + let item = over.appearance.items.first().unwrap(); + assert_eq!(drawn_light(&crate::item_effects_in(&over.appearance.effects, item.effects())), Some(Light::Left)); + // A fill's own turn of 30° and the object's turn of 30° make 60°, rounded once: Top Left, not + // Left (Top Left after the first turn, then Left after the second). + let thirty = || transform(json!({"rotate": 30})); + let mut both = fill_square(37, vec![relief(), thirty()]); + both.appearance.effects = vec![thirty()]; + let item = both.appearance.items.first().unwrap(); + assert_eq!(drawn_light(&crate::item_effects_in(&both.appearance.effects, item.effects())), Some(Light::TopLeft)); + // A group's Transform effect over a member's own filter and its fill's. + let g = group(33, vec![square(34, vec![relief()]), fill_square(35, vec![relief()])], vec![turn()]); + let art = crate::evaluate_container(None, &g).unwrap(); + assert_eq!(lights_in(&art), vec![Light::Left; 2]); + // Baked for export: the Transform effect is applied and the filter keeps the turned light. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(36, vec![relief(), turn()])).unwrap(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(36)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{:?}", b.appearance.effects); + assert_eq!(drawn_light(&b.appearance.effects), Some(Light::Left)); + for id in [34, 35] { + assert_eq!(lights_in(baked.node(NodeId(id)).unwrap()), vec![Light::Left], "{id}"); + } +} + /// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow /// before the 3D effect, as it does without the 3D effect. #[test] diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 50d0245c12..3bbd803cc2 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -337,6 +337,38 @@ fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() { } } +/// A Transform effect listed after a Bas Relief turns its light with the art: a group of a black +/// square centered on a white one, lit from the top and turned 90° counterclockwise about its +/// center, draws as the group lit from the left. +#[test] +fn a_transform_after_bas_relief_turns_its_light_with_the_art() { + let relieved = |effects: Vec<(&str, serde_json::Value)>| { + let mut d = Document::new(100.0, 100.0); + let mut square = + |r: Rect, c: Color| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0))); + let members = vec![square(Rect::new(20.0, 20.0, 80.0, 80.0), Color::WHITE), square(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK)]; + let mut g = Node::group(d.alloc_id(), members); + g.appearance.effects = effects.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + render(&d) + }; + let relief = |light: &str| ("sketch.basRelief", json!({"lightDirection": light})); + let turned = relieved(vec![relief("top"), ("distort.transform", json!({"rotate": 90}))]); + let left = relieved(vec![relief("left")]); + let top = relieved(vec![relief("top")]); + // The largest difference of a channel between two images. + let diff = |a: &Rendered, b: &Rendered| { + (0..100u32) + .flat_map(|y| (0..100u32).map(move |x| (x, y))) + .map(|(x, y)| a.pixel(x, y).iter().zip(b.pixel(x, y)).map(|(p, q)| p.abs_diff(q)).max().unwrap_or(0)) + .max() + .unwrap_or(0) + }; + assert!(diff(&left, &top) > 50, "the light's direction shows: {}", diff(&left, &top)); + assert!(diff(&turned, &left) <= 2, "turned top light draws as a left light: {}", diff(&turned, &left)); +} + /// An object's or a group's Transform effect transforms the raster effects of what it holds with /// the art: halving a 40 pt square about its center halves the 10 pt offset of its fill's shadow, /// and of a group member's own shadow, with Scale Strokes & Effects off or on. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..4615d556bb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -619,7 +619,7 @@ since 2026-10-01. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. -- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. The light direction of Underpainting, Rough Pastels, Conté Crayon, Bas Relief, Plaster and Texturizer and the stroke direction of Sprayed Strokes and Graphic Pen now turn and reflect with such a Transform effect, each set to the nearest of its menu's choices (at a tie, the one clockwise of it; VectorCraft's own rule), and Color Halftone's four screen angles turn and reflect with the art ([Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html); effects `tests::rasterfollow::bas_relief_light_follows_rotation_and_reflection` and the tests after it). Not yet: filters whose strokes or patterns run along fixed page axes without a direction setting (Angled Strokes, Crosshatch, Mezzotint's lines, Halftone Pattern's lines, Grain's horizontal and vertical types, De-Interlace) keep those axes, and an uneven scale or a shear sets a direction from the one vector it maps, not a skewed pattern. Scores and hours unchanged. - **Raster effects before a Transform effect that makes copies:** the Transform effect can make copies with its transformation applied ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)). Giving each copy the raster effects listed before that effect is VectorCraft's own rule. On a path or compound path, each copy now carries the Drop Shadow, glow, blur, Feather or pixel filter listed before the last Transform effect that makes copies, with its sizes scaled by that copy's mean scale and a Drop Shadow's offset turned, reflected and scaled with the copy; a raster effect listed after it paints once around all the copies. Before, one shadow was drawn under all the copies at the object's own distances. The canvas, the object's bounds, export and Expand Appearance draw the same copies, and copies that stop at the effect budget each keep their effects (effects `tests::rasterfollow::each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it`, `tests::rasterfollow::a_shadow_after_a_copying_transform_paints_once_around_the_copies`, `tests::rasterfollow::copies_past_the_budget_each_keep_their_shadow`; render `objectfx::each_copy_of_a_transform_effect_draws_its_own_shadow`; engine `objexpand::each_copys_shadow_expands_as_the_canvas_draws_it`). A Transform effect without copies draws as before. Not yet: the copies of type, images, symbol instances and groups, and a fill's or stroke's own copying Transform effect, still share one set of raster effects, and so does a path with Rasterize, an SVG filter or another effect that is neither a geometry effect nor one of those raster effects before the copying effect. Scores and hours unchanged. - **Raster effects before a Transform effect past the effect budget:** VectorCraft leaves out a Transform effect whose result would pass its geometry budget of 2 million anchors or 8 million lines drawn, and the art stays where it was. The raster effects listed before that Transform effect now stay with the art too: a Drop Shadow keeps its own offset and blur, and the Transform effects after it that fit still move both. Before, the art stayed while the shadow scaled with the Transform effect it did not take. This is VectorCraft's own rule. It applies to a path's own effects and to its fills' and strokes' own, also when the path is in a group whose geometry effects run after its members' own, on the canvas and in its bounds, in export, Expand Appearance, Object › Expand and Outline Stroke, and a hidden shadow keeps its values once shown (effects `tests::rasterfollow::a_transform_past_the_line_budget_moves_the_art_with_its_shadow`, `tests::rasterfollow::only_transforms_that_ran_move_a_preceding_shadow`, `tests::rasterfollow::a_skipped_item_transform_keeps_its_shadow`, `tests::rasterfollow::the_shape_art_of_a_skipped_transform_keeps_its_shadows`, `tests::rasterfollow::a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects`; render `objectfx::a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn`, `fx::renderer::tests::a_path_paints_from_the_transforms_that_moved_it`; engine `objexpand::a_shadow_before_a_skipped_transform_expands_with_its_own_offset`). Not yet: the shadow of type, an image, a symbol instance or a group whose art a Transform effect leaves in place past the budget still follows that effect. Scores and hours unchanged. - **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged.