mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Effects: raster effects before a Transform effect past the budget stay with the art
A Transform effect whose result would pass the geometry budget (2 million anchors or 8 million lines drawn) is left out and the art stays where it was, but the raster effects listed before it still scaled and moved with it: before a Transform effect that scales the art 1000 times, a Drop Shadow's offset grew 1000 times and its blur went to the 1000 pt limit around art that had not moved, and Expand kept those values. The geometry pass now notes each Transform effect that moves the art once and that it leaves out, in the Carry it already returns, and Carry::hide_skipped hides those effects in the list the raster effects are read from. The canvas (a path's own effects and its fills' and strokes'), its bounds, bake (SVG and PDF export), shape art, Expand, Expand Appearance and Outline Stroke use it, and a fill's Transform effect inside a piece no longer scales the effects before it when the budget leaves it out. The budget itself is unchanged. A path drawn as its own art inside a group whose geometry effects run after its members' own read its object effects whole. The reshaping pass now notes a Transform effect that no path in the art took past the budget, and the member's art hides it before its raster effects are read. The bounds skip the extra geometry pass when no raster effect could follow a Transform effect, and painted_reach runs it once for both measurements.
This commit is contained in:
@@ -83,15 +83,17 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext
|
||||
|
||||
/// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the
|
||||
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
|
||||
/// moved along with them.
|
||||
/// moved along with them and the Transform effects the geometry pass left out hidden
|
||||
/// ([`crate::Carry::hide_skipped`]).
|
||||
fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
|
||||
let (ig, carry) = apply(item_effects(item), g, &ctx.item(item));
|
||||
let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() };
|
||||
if !carry.moves(&ap) {
|
||||
return (ig, carried.clone());
|
||||
if carry.moves(&ap) {
|
||||
carry.apply(&mut ap);
|
||||
}
|
||||
carry.apply(&mut ap);
|
||||
(ig, ap.items.pop().unwrap_or_else(|| carried.clone()))
|
||||
let mut out = ap.items.pop().unwrap_or_else(|| carried.clone());
|
||||
carry.hide_skipped(out.effects_mut());
|
||||
(ig, out)
|
||||
}
|
||||
|
||||
/// A path node of `kind`'s flavour (compound when the result has several subpaths).
|
||||
@@ -470,6 +472,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
carry.hide_skipped(&mut m.appearance.effects);
|
||||
// The raster effects of its fills and strokes follow its Transform effects, as on the canvas.
|
||||
crate::raster::follow_items(&mut m);
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
|
||||
@@ -200,8 +200,12 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
|
||||
/// it unevenly (a map after which a refit would not match the mapped gradient): it is fixed to its
|
||||
/// fit on the geometry that effect takes, and then follows that effect and the ones after it as a
|
||||
/// placed gradient does.
|
||||
///
|
||||
/// It also lists the visible Transform effects that move the art once and that the pass left out,
|
||||
/// their results past the budget, by their index in the effect list it was given: the raster
|
||||
/// effects before such an effect stay as they are, as the art does ([`Self::hide_skipped`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Carry(Vec<CarryStep>);
|
||||
pub struct Carry(Vec<CarryStep>, Vec<usize>);
|
||||
|
||||
/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit
|
||||
/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map
|
||||
@@ -265,7 +269,7 @@ impl Carry {
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
Carry(step.into_iter().collect())
|
||||
Carry(step.into_iter().collect(), vec![])
|
||||
}
|
||||
|
||||
/// Does it leave gradients where they are?
|
||||
@@ -310,6 +314,30 @@ impl Carry {
|
||||
|
||||
pub(crate) fn push(&mut self, more: Carry) {
|
||||
self.0.extend(more.0);
|
||||
self.1.extend(more.1);
|
||||
}
|
||||
|
||||
/// Did the pass leave out a Transform effect that moves the art once ([`Self::hide_skipped`])?
|
||||
pub fn skipped(&self) -> bool {
|
||||
!self.1.is_empty()
|
||||
}
|
||||
|
||||
/// Hide each Transform effect of `effects`, the list the pass was given, that it left out (its
|
||||
/// result past the budget): the art stays where it was, so the raster effects listed before it
|
||||
/// do not follow it ([`crate::raster::followed`]). Other effects stay as they are.
|
||||
pub fn hide_skipped(&self, effects: &mut [Effect]) {
|
||||
for &i in &self.1 {
|
||||
if let Some(e) = effects.get_mut(i).filter(|e| e.id == "distort.transform") {
|
||||
e.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that the Transform effect at index `i` of the pass's list was left out.
|
||||
pub(crate) fn skip(&mut self, i: usize) {
|
||||
if !self.1.contains(&i) {
|
||||
self.1.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +382,13 @@ pub(crate) fn apply_geometry_carrying_within(
|
||||
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
|
||||
}
|
||||
cur = next;
|
||||
} else if let Some(c) = carry.as_deref_mut()
|
||||
&& e.id == "distort.transform"
|
||||
&& !crate::raster::holds_back(e)
|
||||
&& let Some(i) = effects.iter().position(|x| std::ptr::eq(x, e))
|
||||
{
|
||||
// The raster effects before it stay as the art does.
|
||||
c.skip(i);
|
||||
}
|
||||
}
|
||||
cur
|
||||
|
||||
@@ -261,8 +261,17 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
// So do the raster effects of its fills and strokes, by `n`'s Transform effects
|
||||
// ([`crate::follow_items`]).
|
||||
crate::raster::follow_items_of(&mut m, &n.appearance.effects);
|
||||
// ([`crate::follow_items`]) that moved the outline.
|
||||
let mut owner = std::borrow::Cow::Borrowed(n.appearance.effects.as_slice());
|
||||
if carry.skipped() {
|
||||
carry.hide_skipped(owner.to_mut());
|
||||
if m.appearance.effects == n.appearance.effects {
|
||||
let mut ap = (*m.appearance).clone();
|
||||
ap.effects = owner.to_vec();
|
||||
m.appearance = ap.into();
|
||||
}
|
||||
}
|
||||
crate::raster::follow_items_of(&mut m, &owner);
|
||||
}
|
||||
without_geometry_effects(&mut m);
|
||||
m
|
||||
|
||||
@@ -895,9 +895,16 @@ pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option<Node>
|
||||
return None;
|
||||
}
|
||||
let mut art = one_piece(n);
|
||||
reshape_leaves(&mut art, &fx, &n.appearance.effects, doc);
|
||||
let carry = reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
|
||||
transform_items(&mut art, doc);
|
||||
Some(dressed(art, n))
|
||||
let mut art = dressed(art, n);
|
||||
// The raster effects before a Transform effect the path did not take stay as the path does.
|
||||
if carry.skipped() {
|
||||
let mut ran = n.appearance.effects.clone();
|
||||
carry.hide_skipped(&mut ran);
|
||||
art.appearance.effects = crate::raster::without_geometry(&ran);
|
||||
}
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity
|
||||
@@ -997,7 +1004,8 @@ fn hoist_copies(art: &mut Node) {
|
||||
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
|
||||
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
|
||||
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
|
||||
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
|
||||
/// Returns how gradients that paint the art as a whole follow the effects, and the Transform
|
||||
/// effects that nothing in the art took past the budget ([`crate::Carry::skipped`]). A deferred live-effect
|
||||
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
|
||||
/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of
|
||||
/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the
|
||||
@@ -1053,6 +1061,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
|
||||
used = cost;
|
||||
continue;
|
||||
}
|
||||
// A Transform effect that moves the art once and that nothing in it took (past the budget)
|
||||
// is left out ([`crate::Carry::hide_skipped`]), listed by its index in `owner`.
|
||||
if ran.kept
|
||||
&& !ran.took
|
||||
&& e.id == "distort.transform"
|
||||
&& !crate::raster::holds_back(e)
|
||||
&& let Some(i) = owner.iter().position(|x| std::ptr::eq(x, *e))
|
||||
{
|
||||
whole.skip(i);
|
||||
}
|
||||
if let Some(carry) = ran.whole {
|
||||
whole.push(carry);
|
||||
}
|
||||
@@ -1084,6 +1102,8 @@ struct Step<'a> {
|
||||
struct Ran {
|
||||
/// A path in the art kept its shape (past the budget).
|
||||
kept: bool,
|
||||
/// A path, image or symbol instance in the art took the effect.
|
||||
took: bool,
|
||||
/// The linked opacity mask of an object in the art stayed as it was while the paths it masks
|
||||
/// took the effect, or could have ([`Step::reshape_masks`]).
|
||||
masks: bool,
|
||||
@@ -1123,7 +1143,7 @@ impl Step<'_> {
|
||||
depth: usize,
|
||||
) -> Ran {
|
||||
let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed);
|
||||
let stayed = Ran { kept: false, masks: true, short: false, whole: None };
|
||||
let stayed = Ran { kept: false, took: false, masks: true, short: false, whole: None };
|
||||
if let Some(plugin) = painted_plugin(e) {
|
||||
if own || has_linked_mask(art, false) {
|
||||
return stayed;
|
||||
@@ -1137,7 +1157,7 @@ impl Step<'_> {
|
||||
*used = used.minus(before).plus(made);
|
||||
}
|
||||
});
|
||||
return Ran { kept: false, masks: false, short: false, whole: None };
|
||||
return Ran { kept: false, took: true, masks: false, short: false, whole: None };
|
||||
}
|
||||
// A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the
|
||||
// art of its linked mask.
|
||||
@@ -1190,7 +1210,7 @@ impl Step<'_> {
|
||||
// ([`transform_placed`]): a mask's art that holds one can't take another effect as the art
|
||||
// it masks does.
|
||||
if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) {
|
||||
return Ran { kept: false, masks: true, short: false, whole: None };
|
||||
return Ran { kept: false, took: false, masks: true, short: false, whole: None };
|
||||
}
|
||||
// Art of several objects is copied whole, so the copies don't interleave member by member.
|
||||
// The copies of its members carry their linked masks, moved with them.
|
||||
@@ -1200,7 +1220,7 @@ impl Step<'_> {
|
||||
{
|
||||
if !own {
|
||||
*used = copy_art(art, m, copies, copy_scaling(options), max);
|
||||
return Ran { kept: false, masks: false, short: false, whole: None };
|
||||
return Ran { kept: false, took: true, masks: false, short: false, whole: None };
|
||||
}
|
||||
// A mask's art: its copies keep to what the rest of the art leaves of `max`, and the
|
||||
// linked mask of the art itself takes them too. That mask's art stays in `used`, as
|
||||
@@ -1212,7 +1232,7 @@ impl Step<'_> {
|
||||
let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies);
|
||||
let none = HashSet::new();
|
||||
let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth);
|
||||
return Ran { kept: false, masks, short, whole: None };
|
||||
return Ran { kept: false, took: true, masks, short, whole: None };
|
||||
}
|
||||
// A shortfall in the copies matters for the objects a linked mask above them covers, which
|
||||
// the mask would take a different number of copies than, and anywhere in a mask's art.
|
||||
@@ -1223,6 +1243,7 @@ impl Step<'_> {
|
||||
}
|
||||
let counts = |id: NodeId| watch && (own || covered.contains(&id));
|
||||
let mut short = false;
|
||||
let mut took = false;
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let at = leaf_at;
|
||||
leaf_at += 1;
|
||||
@@ -1247,6 +1268,7 @@ impl Step<'_> {
|
||||
if options.reach_appearance() {
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, raster_map.is_some(), None);
|
||||
*used = used.minus(before).plus(made);
|
||||
took |= moved;
|
||||
if moved {
|
||||
// With Transform Objects off the path stays, and its mask takes the
|
||||
// effect's stroke and pattern changes as its own art.
|
||||
@@ -1263,6 +1285,7 @@ impl Step<'_> {
|
||||
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
|
||||
let made = copy_leaf(leaf, &path, m, copies, scaling, room, None);
|
||||
*used = used.minus(before).plus(made);
|
||||
took = true;
|
||||
moved_ids.insert(leaf.id);
|
||||
short |= counts(leaf.id) && copied(leaf) <= copies;
|
||||
return;
|
||||
@@ -1274,6 +1297,7 @@ impl Step<'_> {
|
||||
kept_leaves.insert(at);
|
||||
return;
|
||||
};
|
||||
took = true;
|
||||
// A Transform effect's copies of a path are subpaths of it, each with the path's anchors.
|
||||
if counts(leaf.id)
|
||||
&& let Some((_, copies, _, _)) = transform
|
||||
@@ -1315,6 +1339,8 @@ impl Step<'_> {
|
||||
*art = Node::group(id, vec![Arc::new(one)]);
|
||||
}
|
||||
let placed = if watch { masked_placed(art, own) } else { 0 };
|
||||
// Images and symbol instances take the effect whatever the budget.
|
||||
took = took || placed_count(art) > 0;
|
||||
// Scale Strokes & Effects leaves the raster effects that follow the effect to
|
||||
// [`follow_art`].
|
||||
let mut scaling = copy_scaling(options);
|
||||
@@ -1348,7 +1374,7 @@ impl Step<'_> {
|
||||
}
|
||||
let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art));
|
||||
let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed));
|
||||
Ran { kept: !kept_ids.is_empty(), masks, short, whole }
|
||||
Ran { kept: !kept_ids.is_empty(), took, masks, short, whole }
|
||||
}
|
||||
|
||||
/// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path,
|
||||
@@ -2066,9 +2092,14 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
// effects before this one back from every Transform effect ([`crate::raster::followed`]).
|
||||
let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back);
|
||||
// The effects before the Transform effect that it keeps, each raster effect following the
|
||||
// geometry effects before it ([`crate::raster::without_geometry`]) unless held back.
|
||||
// geometry effects before it that moved the geometry ([`crate::raster::without_geometry`],
|
||||
// [`crate::Carry::hide_skipped`]) unless held back.
|
||||
let mut ran = std::borrow::Cow::Borrowed(before);
|
||||
if carry.skipped() {
|
||||
carry.hide_skipped(ran.to_mut());
|
||||
}
|
||||
let kept_before: Vec<Effect> =
|
||||
if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) };
|
||||
if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(&ran) };
|
||||
if let Some(plugin) = painted_plugin(&fx[at]) {
|
||||
let mut it = item.clone();
|
||||
let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect();
|
||||
@@ -2210,7 +2241,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h
|
||||
carry.apply(&mut leaf.appearance);
|
||||
}
|
||||
set_geometry(leaf, out, rule);
|
||||
follow_piece(leaf, was, from, &e);
|
||||
// A Transform effect left out (past the budget) leaves the path and its raster effects
|
||||
// as they were.
|
||||
if !carry.skipped() {
|
||||
follow_piece(leaf, was, from, &e);
|
||||
}
|
||||
});
|
||||
if holds {
|
||||
held = held.max(j);
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, shapes};
|
||||
|
||||
use crate::{RasterFx, bake_document, expand_outlined, raster_effects};
|
||||
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};
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
@@ -652,3 +653,191 @@ fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed path of `n` curves along the x axis, 2 pt apart, each drawn with 14 lines at 1 px/pt
|
||||
/// and with 425 once scaled 1000 times ([`crate::util::line_estimate`]).
|
||||
fn wavy(n: usize) -> PathData {
|
||||
let anchors = (0..=n)
|
||||
.map(|i| {
|
||||
let x = 2.0 * i as f64;
|
||||
Anchor::with_handles(Point::new(x, 0.0), Point::new(x - 0.5, -20.0), Point::new(x + 0.5, 20.0))
|
||||
})
|
||||
.collect();
|
||||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// A red path `id` along `path` with object effects `effects`.
|
||||
fn path_node(id: u64, path: PathData, effects: Vec<Effect>) -> Node {
|
||||
let mut n = Node::path(NodeId(id), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
|
||||
n.appearance.effects = effects;
|
||||
n
|
||||
}
|
||||
|
||||
/// A Transform effect scaling 1000 times.
|
||||
fn thousandfold() -> Effect {
|
||||
transform(json!({"scaleH": 100000, "scaleV": 100000}))
|
||||
}
|
||||
|
||||
/// `n` baked on its own ([`bake_document`]).
|
||||
fn baked(n: Node) -> Node {
|
||||
let id = n.id;
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
bake_document(&d).unwrap().node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A Transform effect whose result passes the 8 million lines of the effect budget is left out, so
|
||||
/// the art stays where it is, and the shadow before it keeps its offset and blur with it: drawn,
|
||||
/// baked for export, and once shown after baking when it is hidden. One within the budget moves
|
||||
/// and scales both.
|
||||
#[test]
|
||||
fn a_transform_past_the_line_budget_moves_the_art_with_its_shadow() {
|
||||
let path = wavy(30_000);
|
||||
assert!(Budget::EFFECTS.fits(Budget::of(&path)), "{:?}", Budget::of(&path));
|
||||
let candidate = Budget::of(&path.transformed(Affine::scale(1000.0)));
|
||||
assert!(candidate.lines > MAX_EFFECT_LINES, "{candidate:?}");
|
||||
let b = path.bounds().unwrap();
|
||||
let n = path_node(30, path.clone(), vec![shadow(10.0, 5.0, 4.0), thousandfold()]);
|
||||
let (g, carry) = apply_geometry_carrying(&n.appearance.effects, &path, b, &GeomContext::of(&n));
|
||||
assert_eq!(g, path, "the art stays where it is");
|
||||
assert!(carry.skipped());
|
||||
let mut drawn = n.appearance.effects.clone();
|
||||
carry.hide_skipped(&mut drawn);
|
||||
assert!(close(drawn_shadow(&drawn), (10.0, 5.0, 4.0)), "drawn {:?}", drawn_shadow(&drawn));
|
||||
let out = baked(n.clone());
|
||||
assert_eq!(out.geometric_bounds(), Some(b));
|
||||
assert!(close(drawn_shadow(&out.appearance.effects), (10.0, 5.0, 4.0)), "baked {:?}", drawn_shadow(&out.appearance.effects));
|
||||
// A hidden shadow keeps its values too, once shown.
|
||||
let mut hidden = shadow(10.0, 5.0, 4.0);
|
||||
hidden.visible = false;
|
||||
let out = baked(path_node(31, path.clone(), vec![hidden, thousandfold()]));
|
||||
let mut shown = out.appearance.effects.clone();
|
||||
shown.iter_mut().for_each(|e| e.visible = true);
|
||||
assert!(close(drawn_shadow(&shown), (10.0, 5.0, 4.0)), "shown {:?}", drawn_shadow(&shown));
|
||||
// A fill's own shadow before its own Transform effect stays too.
|
||||
let mut fill = path_node(35, path.clone(), vec![]);
|
||||
if let Some(item) = fill.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![shadow(10.0, 5.0, 4.0), thousandfold()];
|
||||
}
|
||||
let out = baked(fill);
|
||||
assert!(near(&shadows_in(&out), &[(10.0, 5.0)]), "baked fill {:?}", shadows_in(&out));
|
||||
// Within the budget, both move.
|
||||
let doubled = path_node(32, path.clone(), vec![shadow(10.0, 5.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]);
|
||||
let (g, carry) = apply_geometry_carrying(&doubled.appearance.effects, &path, b, &GeomContext::of(&doubled));
|
||||
assert!(g != path && !carry.skipped());
|
||||
let out = baked(doubled);
|
||||
assert!(close(drawn_shadow(&out.appearance.effects), (20.0, 10.0, 8.0)), "baked {:?}", drawn_shadow(&out.appearance.effects));
|
||||
}
|
||||
|
||||
/// The art of a path whose Transform effect is left out past the budget ([`crate::shape_art`],
|
||||
/// as a group's or a mask's member is drawn) keeps the object's shadow and its fill's shadow with
|
||||
/// their own offsets.
|
||||
#[test]
|
||||
fn the_shape_art_of_a_skipped_transform_keeps_its_shadows() {
|
||||
let mut n = path_node(34, wavy(30_000), vec![shadow(10.0, 5.0, 4.0), thousandfold()]);
|
||||
if let Some(item) = n.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![shadow(1.0, 0.5, 0.0)];
|
||||
}
|
||||
let art = crate::shape_art(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[(10.0, 5.0), (1.0, 0.5)]), "{:?}", shadows_in(&art));
|
||||
}
|
||||
|
||||
/// A path whose own Transform effect is left out past the budget, in a group whose geometry effect
|
||||
/// (a move, which leaves offsets as they are) runs after its members' own, is drawn with its shadow
|
||||
/// at its own offset; one within the budget scales the shadow with the path.
|
||||
#[test]
|
||||
fn a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects() {
|
||||
for (last, want) in [(thousandfold(), (10.0, 5.0)), (transform(json!({"scaleH": 200, "scaleV": 200})), (20.0, 10.0))] {
|
||||
let member = path_node(38, wavy(30_000), vec![shadow(10.0, 5.0, 4.0), last.clone()]);
|
||||
let g = group(37, vec![member], vec![transform(json!({"moveH": 5, "moveV": 5}))]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
assert!(near(&shadows_in(&node_in(&art, 38)), &[want]), "{last:?}: {:?}", shadows_in(&node_in(&art, 38)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Of two Transform effects after a shadow, the shadow follows only the ones that ran: the first
|
||||
/// left out past the budget, or hidden, and the second within it; and both when the first's
|
||||
/// result is exactly at the limit.
|
||||
#[test]
|
||||
fn only_transforms_that_ran_move_a_preceding_shadow() {
|
||||
let circle = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let b = circle.bounds().unwrap();
|
||||
let doubled = Budget::of(&circle.transformed(Affine::scale(2.0))).lines;
|
||||
assert!(doubled > Budget::of(&circle).lines);
|
||||
let run = |effects: &[Effect], lines: usize| {
|
||||
let mut carry = Carry::default();
|
||||
apply_geometry_carrying_within(effects, &circle, b, &GeomContext::default(), Budget { anchors: 1000, lines }, Some(&mut carry));
|
||||
let mut drawn = effects.to_vec();
|
||||
carry.hide_skipped(&mut drawn);
|
||||
drawn_shadow(&drawn)
|
||||
};
|
||||
let effects = vec![shadow(10.0, 0.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))];
|
||||
// Turned 90° counterclockwise on screen, (10, 0) goes to (0, -10).
|
||||
let only_turned = run(&effects, doubled - 1);
|
||||
assert!(only_turned.0.abs() < 1e-9 && (only_turned.1 + 10.0).abs() < 1e-9 && (only_turned.2 - 4.0).abs() < 1e-9, "{only_turned:?}");
|
||||
let both = run(&effects, doubled);
|
||||
assert!(both.0.abs() < 1e-9 && (both.1 + 20.0).abs() < 1e-9 && (both.2 - 8.0).abs() < 1e-9, "{both:?}");
|
||||
let mut hidden = effects.clone();
|
||||
hidden[1].visible = false;
|
||||
let turned = run(&hidden, doubled);
|
||||
assert!(turned.0.abs() < 1e-9 && (turned.1 + 10.0).abs() < 1e-9 && (turned.2 - 4.0).abs() < 1e-9, "{turned:?}");
|
||||
}
|
||||
|
||||
/// A fill's own Transform effect left out past the budget, after a Transform effect of that fill
|
||||
/// that makes it a piece of its own (Transform Patterns), leaves the piece and the shadow before it
|
||||
/// as they were. One within the budget scales both.
|
||||
#[test]
|
||||
fn a_skipped_item_transform_keeps_its_shadow() {
|
||||
let path = wavy(30_000);
|
||||
let b = path.bounds().unwrap();
|
||||
for (last, want) in [(thousandfold(), (10.0, 5.0)), (transform(json!({"scaleH": 200, "scaleV": 200})), (20.0, 10.0))] {
|
||||
let mut n = path_node(33, path.clone(), vec![]);
|
||||
if let Some(item) = n.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![transform(json!({"patterns": true})), shadow(10.0, 5.0, 4.0), last.clone()];
|
||||
}
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[want]), "{last:?}: {:?}", shadows_in(&art));
|
||||
if want == (10.0, 5.0) {
|
||||
assert_eq!(art.geometric_bounds(), Some(b), "the piece stays where it is");
|
||||
}
|
||||
// The same with the Transform effect before the one that makes the piece.
|
||||
let mut n = path_node(36, path.clone(), vec![]);
|
||||
if let Some(item) = n.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![shadow(10.0, 5.0, 4.0), last.clone(), transform(json!({"patterns": true}))];
|
||||
}
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[want]), "{last:?} first: {:?}", shadows_in(&art));
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's Transform effect moves each member's shadow as it moves that member: the member whose
|
||||
/// result would pass what the budget leaves keeps its shape and its shadow, and the other takes
|
||||
/// both.
|
||||
#[test]
|
||||
fn a_group_transform_follows_only_the_members_it_moved() {
|
||||
let small = path_node(41, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), vec![shadow(1.0, 0.5, 0.0)]);
|
||||
let large = path_node(42, wavy(30_000), vec![shadow(1.0, 0.5, 0.0)]);
|
||||
let g = group(40, vec![small, large], vec![thousandfold()]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
assert!(near(&shadows_in(&node_in(&art, 41)), &[(1000.0, 500.0)]), "{:?}", shadows_in(&node_in(&art, 41)));
|
||||
assert!(near(&shadows_in(&node_in(&art, 42)), &[(1.0, 0.5)]), "{:?}", shadows_in(&node_in(&art, 42)));
|
||||
}
|
||||
|
||||
/// A Transform effect that makes copies, or has Transform Objects off, keeps its own rule for the
|
||||
/// shadow before it, past the budget or not: the shadow stays as it is, and the pass does not
|
||||
/// report it as left out.
|
||||
#[test]
|
||||
fn copying_and_object_disabled_transforms_keep_their_existing_shadow_rules() {
|
||||
let path = wavy(30_000);
|
||||
let b = path.bounds().unwrap();
|
||||
for t in [
|
||||
transform(json!({"scaleH": 100000, "scaleV": 100000, "copies": 1})),
|
||||
transform(json!({"scaleH": 100000, "scaleV": 100000, "objects": false})),
|
||||
] {
|
||||
let effects = vec![shadow(10.0, 5.0, 4.0), t.clone()];
|
||||
let (_, carry) = apply_geometry_carrying(&effects, &path, b, &GeomContext::default());
|
||||
assert!(!carry.skipped(), "{t:?}");
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 5.0, 4.0)), "{t:?}: {:?}", drawn_shadow(&effects));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +606,9 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
// So do the raster effects of its fills and strokes.
|
||||
// So do the raster effects of it and of its fills and strokes, by the Transform effects that
|
||||
// moved it.
|
||||
carry.hide_skipped(&mut m.appearance.effects);
|
||||
vectorcraft_render::effects::follow_items(&mut m);
|
||||
m.appearance.effects = without_geometry(&m.appearance.effects);
|
||||
Some(m)
|
||||
@@ -634,7 +636,8 @@ pub(crate) fn fill_shape(
|
||||
if carry.moves(&one) {
|
||||
carry.apply(&mut one);
|
||||
}
|
||||
let moved = one.items.pop().unwrap_or_else(|| item.clone());
|
||||
let mut moved = one.items.pop().unwrap_or_else(|| item.clone());
|
||||
carry.hide_skipped(moved.effects_mut());
|
||||
Some((with_path(l, outline.clone()), outline, b, moved))
|
||||
}
|
||||
|
||||
|
||||
@@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke(
|
||||
/// geometry effects leave nothing to paint, an empty group takes the path's place.
|
||||
/// `None` when `l` has no stroke to outline.
|
||||
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, has_transform_art, reshape, without_geometry};
|
||||
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
|
||||
return None;
|
||||
}
|
||||
@@ -983,11 +983,16 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = st.clone();
|
||||
plain.effects = without_geometry(&st.effects);
|
||||
let stroked = match path.bounds() {
|
||||
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
|
||||
Some(b) if has_geometry(&st.effects) => {
|
||||
let (stroked, carry) = apply_geometry_carrying(&st.effects, &path, b, &ctx.item(item));
|
||||
// Its raster effects follow only the Transform effects that moved it.
|
||||
carry.hide_skipped(&mut plain.effects);
|
||||
stroked
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
plain.effects = without_geometry(&plain.effects);
|
||||
parts.extend(outlined_stroke(d, brushes, &stroked, rule, &plain));
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1517,3 +1517,54 @@ fn expand_info_counts_two_fills_but_not_a_plain_rectangle() {
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "two fills");
|
||||
}
|
||||
|
||||
/// A Transform effect left out past the effect budget (its result over 8 million lines drawn)
|
||||
/// leaves the art where it is, and Expand, Expand Appearance and Outline Stroke keep the drop
|
||||
/// shadow before it with its own offset and blur, as the canvas draws it: the object's, the fill's
|
||||
/// and the stroke's.
|
||||
#[test]
|
||||
fn a_shadow_before_a_skipped_transform_expands_with_its_own_offset() {
|
||||
use vectorcraft_doc::{Appearance, Effect};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, SubPath};
|
||||
let anchors = (0..=30_000)
|
||||
.map(|i| {
|
||||
let x = 2.0 * f64::from(i);
|
||||
Anchor::with_handles(Point::new(x, 0.0), Point::new(x - 0.5, -20.0), Point::new(x + 0.5, 20.0))
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(SubPath::new(anchors, true));
|
||||
let bounds = path.bounds().unwrap();
|
||||
let effects = || {
|
||||
vec![
|
||||
Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10, "y": 5, "blur": 4}), visible: true },
|
||||
Effect { id: "distort.transform".into(), params: json!({"scaleH": 100000, "scaleV": 100000}), visible: true },
|
||||
]
|
||||
};
|
||||
let shadow = |effects: &[Effect]| match vectorcraft_render::effects::raster_effects(effects).first() {
|
||||
Some(vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur),
|
||||
other => panic!("expected a drop shadow, got {other:?}"),
|
||||
};
|
||||
let mut d = vectorcraft_doc::Document::new(200.0, 200.0);
|
||||
let basic = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0);
|
||||
let mut object = Node::path(NodeId(1), path.clone(), basic.clone());
|
||||
object.appearance.effects = effects();
|
||||
let m = crate::cmd::expand::reshaped(&d, &object).unwrap();
|
||||
assert_eq!(m.geometric_bounds(), Some(bounds), "the art stays where it is");
|
||||
assert_eq!(shadow(&m.appearance.effects), (10.0, 5.0, 4.0), "the object's shadow");
|
||||
let mut fill = Node::path(NodeId(2), path.clone(), basic.clone());
|
||||
*fill.appearance.items[0].effects_mut() = effects();
|
||||
let (_, _, _, moved) = crate::cmd::expand::fill_shape(&d, &fill, &fill.appearance.items[0], &path, bounds).unwrap();
|
||||
assert_eq!(shadow(moved.effects()), (10.0, 5.0, 4.0), "the fill's shadow");
|
||||
let mut stroke = Node::path(NodeId(3), path.clone(), basic);
|
||||
*stroke.appearance.items[1].effects_mut() = effects();
|
||||
let outlined = crate::cmd::pathops::outline_strokes(&mut d, &[], &stroke).unwrap();
|
||||
let mut drawn = vec![];
|
||||
outlined.walk(&mut |n| {
|
||||
for item in &n.appearance.items {
|
||||
if !vectorcraft_render::effects::raster_effects(item.effects()).is_empty() {
|
||||
drawn.push(shadow(item.effects()));
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(drawn, [(10.0, 5.0, 4.0)], "the stroke's shadow");
|
||||
}
|
||||
|
||||
@@ -69,12 +69,23 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
}
|
||||
let bp = node_bezpath(n)?;
|
||||
let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) };
|
||||
let g = effected_path(symbols.document(), n, &bp);
|
||||
let (g, carry) = effected_path_carrying(symbols.document(), n, &bp);
|
||||
// The raster effects reach as far as they are drawn: following only the Transform effects
|
||||
// that moved the art ([`effects::Carry::hide_skipped`]).
|
||||
let mut ran = std::borrow::Cow::Borrowed(n);
|
||||
if carry.skipped() {
|
||||
carry.hide_skipped(&mut ran.to_mut().appearance.effects);
|
||||
}
|
||||
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
|
||||
for item in &n.appearance.items {
|
||||
for (j, item) in n.appearance.items.iter().enumerate() {
|
||||
let fx = item_effects(item);
|
||||
if effects::has_geometry(fx) {
|
||||
let ig = apply(fx, &g, &ctx.item(item));
|
||||
let (ig, c) = apply_carrying(fx, &g, &ctx.item(item));
|
||||
if c.skipped()
|
||||
&& let Some(it) = ran.to_mut().appearance.items.get_mut(j)
|
||||
{
|
||||
c.hide_skipped(it.effects_mut());
|
||||
}
|
||||
if !ig.elements().is_empty() {
|
||||
let b = ig.bounding_box();
|
||||
r = Some(r.map_or(b, |r| r.union(b)));
|
||||
@@ -82,7 +93,44 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
}
|
||||
}
|
||||
let s = n.appearance.outset();
|
||||
r.map(|r| with_raster_reach(symbols, n, r.inflate(s, s)))
|
||||
r.map(|r| with_raster_reach(symbols, &ran, r.inflate(s, s)))
|
||||
}
|
||||
|
||||
/// `n` with the Transform effects its geometry passes leave out (past the budget) hidden, in its
|
||||
/// own list and in its fills' and strokes' ([`effects::Carry::hide_skipped`]): the effects its
|
||||
/// raster effects are drawn from. Borrowed when it has no Transform effect or no raster effect, is
|
||||
/// not a path or compound path, or its art is made by [`effects::reshape`] or a 3D effect.
|
||||
fn ran<'a>(doc: &Document, n: &'a Node) -> std::borrow::Cow<'a, Node> {
|
||||
let transforms = |fx: &[Effect]| fx.iter().any(|e| e.visible && e.id == "distort.transform");
|
||||
let mut out = std::borrow::Cow::Borrowed(n);
|
||||
if (!transforms(&n.appearance.effects) && !n.appearance.items.iter().any(|i| transforms(item_effects(i))))
|
||||
|| (effects::raster_effects(&n.appearance.effects).is_empty()
|
||||
&& n.appearance.items.iter().all(|i| effects::raster_effects(item_effects(i)).is_empty()))
|
||||
|| effects::has_painted_effects(n)
|
||||
|| effects::has_revolve(n)
|
||||
|| effects::has_solid(n)
|
||||
{
|
||||
return out;
|
||||
}
|
||||
let Some(bp) = node_bezpath(n) else { return out };
|
||||
let ctx = GeomContext { doc: Some(doc), ..GeomContext::of(n) };
|
||||
let (g, carry) = apply_carrying(&n.appearance.effects, &bp, &ctx);
|
||||
if carry.skipped() {
|
||||
carry.hide_skipped(&mut out.to_mut().appearance.effects);
|
||||
}
|
||||
for (j, item) in n.appearance.items.iter().enumerate() {
|
||||
let fx = item_effects(item);
|
||||
if !transforms(fx) || g.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (_, c) = apply_carrying(fx, &g, &ctx.item(item));
|
||||
if c.skipped()
|
||||
&& let Some(it) = out.to_mut().appearance.items.get_mut(j)
|
||||
{
|
||||
c.hide_skipped(it.effects_mut());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `r`, the art of `n` with its strokes, grown by how far the raster effects of `n`'s fills and
|
||||
@@ -176,13 +224,37 @@ fn paints(item: &AppearanceItem) -> bool {
|
||||
/// [`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<Rect> {
|
||||
drawn_bounds_ran(symbols, n, None)
|
||||
}
|
||||
|
||||
/// [`drawn_bounds`], with `ran`, [`ran`] of `n`, when the caller has it.
|
||||
fn drawn_bounds_ran(symbols: &SymbolBoxes, n: &Node, ran: Option<&Node>) -> Option<Rect> {
|
||||
// A 3D effect's art holds its fills' and strokes' raster effects, measured with it.
|
||||
if let Some(b) = three_d_bounds(symbols, n) {
|
||||
return b;
|
||||
}
|
||||
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_raster(n, i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
|
||||
let mut items =
|
||||
n.appearance.items.iter().enumerate().filter(|(_, i)| paints(i) && !effects::raster_effects(item_effects(i)).is_empty()).peekable();
|
||||
if items.peek().is_none() {
|
||||
return Some(b);
|
||||
}
|
||||
let own;
|
||||
let ran = match ran {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
own = self::ran(symbols.document(), n);
|
||||
&*own
|
||||
}
|
||||
};
|
||||
Some(
|
||||
items
|
||||
.map(|(j, i)| {
|
||||
let r = ran.appearance.items.get(j).unwrap_or(i);
|
||||
spread(symbols, &item_raster(ran, r), item_bounds(symbols.document(), 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
|
||||
@@ -363,7 +435,9 @@ pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
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))
|
||||
let ran = ran(symbols.document(), n);
|
||||
let b = drawn_bounds_ran(symbols, n, Some(&ran))?;
|
||||
Some(spread(symbols, &ran.appearance.effects, b))
|
||||
}
|
||||
|
||||
/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping
|
||||
|
||||
@@ -676,6 +676,13 @@ impl Renderer {
|
||||
ap.items.pop()
|
||||
});
|
||||
let item = carried.as_ref().and_then(Option::as_ref).unwrap_or(item);
|
||||
// Its raster effects follow only the Transform effects that moved it.
|
||||
let ran = carry.skipped().then(|| {
|
||||
let mut it = item.clone();
|
||||
carry.hide_skipped(it.effects_mut());
|
||||
it
|
||||
});
|
||||
let item = ran.as_ref().unwrap_or(item);
|
||||
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
|
||||
let rfx = effects::raster_effects(&item_raster(n, item));
|
||||
if rfx.is_empty() {
|
||||
|
||||
@@ -381,7 +381,8 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
let gctx = GeomContext { doc: Some(f.symbols.document()), ..GeomContext::of(paints) };
|
||||
let rfx = effects::raster_effects(&n.appearance.effects);
|
||||
// `paints` hides the Transform effects that did not move `g`.
|
||||
let rfx = effects::raster_effects(&paints.appearance.effects);
|
||||
// Document-space reach of the painted geometry (strokes, arrowheads…).
|
||||
let outset = n.appearance.outset();
|
||||
let reach = g.bounding_box().inflate(outset, outset);
|
||||
@@ -926,12 +927,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with the gradients of its fills and strokes moved along with its effects by `carry`, or
|
||||
/// `None` when they stay where they are.
|
||||
/// `n` with the gradients of its fills and strokes moved along with its effects by `carry`, and
|
||||
/// the Transform effects the geometry pass left out hidden ([`effects::Carry::hide_skipped`]), so
|
||||
/// its raster effects and those of its fills and strokes stay with its art; `None` when it stays
|
||||
/// as it is.
|
||||
fn carried(n: &Node, carry: &effects::Carry) -> Option<Arc<Node>> {
|
||||
carry.moves(&n.appearance).then(|| {
|
||||
let moves = carry.moves(&n.appearance);
|
||||
(moves || carry.skipped()).then(|| {
|
||||
let mut m = n.clone();
|
||||
carry.apply(&mut m.appearance);
|
||||
if moves {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
carry.hide_skipped(&mut m.appearance.effects);
|
||||
Arc::new(m)
|
||||
})
|
||||
}
|
||||
@@ -943,6 +950,42 @@ mod tests {
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{Document, color::BlendMode};
|
||||
|
||||
/// The node a path is painted from hides a Transform effect left out past the effect budget,
|
||||
/// so the drop shadow before it is drawn with its own offset and blur, as the art stays.
|
||||
#[test]
|
||||
fn a_path_paints_from_the_transforms_that_moved_it() {
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, SubPath};
|
||||
let anchors = (0..=30_000)
|
||||
.map(|i| {
|
||||
let x = 2.0 * f64::from(i);
|
||||
Anchor::with_handles(Point::new(x, 0.0), Point::new(x - 0.5, -20.0), Point::new(x + 0.5, 20.0))
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(SubPath::new(anchors, true));
|
||||
let mut n = Node::path(
|
||||
vectorcraft_doc::NodeId(1),
|
||||
path.clone(),
|
||||
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vectorcraft_color::Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![
|
||||
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 10, "y": 5, "blur": 4}), visible: true },
|
||||
vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"scaleH": 100000, "scaleV": 100000}),
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
let doc = Document::new(100.0, 100.0);
|
||||
let bp = path.to_bezpath();
|
||||
let (g, carry) = effected_path_carrying(&doc, &n, &bp);
|
||||
assert_eq!(g.bounding_box(), bp.bounding_box(), "the art stays where it is");
|
||||
let paints = carried(&n, &carry).expect("the Transform effect is hidden");
|
||||
match effects::raster_effects(&paints.appearance.effects).first() {
|
||||
Some(RasterFx::DropShadow { dx, dy, blur, .. }) => assert!((*dx, *dy, *blur) == (10.0, 5.0, 4.0), "{dx} {dy} {blur}"),
|
||||
other => panic!("expected a drop shadow, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncached_darkness_shadow_subtracts_before_separate_opacity() {
|
||||
let doc = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -1178,3 +1178,44 @@ fn symbol_instances_with_effects_are_charged_to_the_symbol_budget() {
|
||||
assert!(!r.charge_fx_instance(&d, &inst), "the next does not");
|
||||
assert_eq!(r.symbol_nodes, vectorcraft_doc::MAX_SYMBOL_NODES);
|
||||
}
|
||||
|
||||
/// A closed path of 30,000 curves along the x axis, 2 pt apart: drawn with about 420,000 lines,
|
||||
/// and with more than the effect budget's 8 million once scaled 1000 times.
|
||||
fn wavy() -> vectorcraft_geom::PathData {
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||||
let anchors = (0..=30_000)
|
||||
.map(|i| {
|
||||
let x = 2.0 * f64::from(i);
|
||||
Anchor::with_handles(Point::new(x, 0.0), Point::new(x - 0.5, -20.0), Point::new(x + 0.5, 20.0))
|
||||
})
|
||||
.collect();
|
||||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// A Transform effect left out past the effect budget leaves the art where it is, and the drop
|
||||
/// shadow before it (the object's, or the fill's before the fill's own Transform effect) reaches
|
||||
/// as far as it is drawn: by its own offset, not by a thousand times it.
|
||||
#[test]
|
||||
fn a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn() {
|
||||
let shadow = fx("stylize.dropShadow", json!({"x": 10, "y": 5, "blur": 0}));
|
||||
let big = fx("distort.transform", json!({"scaleH": 100000, "scaleV": 100000}));
|
||||
let path = wavy();
|
||||
let art = path.bounds().unwrap();
|
||||
let mut object = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
|
||||
let mut fill = object.clone();
|
||||
object.appearance.effects = vec![shadow.clone(), big.clone()];
|
||||
if let Some(item) = fill.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![shadow, big];
|
||||
}
|
||||
let d = Document::new(100.0, 100.0);
|
||||
let symbols = d.symbol_boxes();
|
||||
// Within the shadow's 10 pt on every side (the visual bounds grow each side by the most any
|
||||
// side reaches).
|
||||
let within = |b: Rect| b.x0 >= art.x0 - 11.0 && b.y0 >= art.y0 - 11.0 && b.x1 <= art.x1 + 11.0 && b.y1 <= art.y1 + 11.0;
|
||||
for (what, n) in [("object", &object), ("fill", &fill)] {
|
||||
let b = crate::fx::visual_bounds(&symbols, n).unwrap();
|
||||
assert!(within(b), "{what}: visual {b:?} for art {art:?}");
|
||||
let b = crate::fx::painted_reach(&symbols, n).unwrap();
|
||||
assert!(within(b), "{what}: painted {b:?} for art {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +607,7 @@ since 2026-10-01.
|
||||
- **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 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.
|
||||
- **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and 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)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. 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.
|
||||
|
||||
Reference in New Issue
Block a user