mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 22:22:07 +00:00
Merge pull request #6233 from dotCooCoo/fix/fitted-gradient-turns-with-transform
Effects: fitted gradients turn with the Transform effect
This commit is contained in:
@@ -31,6 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId};
|
||||
pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget};
|
||||
pub use live_shape::{LiveShape, Scaling};
|
||||
pub use opacity::{Knockout, OpacityMask, whiten};
|
||||
pub use ops::{flattens, keeps_gradient_fit};
|
||||
use wire::NodeWire;
|
||||
pub use wire::{MAX_READ_NODES, with_node_budget};
|
||||
|
||||
|
||||
@@ -329,6 +329,10 @@ pub struct Scaling {
|
||||
/// Pattern fills and strokes move with the art (Transform Patterns; General › Transform
|
||||
/// Pattern Tiles is its default): their tiles transform too, instead of staying put.
|
||||
pub patterns: bool,
|
||||
/// Gradients fitted to the object stay fitted under a transform that flattens it to a line or
|
||||
/// a point (no area left), as a Transform effect leaves them, instead of being fixed to their
|
||||
/// fit and flattened to one color with the object.
|
||||
pub keep_fit_when_flat: bool,
|
||||
}
|
||||
|
||||
impl Scaling {
|
||||
|
||||
@@ -431,7 +431,7 @@ impl Node {
|
||||
// Type and symbols in perspective keep looking the same, moved by `a`.
|
||||
self.transform_projection(a);
|
||||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||
if !keeps_gradient_fit(a) {
|
||||
if !keeps_gradient_fit(a) && !(sc.keep_fit_when_flat && flattens(a)) {
|
||||
self.pin_gradients();
|
||||
}
|
||||
let k = Scaling::factor(a);
|
||||
@@ -770,13 +770,24 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
|
||||
fn keeps_gradient_fit(a: Affine) -> bool {
|
||||
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? The
|
||||
/// terms are compared within 1e-12 of the scale (at least of 1). A scale of 0 or less, a turn, a
|
||||
/// reflection and an uneven scale give false, and so does a map that flattens the object
|
||||
/// ([`flattens`]), which callers that keep a fitted gradient there check apart.
|
||||
pub fn keeps_gradient_fit(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-12 * m0.abs().max(1.0);
|
||||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||||
}
|
||||
|
||||
/// Does `a` flatten what it maps to a line or a point (its determinant 0, within 1e-12 of the
|
||||
/// squares of its terms), or is it not finite?
|
||||
pub fn flattens(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let norm = m0 * m0 + m1 * m1 + m2 * m2 + m3 * m3;
|
||||
!a.is_finite() || a.determinant().abs() <= 1e-12 * norm
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
|
||||
/// everywhere for objects without them, such as type, whose boxes stand in).
|
||||
|
||||
@@ -221,7 +221,8 @@ fn assert_round_corners(n: &Node, back: Affine, r: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false };
|
||||
const KEEP_CORNERS: Scaling =
|
||||
Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false, keep_fit_when_flat: false };
|
||||
|
||||
#[test]
|
||||
fn uneven_scales_keep_live_corners_circular() {
|
||||
|
||||
@@ -210,6 +210,139 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo
|
||||
assert_eq!(baked(&fitted, distort()).1.geom, None);
|
||||
}
|
||||
|
||||
/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a
|
||||
/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped.
|
||||
/// Moves and even scales leave it fitted.
|
||||
#[test]
|
||||
fn a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone()))));
|
||||
let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None };
|
||||
// The geometry of the baked fill (`own`: the Transform effect is the fill's own effect).
|
||||
let baked = |kind, effects: &[Effect], own: bool| {
|
||||
let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0);
|
||||
if own {
|
||||
*ap.items[0].effects_mut() = effects.to_vec();
|
||||
}
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
|
||||
if !own {
|
||||
n.appearance.effects = effects.to_vec();
|
||||
}
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let mut found = vec![];
|
||||
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
|
||||
if let Paint::Gradient(g) = c.appearance.fill_paint() {
|
||||
found.push(g.geom);
|
||||
}
|
||||
});
|
||||
assert_eq!(found.len(), 1, "{effects:?}, own {own}");
|
||||
found[0]
|
||||
};
|
||||
let t = |params| fx("distort.transform", params);
|
||||
let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"}));
|
||||
let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]});
|
||||
let linear = GradientKind::Linear;
|
||||
let cases = [
|
||||
// A quarter turn about the center: the left to right ramp runs from the bottom up.
|
||||
(linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))),
|
||||
// Reflected left to right: the ramp runs from right to left.
|
||||
(linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))),
|
||||
// With Scale Strokes & Effects on, the same.
|
||||
(linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))),
|
||||
// Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt.
|
||||
(linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))),
|
||||
// Stretched to twice the width: the circle of radius 50 about the center becomes an
|
||||
// ellipse 100 pt across and 50 pt tall.
|
||||
(GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }),
|
||||
// Turned, then moved one width right by Free Distort: the fixed gradient moves with it.
|
||||
(linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))),
|
||||
];
|
||||
for (kind, effects, want) in cases {
|
||||
for own in [false, true] {
|
||||
let what = format!("{kind:?} {effects:?}, own {own}");
|
||||
let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
|
||||
assert_geom_near(got, want, &what);
|
||||
}
|
||||
}
|
||||
// A move and an even scale are what a refit gives: the gradient stays fitted.
|
||||
for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] {
|
||||
for own in [false, true] {
|
||||
assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}");
|
||||
}
|
||||
}
|
||||
// A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102,
|
||||
// with Scale Strokes & Effects off or on.
|
||||
let stroked = |effects: &[Effect], own: bool| {
|
||||
let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0);
|
||||
let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
|
||||
if own {
|
||||
*stroke.effects_mut() = effects.to_vec();
|
||||
}
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
|
||||
if !own {
|
||||
n.appearance.effects = effects.to_vec();
|
||||
}
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let mut found = vec![];
|
||||
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
|
||||
for item in &c.appearance.items {
|
||||
if let AppearanceItem::Stroke(s) = item
|
||||
&& let Paint::Gradient(g) = &s.paint
|
||||
{
|
||||
found.push(g.geom);
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(found.len(), 1, "{effects:?}, own {own}");
|
||||
found[0]
|
||||
};
|
||||
for strokes in [false, true] {
|
||||
for own in [false, true] {
|
||||
let what = format!("stroke, strokes {strokes}, own {own}");
|
||||
let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
|
||||
assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted:
|
||||
/// mapping it with the effect would flatten it to one color.
|
||||
#[test]
|
||||
fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
// With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them.
|
||||
for (options, strokes) in [
|
||||
(json!({}), 1),
|
||||
(json!({"strokes": true}), 1),
|
||||
(json!({"patterns": true}), 1),
|
||||
(json!({"copies": 2}), 3),
|
||||
(json!({"strokes": true, "copies": 2}), 3),
|
||||
(json!({"patterns": true, "copies": 2}), 3),
|
||||
] {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0));
|
||||
let mut p = json!({"scaleH": 0.0, "rotate": 30.0});
|
||||
if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) {
|
||||
p.extend(o.clone());
|
||||
}
|
||||
n.appearance.effects = vec![fx("distort.transform", p)];
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let mut found = vec![];
|
||||
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
|
||||
for item in &c.appearance.items {
|
||||
if let AppearanceItem::Stroke(s) = item
|
||||
&& let Paint::Gradient(g) = &s.paint
|
||||
{
|
||||
found.push(g.geom);
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(found, vec![None; strokes], "{options}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
|
||||
@@ -247,7 +247,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), copies: 0..1000 (0; each copy transforms the last again; 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; 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)}",
|
||||
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(
|
||||
|
||||
@@ -174,22 +174,32 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
|
||||
(out.to_bezpath(), carry)
|
||||
}
|
||||
|
||||
/// How placed gradients follow geometry effects. The Transform effect without copies maps them as
|
||||
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and
|
||||
/// the Warp effects move and scale them from the object's bounds before the effect to its bounds
|
||||
/// after it. For the other geometry effects it is empty. It does not change gradients fitted to
|
||||
/// the object.
|
||||
/// How gradients follow geometry effects. The Transform effect without copies maps placed
|
||||
/// gradients as it maps the object (with copies, `crate::reshape` gives each copy its own). Free
|
||||
/// Distort and the Warp effects move and scale them from the object's bounds before the effect to
|
||||
/// its bounds after it. For the other geometry effects it is empty. A gradient fitted to the
|
||||
/// object fits the geometry each effect makes, until a Transform effect turns, reflects or scales
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Carry(Vec<CarryStep>);
|
||||
|
||||
/// 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
|
||||
/// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it
|
||||
/// would flatten it to one color ([`vectorcraft_doc::node::flattens`]).
|
||||
pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool {
|
||||
!vectorcraft_doc::node::flattens(m) && !vectorcraft_doc::node::keeps_gradient_fit(m)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum CarryStep {
|
||||
Affine(vectorcraft_geom::Affine),
|
||||
/// Map `m`; `from` is the geometric bounds of the geometry it takes, which a fitted gradient
|
||||
/// fits before `m` moves it (none: fitted gradients stay fitted).
|
||||
Affine { m: vectorcraft_geom::Affine, from: Option<Rect> },
|
||||
/// From box `from` to box `to` (strokes: their stroke-inflated boxes).
|
||||
Boxes {
|
||||
from: Rect,
|
||||
to: Rect,
|
||||
},
|
||||
Boxes { from: Rect, to: Rect },
|
||||
}
|
||||
|
||||
impl Carry {
|
||||
@@ -229,7 +239,7 @@ impl Carry {
|
||||
return Carry::default();
|
||||
}
|
||||
let step = match id {
|
||||
"distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)),
|
||||
"distort.transform" => Some(CarryStep::Affine { m: distort::transform_map(b, p, seed).0, from }),
|
||||
_ => match (from, to) {
|
||||
(Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => {
|
||||
Some(CarryStep::Boxes { from, to })
|
||||
@@ -249,17 +259,32 @@ impl Carry {
|
||||
/// freeform gradient's own points included)? Callers check first, so an appearance shared with
|
||||
/// other objects is not copied for nothing.
|
||||
pub fn moves(&self, ap: &Appearance) -> bool {
|
||||
!self.0.is_empty() && ap.has_positioned_gradient()
|
||||
!self.0.is_empty() && (ap.has_positioned_gradient() || (self.pin().is_some() && ap.has_unplaced_gradient()))
|
||||
}
|
||||
|
||||
/// Move the placed gradients of `ap` along, through each step in turn.
|
||||
/// The first step that fixes gradients fitted to the object: a map a refit would not
|
||||
/// reproduce, on geometry whose bounds are known.
|
||||
fn pin(&self) -> Option<usize> {
|
||||
self.0.iter().position(|s| matches!(s, CarryStep::Affine { m, from: Some(_) } if fixes_fit(*m)))
|
||||
}
|
||||
|
||||
/// Move the gradients of `ap` along. Placed gradients take every step in turn; fitted ones
|
||||
/// are fixed to their fit at the first step [`Self::pin`] finds and take it and the steps
|
||||
/// after it.
|
||||
pub fn apply(&self, ap: &mut Appearance) {
|
||||
if !self.moves(ap) {
|
||||
return;
|
||||
}
|
||||
for s in &self.0 {
|
||||
// The steps move placed gradients only; fitted ones become placed at the pin.
|
||||
let pin = self.pin();
|
||||
for (k, s) in self.0.iter().enumerate() {
|
||||
if Some(k) == pin
|
||||
&& let CarryStep::Affine { from: Some(b), .. } = *s
|
||||
{
|
||||
ap.pin_gradients(b);
|
||||
}
|
||||
match *s {
|
||||
CarryStep::Affine(m) => ap.transform_gradients(m),
|
||||
CarryStep::Affine { m, .. } => ap.transform_gradients(m),
|
||||
CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +598,70 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the
|
||||
/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own
|
||||
/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients
|
||||
/// fitted.
|
||||
#[test]
|
||||
fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_doc::ImageObject;
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
let square = |id: u64, x: f64| {
|
||||
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(fitted.clone(), Paint::None, 0.0)))
|
||||
};
|
||||
let mut image = Node::new(
|
||||
NodeId(4),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
xf: vectorcraft_geom::Affine::translate((30.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
|
||||
// A symbol instance whose symbol is not in a document is measured as the 20 pt square about
|
||||
// its origin: here 22.5..27.5 across and 2.5..7.5 down.
|
||||
let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25);
|
||||
let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at });
|
||||
instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
|
||||
// Ends of each gradient by object id; none for a gradient still fitted.
|
||||
let ends = |params: serde_json::Value| {
|
||||
let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]);
|
||||
inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
|
||||
let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]);
|
||||
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
art.walk(&mut |c: &Node| {
|
||||
if let Paint::Gradient(p) = c.appearance.fill_paint() {
|
||||
out.insert(c.id.0, p.geom.map(|g| (g.start, g.end)));
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
|
||||
// A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp
|
||||
// from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1).
|
||||
let turned = ends(serde_json::json!({"rotate": 90}));
|
||||
let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)];
|
||||
for (id, x0, x1) in want {
|
||||
let got = turned.get(&id).copied().flatten();
|
||||
let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1)));
|
||||
assert!(ok, "object {id}: {got:?} in {turned:?}");
|
||||
}
|
||||
// The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70.
|
||||
let moved = ends(serde_json::json!({"moveH": 30}));
|
||||
let group = moved.get(&10).copied().flatten();
|
||||
assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}");
|
||||
assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
|
||||
@@ -708,7 +708,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
return;
|
||||
}
|
||||
if separate {
|
||||
let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
|
||||
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
|
||||
let made = copy_leaf(leaf, &path, m, copies, scaling, room);
|
||||
used = used.minus(before).plus(made);
|
||||
return;
|
||||
}
|
||||
@@ -718,8 +719,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
return;
|
||||
};
|
||||
used = used.minus(before).plus(Budget::of(&out));
|
||||
if follows && !boxes && leaf.appearance.has_positioned_gradient() {
|
||||
crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance);
|
||||
if follows && !boxes {
|
||||
// A gradient fitted to the path fits the path's own bounds.
|
||||
let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed);
|
||||
if carry.moves(&leaf.appearance) {
|
||||
carry.apply(&mut leaf.appearance);
|
||||
}
|
||||
}
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
@@ -774,6 +779,8 @@ fn transform_leaf(
|
||||
strokes: options.strokes,
|
||||
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
|
||||
patterns: options.patterns,
|
||||
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
|
||||
keep_fit_when_flat: true,
|
||||
..Default::default()
|
||||
};
|
||||
if !options.objects {
|
||||
@@ -1052,7 +1059,7 @@ fn is_member_group(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a
|
||||
/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and
|
||||
/// gradient: the child indices that lead to it from the art, its geometric bounds, and
|
||||
/// whether a path below it that its bounds measure kept its shape.
|
||||
type GroupMark = (Vec<usize>, Option<Rect>, bool);
|
||||
|
||||
@@ -1069,7 +1076,7 @@ fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
at.push(i);
|
||||
let (b, h) = group_marks(c, at, kept, out);
|
||||
if is_member_group(c) && c.appearance.has_positioned_gradient() {
|
||||
if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
|
||||
out.push((at.clone(), b, h));
|
||||
}
|
||||
at.pop();
|
||||
@@ -1223,6 +1230,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
let src = (**c).clone();
|
||||
let placed = |xf: Affine| {
|
||||
let mut one = src.clone();
|
||||
// Gradients fitted to it are fixed to their fit first when a refit would not match.
|
||||
if crate::geometry::fixes_fit(xf) {
|
||||
one.pin_gradients();
|
||||
}
|
||||
match &mut one.kind {
|
||||
NodeKind::Image(im) => im.xf = xf * im.xf,
|
||||
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
|
||||
|
||||
@@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand
|
||||
/// of the fill makes strips that draw the same.
|
||||
#[test]
|
||||
fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
|
||||
// The gray level at (x, y).
|
||||
let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32;
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
for (params, item) in
|
||||
[(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))]
|
||||
{
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false);
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item}));
|
||||
let before = render(&s);
|
||||
if params["rotate"] == 90 {
|
||||
// The white to black ramp runs up or down the turned square, not across it.
|
||||
let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130));
|
||||
let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100));
|
||||
assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}");
|
||||
}
|
||||
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
|
||||
let after = render(&s);
|
||||
let diff = mean_diff(&before, &after);
|
||||
assert!(diff < 1.0, "{params} on item {item}: mean difference {diff}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
|
||||
@@ -22,7 +22,8 @@ pub(super) enum Page {
|
||||
|
||||
/// How art moved from a page to its place in the layers' document transforms: its pattern tiles
|
||||
/// move with it.
|
||||
pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true };
|
||||
pub(super) const PAGE_MOVE: Scaling =
|
||||
Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true, keep_fit_when_flat: false };
|
||||
|
||||
/// The page (`visible`, the file read as its page) and the place of its area in the layers'
|
||||
/// document.
|
||||
|
||||
@@ -857,6 +857,41 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() {
|
||||
assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50));
|
||||
}
|
||||
|
||||
/// The canvas turns and reflects a gradient fitted to the square with its Transform effect, on the
|
||||
/// object and as the fill's own effect.
|
||||
#[test]
|
||||
fn a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas() {
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
let draw = |e: Effect, own: bool| {
|
||||
let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0));
|
||||
let mut n = Node::path(NodeId(0), square, Appearance::basic(fitted.clone(), Paint::None, 0.0));
|
||||
if own {
|
||||
n.appearance.items[0].effects_mut().push(e);
|
||||
} else {
|
||||
n.appearance.effects.push(e);
|
||||
}
|
||||
render(&doc_with(Document::new(100.0, 100.0), n))
|
||||
};
|
||||
let rb = |img: &Rendered, x: u32, y: u32| {
|
||||
let p = img.pixel(x, y);
|
||||
(p[0], p[2])
|
||||
};
|
||||
let red = |(r, b): (u8, u8)| r > 200 && b < 60;
|
||||
let blue = |(r, b): (u8, u8)| b > 200 && r < 60;
|
||||
for own in [false, true] {
|
||||
// A quarter turn: the left (red) to right (blue) ramp runs from the bottom up.
|
||||
let img = draw(fx("distort.transform", json!({"rotate": 90})), own);
|
||||
let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63));
|
||||
assert!(blue(top) && red(bottom), "own {own}: {top:?} {bottom:?}");
|
||||
assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "own {own}: the same color across");
|
||||
// Reflected left to right: blue on the left, red on the right.
|
||||
let img = draw(fx("distort.transform", json!({"reflectX": true})), own);
|
||||
let (left, right) = (rb(&img, 11, 50), rb(&img, 38, 50));
|
||||
assert!(blue(left) && red(right), "own {own}: {left:?} {right:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() {
|
||||
use vectorcraft_color::GradientGeom;
|
||||
|
||||
@@ -558,6 +558,7 @@ since 2026-10-01.
|
||||
- **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.
|
||||
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
|
||||
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
|
||||
- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user