mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Blend: Ease In / Ease Out and separate Color Ease in Blend Options (#1124)
Squashed from #2539 onto main.
This commit is contained in:
+68
-10
@@ -579,11 +579,18 @@ impl Lerp {
|
||||
|
||||
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
||||
pub fn at(&self, t: f64) -> Node {
|
||||
self.at_eased(t, t)
|
||||
}
|
||||
|
||||
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
|
||||
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
|
||||
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
|
||||
let (a, b) = (&self.a, &self.b);
|
||||
let base = if t < 0.5 { a } else { b };
|
||||
let mut n = match &self.shape {
|
||||
Shape::Group { children, clip } => {
|
||||
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip });
|
||||
let mut g =
|
||||
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
|
||||
g.isolate = base.isolate;
|
||||
g.knockout = base.knockout;
|
||||
g
|
||||
@@ -593,8 +600,8 @@ impl Lerp {
|
||||
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
|
||||
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
|
||||
p.p = x.p.lerp(y.p, t);
|
||||
p.color = lerp_color(&x.color, &y.color, t as f32);
|
||||
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
|
||||
p.color = lerp_color(&x.color, &y.color, tc as f32);
|
||||
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
|
||||
for h in 0..4 {
|
||||
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
|
||||
}
|
||||
@@ -620,7 +627,7 @@ impl Lerp {
|
||||
Shape::Placed => {
|
||||
let mut n = base.clone();
|
||||
match (&mut n.kind, &a.kind, &b.kind) {
|
||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t),
|
||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
|
||||
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
|
||||
*o = lerp_affine(*x, *y, t)
|
||||
}
|
||||
@@ -657,8 +664,8 @@ impl Lerp {
|
||||
}
|
||||
};
|
||||
n.id = NodeId(0);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
|
||||
n.blend = base.blend;
|
||||
n.visible = true;
|
||||
n.locked = false;
|
||||
@@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
|
||||
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
||||
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
||||
/// the same place (by character) of both.
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
||||
o.xf = lerp_affine(x.xf, y.xf, t);
|
||||
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
||||
(&mut o.kind, &x.kind, &y.kind)
|
||||
@@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
||||
}
|
||||
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
let tf = t as f32;
|
||||
let tf = tc as f32;
|
||||
let mut resized = false;
|
||||
for r in &mut o.runs {
|
||||
let len = r.text.chars().count();
|
||||
@@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
let n = blend_step_count(a, b, spec.spacing, len);
|
||||
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
||||
for j in 1..=n {
|
||||
let t = j as f64 / (n + 1) as f64;
|
||||
let mut s = lerp.at(t);
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
let u = j as f64 / (n + 1) as f64;
|
||||
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
let mut s = lerp.at_eased(t, tc);
|
||||
if let Some(r) = &rail {
|
||||
r.place(&mut s, i, t);
|
||||
}
|
||||
@@ -1134,6 +1143,55 @@ mod tests {
|
||||
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_keeps_the_ends_and_stays_monotone() {
|
||||
use crate::live::{BlendEase, BlendEaseType as K};
|
||||
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
|
||||
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
|
||||
let e = BlendEase { kind, ramp };
|
||||
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
|
||||
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
|
||||
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert_eq!(e.apply(f64::NAN), 0.0);
|
||||
if ramp == 0.0 {
|
||||
assert_eq!(e.apply(0.25), 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
let e = |kind| BlendEase { kind, ramp: 1.0 };
|
||||
assert!(e(K::In).apply(0.25) < 0.25);
|
||||
assert!(e(K::Out).apply(0.25) > 0.25);
|
||||
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
|
||||
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_easing_moves_colours_apart_from_positions() {
|
||||
let rect = |x: f64, c: Color| {
|
||||
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
|
||||
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
|
||||
Arc::new(n)
|
||||
};
|
||||
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
|
||||
let mid = |spec: &BlendSpec| {
|
||||
let out = blend_expand(&keys, spec);
|
||||
let n = &out[1];
|
||||
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
|
||||
};
|
||||
// Position easing runs along a spine: Align to Path gives the blend one.
|
||||
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
|
||||
let (x0, p0) = mid(&base);
|
||||
// Colour easing alone leaves the position and moves the colour.
|
||||
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
|
||||
let (x1, p1) = mid(&colour);
|
||||
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
|
||||
// Position easing alone leaves the colour and moves the position.
|
||||
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
|
||||
let (x2, p2) = mid(&pos);
|
||||
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
|
||||
assert_eq!(mid(&pos).0, x2, "deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_lerp_turns_the_short_way() {
|
||||
let x = Affine::rotate(170f64.to_radians());
|
||||
|
||||
@@ -99,7 +99,7 @@ pub use graph::{
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live_paint::{GapOptions, GapSize};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
|
||||
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
|
||||
AlignToPath,
|
||||
}
|
||||
|
||||
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BlendEaseType {
|
||||
#[default]
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
OutIn,
|
||||
}
|
||||
|
||||
impl BlendEaseType {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::In => "easeIn",
|
||||
Self::Out => "easeOut",
|
||||
Self::InOut => "easeInOut",
|
||||
Self::OutIn => "easeOutIn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendEase {
|
||||
#[serde(default)]
|
||||
pub kind: BlendEaseType,
|
||||
#[serde(default, deserialize_with = "blend_ramp")]
|
||||
pub ramp: f64,
|
||||
}
|
||||
|
||||
impl BlendEase {
|
||||
pub fn is_linear(&self) -> bool {
|
||||
self.ramp <= 0.0
|
||||
}
|
||||
|
||||
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.kind == BlendEaseType::In && self.ramp == 0.0
|
||||
}
|
||||
|
||||
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
|
||||
/// describes the four distributions, not their numeric curves; these curves are original.
|
||||
pub fn apply(&self, u: f64) -> f64 {
|
||||
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let eased = match self.kind {
|
||||
BlendEaseType::In => u * u,
|
||||
BlendEaseType::Out => u * (2.0 - u),
|
||||
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
|
||||
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
|
||||
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
|
||||
};
|
||||
u + r * (eased - u)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendSpec {
|
||||
#[serde(default)]
|
||||
pub spacing: BlendSpacing,
|
||||
#[serde(default)]
|
||||
pub orientation: BlendOrientation,
|
||||
/// Acceleration of the steps' shapes and positions along the spine between two keys.
|
||||
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
|
||||
pub ease: BlendEase,
|
||||
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
|
||||
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
|
||||
pub color_shift: f64,
|
||||
/// Replaced spine. `None` = the straight lines between the key objects' centres.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spine: Option<PathData>,
|
||||
@@ -1757,10 +1820,58 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
|
||||
o
|
||||
}
|
||||
|
||||
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
|
||||
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
|
||||
/// commands still refuse such input (`ease_param`).
|
||||
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
|
||||
}
|
||||
|
||||
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
blend_number(d, 0.0, 1.0)
|
||||
}
|
||||
|
||||
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
blend_number(d, -100.0, 100.0)
|
||||
}
|
||||
|
||||
/// Whether an optional stored color shift has its unchanged default.
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
impl BlendSpec {
|
||||
/// Shift the color transition independently of shape placement. The exponential curve is
|
||||
/// original; the documented behavior specifies the direction, not numeric sample values.
|
||||
pub fn color_position(&self, u: f64) -> f64 {
|
||||
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
|
||||
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
|
||||
/// failing the whole document.
|
||||
#[test]
|
||||
fn blend_easing_numbers_clamp_when_read() {
|
||||
use serde::de::value::{Error, F64Deserializer};
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
|
||||
Reference in New Issue
Block a user