mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Effects: a Transform effect transforms the raster effects listed before it
Effects apply in the order they are listed, so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect paints art that the Transform effect then transforms. VectorCraft drew such an effect at its own distances around the transformed art: a square scaled to 200% kept a 10 pt shadow offset. Now the effect follows the Transform effects after it, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Effects listed after the Transform effect keep their values, and Transform Objects off changes nothing. - raster_effects reads each raster effect through the Transform effects after it, so the canvas, export and the redraw region use the followed values. - Where the geometry effects are applied and removed (Expand Appearance, baking for export, evaluated groups), the raster effects that remain carry the same transformation, hidden ones included. - A Transform effect that makes copies or moves at random leaves the raster effects before it as they are. Rasterize and SVG filters keep their options. Docs: the Transform effect's params text, docs/target-app-parity.md.
This commit is contained in:
@@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
|
||||
fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)),
|
||||
AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)),
|
||||
AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects),
|
||||
AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option<Node> {
|
||||
g.knockout = Knockout::Off;
|
||||
g.knockout_shape = n.knockout_shape;
|
||||
g.extras_mut().mask = n.extras().mask.clone();
|
||||
g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects);
|
||||
Some(g)
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
|
||||
return None;
|
||||
}
|
||||
reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d));
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
Some(m)
|
||||
@@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
Some((g, rule, ctx, m))
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"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), 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)}",
|
||||
"{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), 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)}",
|
||||
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(
|
||||
|
||||
@@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
|
||||
fn without_geometry_effects(n: &mut Node) {
|
||||
if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) {
|
||||
let mut ap = (*n.appearance).clone();
|
||||
ap.effects.retain(|e| !crate::is_geometry(&e.id));
|
||||
ap.effects = crate::raster::without_geometry(&ap.effects);
|
||||
n.appearance = ap.into();
|
||||
}
|
||||
}
|
||||
@@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id));
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
|
||||
if paint {
|
||||
let shapes = member_shapes(doc, &m);
|
||||
let bounds = m.geometric_bounds();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Raster (painted) effects: descriptions for the renderer.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
|
||||
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx, PixelSpace};
|
||||
@@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 {
|
||||
(num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// The visible raster effects of `effects`, in stack order.
|
||||
/// The visible raster effects of `effects`, in stack order, each following the Transform effects
|
||||
/// listed after it ([`followed`]).
|
||||
pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
effects
|
||||
followed(effects)
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| {
|
||||
@@ -426,6 +429,89 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `effects` with each raster effect following the visible Transform effects listed after it
|
||||
/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect
|
||||
/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says.
|
||||
/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform
|
||||
/// effect has been expanded. A raster effect that a Transform effect making copies or moving at
|
||||
/// random follows stays as it is, as does one after the last Transform effect. Borrowed when
|
||||
/// nothing changes.
|
||||
pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> {
|
||||
if !effects.iter().any(|e| e.visible && e.id == TRANSFORM) {
|
||||
return Cow::Borrowed(effects);
|
||||
}
|
||||
let mut out: Option<Vec<Effect>> = None;
|
||||
// The linear part of the Transform effects after the effect at hand, the last one outermost;
|
||||
// `None` once one of them makes copies or moves at random.
|
||||
let mut after = Some(Affine::IDENTITY);
|
||||
for (i, e) in effects.iter().enumerate().rev() {
|
||||
if e.id == TRANSFORM {
|
||||
if !e.visible {
|
||||
continue;
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
// With Transform Objects off the art stays where it is.
|
||||
if flag(&p, "objects", true) {
|
||||
let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0);
|
||||
after = after.filter(|_| copies == 0 && !flag(&p, "random", false)).map(|a| a * linear(map));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
out.map_or(Cow::Borrowed(effects), Cow::Owned)
|
||||
}
|
||||
|
||||
/// `effects` without their geometry effects, each raster effect following the Transform effects
|
||||
/// it loses ([`followed`]): what an object keeps once its geometry effects are applied to its art.
|
||||
pub(crate) fn without_geometry(effects: &[Effect]) -> Vec<Effect> {
|
||||
followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect()
|
||||
}
|
||||
|
||||
/// The Transform effect's id.
|
||||
const TRANSFORM: &str = "distort.transform";
|
||||
|
||||
/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG
|
||||
/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room
|
||||
/// around the art.
|
||||
fn follows(id: &str) -> bool {
|
||||
crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE
|
||||
}
|
||||
|
||||
/// `a` without its translation.
|
||||
fn linear(a: Affine) -> Affine {
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
Affine::new([a0, a1, a2, a3, 0.0, 0.0])
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if !follows(&e.id) {
|
||||
return;
|
||||
}
|
||||
let offset = (e.id == "stylize.dropShadow").then(|| {
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0))
|
||||
});
|
||||
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()) {
|
||||
if !e.params.is_object() {
|
||||
e.params = Value::Object(Map::new());
|
||||
}
|
||||
e.params["x"] = json!(o.x);
|
||||
e.params["y"] = json!(o.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected)
|
||||
/// geometry's, in document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect], bounds: Rect) -> f64 {
|
||||
|
||||
@@ -594,7 +594,7 @@ fn as_art_opts(
|
||||
out.knockout = vectorcraft_doc::Knockout::Off;
|
||||
out.knockout_shape = n.knockout_shape;
|
||||
out.extras_mut().mask = n.extras().mask.clone();
|
||||
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod rasterfollow;
|
||||
mod scale;
|
||||
mod transformfx;
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Raster effects listed before a Transform effect follow it: the Transform effect transforms
|
||||
//! what they paint.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::{RasterFx, bake_document, expand_outlined, raster_effects};
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
fn shadow(x: f64, y: f64, blur: f64) -> Effect {
|
||||
fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur}))
|
||||
}
|
||||
|
||||
fn transform(params: Value) -> Effect {
|
||||
fx("distort.transform", params)
|
||||
}
|
||||
|
||||
/// The offset and blur of the first Drop Shadow `effects` draw.
|
||||
fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) {
|
||||
match raster_effects(effects).first() {
|
||||
Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur),
|
||||
other => panic!("expected a drop shadow, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// A 50 pt red square with `effects`.
|
||||
fn square(id: u64, effects: Vec<Effect>) -> Node {
|
||||
let mut n = Node::path(
|
||||
NodeId(id),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = effects;
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() {
|
||||
for strokes in [false, true] {
|
||||
let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))];
|
||||
assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_shadow_after_a_transform_keeps_its_offset() {
|
||||
let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)];
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() {
|
||||
let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))];
|
||||
assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven));
|
||||
// A turn of 90° counterclockwise on screen takes an offset to the right to one upward.
|
||||
let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))];
|
||||
let (dx, dy, blur) = drawn_shadow(&turned);
|
||||
assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}");
|
||||
let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))];
|
||||
assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_after_the_shadow_apply_in_order() {
|
||||
let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))];
|
||||
let (dx, dy, _) = drawn_shadow(&effects);
|
||||
assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}");
|
||||
// Only the Transform after a shadow moves it.
|
||||
let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)];
|
||||
let drawn = raster_effects(&between);
|
||||
let offsets: Vec<(f64, f64)> = drawn
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() {
|
||||
for params in [
|
||||
json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "copies": 1}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "random": true}),
|
||||
] {
|
||||
let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())];
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects));
|
||||
}
|
||||
let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200}));
|
||||
hidden.visible = false;
|
||||
let effects = [shadow(10.0, 10.0, 4.0), hidden];
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects));
|
||||
}
|
||||
|
||||
/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would
|
||||
/// have before, once it is shown.
|
||||
#[test]
|
||||
fn a_hidden_shadow_follows_the_transform_it_loses() {
|
||||
let mut hidden = shadow(10.0, 10.0, 4.0);
|
||||
hidden.visible = false;
|
||||
let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]);
|
||||
assert_eq!(kept.len(), 1);
|
||||
let mut shown = kept[0].clone();
|
||||
shown.visible = true;
|
||||
assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0)));
|
||||
}
|
||||
|
||||
/// Rasterize keeps its room around the art: its image keeps its own pixel grid.
|
||||
#[test]
|
||||
fn rasterize_before_a_transform_keeps_its_options() {
|
||||
let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6}));
|
||||
let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
assert_eq!(crate::raster::followed(&effects)[0], rasterize);
|
||||
assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glows_blurs_and_feathers_scale_with_the_transform_after_them() {
|
||||
let effects = [
|
||||
fx("stylize.outerGlow", json!({"blur": 5})),
|
||||
fx("stylize.innerGlow", json!({"blur": 5})),
|
||||
fx("stylize.feather", json!({"radius": 5})),
|
||||
fx("blur.gaussian", json!({"radius": 5})),
|
||||
transform(json!({"scaleH": 50, "scaleV": 50})),
|
||||
];
|
||||
let sizes: Vec<f64> = raster_effects(&effects)
|
||||
.iter()
|
||||
.map(|r| match r {
|
||||
RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur,
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sizes, vec![2.5; 4]);
|
||||
// How far the shadow paints past the object follows too.
|
||||
let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() {
|
||||
let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
// A path drawn through its art (Scale Strokes & Effects on), expanded.
|
||||
let mut with_options = effects.clone();
|
||||
with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true}));
|
||||
let n = square(7, with_options);
|
||||
let expanded = expand_outlined(None, &n, None).unwrap();
|
||||
assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects);
|
||||
// A path and a group, baked for export.
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, square(8, effects.clone())).unwrap();
|
||||
let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]);
|
||||
g.appearance.effects = effects.clone();
|
||||
d.insert(Some(layer), 1, g).unwrap();
|
||||
let baked = bake_document(&d).unwrap();
|
||||
for id in [8, 9] {
|
||||
let b = baked.node(NodeId(id)).unwrap();
|
||||
assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects);
|
||||
assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects);
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() {
|
||||
assert_eq!(lum(img.pixel(15, 15)), WHITE);
|
||||
}
|
||||
|
||||
/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a
|
||||
/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects
|
||||
/// off or on, and for a group as for a path.
|
||||
#[test]
|
||||
fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() {
|
||||
let r = Rect::new(20.0, 20.0, 60.0, 60.0);
|
||||
let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}));
|
||||
for strokes in [false, true] {
|
||||
let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes}));
|
||||
let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]);
|
||||
let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]);
|
||||
let l = group.layers[0].id;
|
||||
let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap();
|
||||
group.remove(square.id).unwrap();
|
||||
let mut g = Node::group(group.alloc_id(), vec![square]);
|
||||
g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
|
||||
group.insert(Some(l), 0, g).unwrap();
|
||||
for (what, d) in [("path", path), ("group", group)] {
|
||||
let img = render(&d);
|
||||
// The square now covers 30..50 and its shadow 35..55.
|
||||
assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}");
|
||||
assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37));
|
||||
assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55");
|
||||
// The area redrawn for it takes in the 5 pt shadow around the 30..50 square.
|
||||
let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap();
|
||||
assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and
|
||||
/// right; `shade` "color" or "darkness".
|
||||
fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document {
|
||||
|
||||
@@ -589,6 +589,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, the distances of its later effects 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`). Not yet: object effects after a Transform effect keep their distances. 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)), so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect is transformed with the art, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Before, it was drawn at its own distances around the transformed art. The canvas, export and Expand Appearance agree (effects `tests::rasterfollow`, render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are, and a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. Scores and hours unchanged.
|
||||
- **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim.
|
||||
- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
|
||||
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
|
||||
|
||||
Reference in New Issue
Block a user