Blend: Ease In / Ease Out and separate Color Ease in Blend Options (#1124)

Squashed from #2539 onto main.
This commit is contained in:
bflatastic
2026-10-22 04:40:10 +07:00
parent cc42a333b7
commit 92f295d2a2
36 changed files with 534 additions and 24 deletions
+68 -10
View File
@@ -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());
+1 -1
View File
@@ -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;
+111
View File
@@ -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.
+49 -7
View File
@@ -10,8 +10,8 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::live::{
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
PreserveShape, Spine,
self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
GradientMesh, MeshAppearance, PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Blend"],
Some("Cmd+Alt+B"),
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
has_doc,
blend_make
),
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Options…",
["Object", "Blend"],
None,
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
always,
blend_options
),
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Info",
[],
None,
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
always,
blend_info
),
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
}
}
/// Blend panel Type and Ramp, validated before any document mutation.
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
None => current.kind,
Some(v) => match v.as_str() {
Some("easeIn") => BlendEaseType::In,
Some("easeOut") => BlendEaseType::Out,
Some("easeInOut") => BlendEaseType::InOut,
Some("easeOutIn") => BlendEaseType::OutIn,
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
},
};
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
}
/// Bounded percentage input; missing or null preserves the current value.
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(current),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
}
}
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
const C: &str = "object.blend.make";
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
_ => Default::default(),
};
let ease = ease_param(p, C, ease0)?;
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
let id = s.edit("Make Blend", |d, sel| {
wrap(d, sel, &roots, |id, nodes| {
let (children, starts) = merge_keys(nodes, &starts);
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
n.knockout = vectorcraft_doc::Knockout::On;
n
});
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
extend_spine(&mut spec, old, old_spec, &children);
}
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.options";
let Some((_, cur, _)) = selected_spec(s) else {
// Nothing to set them on: the options new blends start with.
if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "select a blend to set easing or color shift"));
}
// Nothing to set them on: the options new blends start with (easing is per blend).
let d = s.prefs.blend_options.unwrap_or_default();
let spacing = spacing_param(p, C, d.spacing)?;
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let ease = ease_param(p, C, cur.ease)?;
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
edit_blends(s, "Blend Options", |_, spec| {
spec.spacing = spacing;
spec.orientation = orientation;
spec.ease = ease;
spec.color_shift = color_shift;
})
}
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
let mut v = blend_options_json(spec.spacing, spec.orientation);
v["target"] = json!("blend");
v["id"] = json!(id.0);
v["easingType"] = json!(spec.ease.kind.id());
v["ramp"] = json!(spec.ease.ramp * 100.0);
v["colorShift"] = json!(spec.color_shift);
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
+76
View File
@@ -153,3 +153,79 @@ fn start_points_round_trip_and_old_blends_load() {
assert!(old.starts.is_empty() && old.start(0).is_none());
assert!(serde_json::to_value(&old).unwrap().get("starts").is_none());
}
#[test]
fn easing_is_set_validated_saved_and_old_blends_are_even() {
let mut s = session();
assert!(s.execute("object.blend.options", &json!({"ramp": 50})).is_err());
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeIn", "ramp": 40})).unwrap());
assert_eq!(spec(&s, g).1.ease.ramp, 0.4);
sel(&mut s, &[g]);
for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] {
s.execute("object.blend.options", &json!({"easingType": kind, "colorShift": -50})).unwrap();
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((&info["easingType"], &info["ramp"], &info["colorShift"]), (&json!(kind), &json!(40.0), &json!(-50.0)));
}
let before = spec(&s, g).1;
let depth = s.doc().unwrap().history.undo.len();
for bad in [json!({"ramp": 101}), json!({"ramp": -1}), json!({"colorShift": "x"}), json!({"colorShift": 1e308}), json!({"easingType": "unknown"})]
{
assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}");
assert_eq!(spec(&s, g).1, before);
assert_eq!(s.doc().unwrap().history.undo.len(), depth);
}
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
s.execute("object.blend.options", &json!({"ramp": 0})).unwrap();
let zero = spec(&s, g).1;
let back: BlendSpec = serde_json::from_value(serde_json::to_value(&zero).unwrap()).unwrap();
assert_eq!(back.ease, zero.ease, "a chosen Type survives at Ramp 0");
let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap();
assert!(old.ease.is_linear() && old.color_shift == 0.0);
let v = serde_json::to_value(&old).unwrap();
assert!(v.get("ease").is_none() && v.get("colorShift").is_none());
}
#[test]
fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeOut", "ramp": 40, "colorShift": -50})).unwrap());
let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let blend_of = |doc: &vectorcraft_doc::Document| match doc.node(g).unwrap().kind.clone() {
NodeKind::Blend { spec, .. } => *spec,
_ => panic!("not a blend"),
};
for (ramp, shift, want) in [("12.5", "-9000.0", (1.0, -100.0)), ("-3.0", "250.0", (0.0, 100.0)), ("null", "\"NaN\"", (0.0, 0.0))] {
let t = text.replacen("\"ramp\":0.4", &format!("\"ramp\":{ramp}"), 1).replacen("\"colorShift\":-50.0", &format!("\"colorShift\":{shift}"), 1);
assert!(t != text && t.contains("\"ramp\":") && t.contains(shift), "the saved blend carries ramp and colorShift");
let doc = vectorcraft_format::load(t.as_bytes()).expect("one bad blend number must not fail the document");
let spec = blend_of(&doc);
assert_eq!((spec.ease.ramp, spec.color_shift), want, "{ramp} / {shift}");
}
// The commands still refuse the same values and leave the blend alone.
sel(&mut s, &[g]);
let before = spec(&s, g).1;
for bad in [json!({"ramp": 1250}), json!({"ramp": -3}), json!({"colorShift": 250}), json!({"colorShift": "NaN"}), json!({"colorShift": 1e308})] {
assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}");
assert_eq!(spec(&s, g).1, before);
}
}
#[test]
fn native_blend_easing_values_clamp_and_non_finite_become_default() {
for (bad, ramp, shift) in [
(json!({"ease": {"ramp": 1e308}}), 1.0, 0.0),
(json!({"ease": {"ramp": -0.1}}), 0.0, 0.0),
(json!({"colorShift": 101}), 0.0, 100.0),
(json!({"colorShift": -101}), 0.0, -100.0),
(json!({"ease": {"ramp": null}, "colorShift": "NaN"}), 0.0, 0.0),
] {
let spec: BlendSpec = serde_json::from_value(bad.clone()).unwrap_or_else(|e| panic!("{bad}: {e}"));
assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}");
}
}
+2
View File
@@ -472,6 +472,8 @@ fn structured_junk() {
("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})),
("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})),
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})),
("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})),
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
+6
View File
@@ -5335,5 +5335,11 @@ theme Appearance Външен вид
Water Paper Мокра хартия
Ruby… Руби…
Ruby Руби
Ease In Плавно начало
Ease Out Плавен край
Ease In Out Плавно начало и край
Ease Out In Плавен край и начало
Ramp: Рампа:
Shift: Изместване:
Fresco… Фреска…
Fresco Фреска
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5378,5 +5378,11 @@ mark-style Japanese Japonské
Water Paper Mokrý papír
Ruby… Ruby…
Ruby Ruby
Ease In Pozvolný začátek
Ease Out Pozvolný konec
Ease In Out Pozvolný začátek a konec
Ease Out In Pozvolný konec a začátek
Ramp: Síla:
Shift: Posun:
Fresco… Freska…
Fresco Freska
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5351,5 +5351,11 @@ swatch Bright Violet Kraftig violet
Water Paper Vådt papir
Ruby… Ruby…
Ruby Ruby
Ease In Langsom start
Ease Out Langsom slutning
Ease In Out Langsom start og slutning
Ease Out In Langsom slutning og start
Ramp: Styrke:
Shift: Forskydning:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -4959,5 +4959,11 @@ effect Soft Weich
Water Paper Feuchtes Papier
Ruby… Ruby…
Ruby Ruby
Ease In Langsam starten
Ease Out Langsam enden
Ease In Out Langsam starten und enden
Ease Out In Langsam enden und starten
Ramp: Stärke:
Shift: Verschiebung:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5330,5 +5330,11 @@ effect Points Σημεία
Water Paper Βρεγμένο χαρτί
Ruby… Ruby…
Ruby Ruby
Ease In Ομαλή αρχή
Ease Out Ομαλό τέλος
Ease In Out Ομαλή αρχή και τέλος
Ease Out In Ομαλό τέλος και αρχή
Ramp: Ένταση:
Shift: Μετατόπιση:
Fresco… Νωπογραφία…
Fresco Νωπογραφία
Can't render this file because it contains an unexpected character in line 2040 and column 19.
+6
View File
@@ -5320,5 +5320,11 @@ mark-style Japanese Japonesas
Water Paper Papel mojado
Ruby… Ruby…
Ruby Ruby
Ease In Entrada suave
Ease Out Salida suave
Ease In Out Entrada y salida suaves
Ease Out In Salida y entrada suaves
Ramp: Intensidad:
Shift: Desplazamiento:
Fresco… Fresco…
Fresco Fresco
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5409,5 +5409,11 @@ swatch Bright Violet Kirkas violetti
Water Paper Märkä paperi
Ruby… Ruby…
Ruby Ruby
Ease In Hidas alku
Ease Out Hidas loppu
Ease In Out Hidas alku ja loppu
Ease Out In Hidas loppu ja alku
Ramp: Voimakkuus:
Shift: Siirtymä:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5377,5 +5377,11 @@ theme Appearance Apparence
Water Paper Papier humide
Ruby… Ruby…
Ruby Ruby
Ease In Accélération progressive
Ease Out Décélération progressive
Ease In Out Accélération et décélération
Ease Out In Décélération et accélération
Ramp: Intensité :
Shift: Décalage :
Fresco… Fresque…
Fresco Fresque
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5364,5 +5364,11 @@ imageTrace Outline Körvonal
Water Paper Nedves papír
Ruby… Ruby…
Ruby Ruby
Ease In Lassú kezdés
Ease Out Lassú befejezés
Ease In Out Lassú kezdés és befejezés
Ease Out In Lassú befejezés és kezdés
Ramp: Erősség:
Shift: Eltolás:
Fresco… Freskó…
Fresco Freskó
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5351,5 +5351,11 @@ swatch Bright Violet Violet cerah
Water Paper Kertas Basah
Ruby… Ruby…
Ruby Ruby
Ease In Awal Lambat
Ease Out Akhir Lambat
Ease In Out Awal dan Akhir Lambat
Ease Out In Akhir dan Awal Lambat
Ramp: Kekuatan:
Shift: Pergeseran:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 3811 and column 19.
+6
View File
@@ -4993,5 +4993,11 @@ lighting Light Luce
Water Paper Carta bagnata
Ruby… Ruby…
Ruby Ruby
Ease In Inizio graduale
Ease Out Fine graduale
Ease In Out Inizio e fine graduali
Ease Out In Fine e inizio graduali
Ramp: Intensità:
Shift: Spostamento:
Fresco… Affresco…
Fresco Affresco
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5040,5 +5040,11 @@ lighting Light ライト
Yonroku-ban 四六判
Ruby… ルビ…
Ruby ルビ
Ease In イーズイン
Ease Out イーズアウト
Ease In Out イーズインアウト
Ease Out In イーズアウトイン
Ramp: 強さ:
Shift: シフト:
Fresco… フレスコ…
Fresco フレスコ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5288,5 +5288,11 @@ imageTrace Outline კონტური
selects several, or draws one inside an artboard) რამდენიმეს მონიშნავს ან ახალს სამუშაო დაფის შიგნით ხატავს)
Ruby… რუბი…
Ruby რუბი
Ease In ნელი დასაწყისი
Ease Out ნელი დასასრული
Ease In Out ნელი დასაწყისი და დასასრული
Ease Out In ნელი დასასრული და დასაწყისი
Ramp: სიძლიერე:
Shift: წანაცვლება:
Fresco… ფრესკა…
Fresco ფრესკა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -4956,5 +4956,11 @@ lighting Light 광원
Water Paper 물 종이
Ruby… 루비…
Ruby 루비
Ease In 서서히 시작
Ease Out 서서히 종료
Ease In Out 서서히 시작 및 종료
Ease Out In 서서히 종료 및 시작
Ramp: 강도:
Shift: 이동:
Fresco… 프레스코…
Fresco 프레스코
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5393,5 +5393,11 @@ swatch Bright Violet Felviolet
Water Paper Nat papier
Ruby… Ruby…
Ruby Ruby
Ease In Langzaam beginnen
Ease Out Langzaam eindigen
Ease In Out Langzaam beginnen en eindigen
Ease Out In Langzaam eindigen en beginnen
Ramp: Sterkte:
Shift: Verschuiving:
Fresco… Fresco…
Fresco Fresco
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5230,5 +5230,11 @@ mark-style Japanese Japońskie
Water Paper Mokry papier
Ruby… Ruby…
Ruby Ruby
Ease In Łagodny początek
Ease Out Łagodny koniec
Ease In Out Łagodny początek i koniec
Ease Out In Łagodny koniec i początek
Ramp: Siła:
Shift: Przesunięcie:
Fresco… Fresk…
Fresco Fresk
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5295,5 +5295,11 @@ mark-style Japanese Japonesas
Water Paper Papel molhado
Ruby… Ruby…
Ruby Ruby
Ease In Entrada suave
Ease Out Saída suave
Ease In Out Entrada e saída suaves
Ease Out In Saída e entrada suaves
Ramp: Intensidade:
Shift: Deslocamento:
Fresco… Afresco…
Fresco Afresco
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -4989,5 +4989,11 @@ lighting Light Свет
Water Paper Мокрая бумага
Ruby… Руби…
Ruby Руби
Ease In Плавное начало
Ease Out Плавное окончание
Ease In Out Плавное начало и окончание
Ease Out In Плавное окончание и начало
Ramp: Сила:
Shift: Сдвиг:
Fresco… Фреска…
Fresco Фреска
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5334,5 +5334,11 @@ theme Appearance Utseende
Water Paper Vått papper
Ruby… Ruby…
Ruby Ruby
Ease In Långsam start
Ease Out Långsamt slut
Ease In Out Långsam start och slut
Ease Out In Långsamt slut och start
Ramp: Styrka:
Shift: Förskjutning:
Fresco… Fresk…
Fresco Fresk
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6
View File
@@ -4973,5 +4973,11 @@ lighting Light Світло
Water Paper Мокрий папір
Ruby… Рубі…
Ruby Рубі
Ease In Плавний початок
Ease Out Плавне завершення
Ease In Out Плавний початок і завершення
Ease Out In Плавне завершення і початок
Ramp: Сила:
Shift: Зсув:
Fresco… Фреска…
Fresco Фреска
Can't render this file because it contains an unexpected character in line 733 and column 40.
@@ -431,6 +431,7 @@ each of `items` is {{id, corners?}} with an object id, not {it}
each row needs `from`
each stop needs a position
each subpath needs an anchors array
easingType must be easeIn, easeOut, easeInOut or easeOutIn
editingData must be true or false
editingData must be true, false or "always"
edits overlap
@@ -935,6 +936,7 @@ scope must be selection or all (got {other})
scribble is too long
segment does not exist
select a Shaper group
select a blend to set easing or color shift
select a graph
select a group or layer with visible, unlocked members
select a layer
@@ -1255,6 +1257,7 @@ zoom must be positive
{key} must be 0–{MAX_ADD_AROUND} pt
{key} must be Unix seconds (an integer) or null
{key} must be [x,y]
{key} must be a percentage {min}..100
{key} must be a positive number, not {v}
{key} must be an array of non-negative integer object ids
{key} must be one of {_1}
+6
View File
@@ -4973,5 +4973,11 @@ theme Appearance Giao diện
Water Paper Giấy ướt
Ruby… Ruby…
Ruby Ruby
Ease In Chậm lúc đầu
Ease Out Chậm lúc cuối
Ease In Out Chậm lúc đầu và cuối
Ease Out In Chậm lúc cuối và đầu
Ramp: Cường độ:
Shift: Dịch chuyển:
Fresco… Bích họa…
Fresco Bích họa
Can't render this file because it contains an unexpected character in line 4023 and column 10.
+6
View File
@@ -5217,5 +5217,11 @@ lighting Light 光源
Water Paper 水彩画纸
Ruby… 注音…
Ruby 注音
Ease In 缓入
Ease Out 缓出
Ease In Out 缓入缓出
Ease Out In 缓出缓入
Ramp: 强度:
Shift: 偏移:
Fresco… 壁画…
Fresco 壁画
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5012,5 +5012,11 @@ lighting Light 光源
Water Paper 水彩畫紙
Ruby… 注音…
Ruby 注音
Ease In 緩入
Ease Out 緩出
Ease In Out 緩入緩出
Ease Out In 緩出緩入
Ramp: 強度:
Shift: 偏移:
Fresco… 壁畫…
Fresco 壁畫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+30
View File
@@ -94,6 +94,36 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
});
let selected = info["target"] == "blend";
// Position easing needs a spine to run along: a replaced one, or Align to Path.
let spined = info["spine"]["explicit"] == true || info["orientation"] == "path";
ui.add_enabled_ui(selected && spined, |ui| {
let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"];
let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")];
let mode = info["easingType"].as_str().unwrap_or(modes[0]);
let index = modes.iter().position(|m| *m == mode).unwrap_or(0);
widgets::label_row(ui, tl!("Type:"), LABEL_W, |ui| {
if let Some(i) = widgets::dropdown(ui, "blend-panel-easing", labels[index], &labels, ui.available_width().max(FIELD_W))
&& let Some(mode) = modes.get(i)
{
change = Some(json!({"easingType": mode}));
}
});
widgets::label_row(ui, tl!("Ramp:"), LABEL_W, |ui| {
let mut ramp = info["ramp"].as_f64().unwrap_or(0.0);
if ui.add(egui::Slider::new(&mut ramp, 0.0..=100.0).suffix("%")).changed() {
change = Some(json!({"ramp": ramp}));
}
});
});
ui.add_enabled_ui(selected, |ui| {
widgets::label_row(ui, tl!("Shift:"), LABEL_W, |ui| {
let mut shift = info["colorShift"].as_f64().unwrap_or(0.0);
if ui.add(egui::Slider::new(&mut shift, -100.0..=100.0).suffix("%")).changed() {
change = Some(json!({"colorShift": shift}));
}
});
});
if let Some(p) = change {
app.run("object.blend.options", p).ok();
}
+34
View File
@@ -225,3 +225,37 @@ fn each_button_runs_its_blend_command() {
let objects = top(&app);
assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}");
}
#[test]
fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() {
// Type and Ramp need a spine: Align to Path gives the blend one.
let mut app = app();
let id = NodeId(app.run("object.blend.make", json!({"smooth": true, "orientation": "path"})).unwrap()["id"].as_u64().unwrap());
let mut f = Frames::new();
for label in ["Type:", "Ease In", "Ramp:", "Shift:"] {
f.text(&mut app, label);
}
f.click_text(&mut app, "Ease In");
f.click_text(&mut app, "Ease Out In");
assert_eq!(spec(&app, id).ease.kind, vectorcraft_doc::BlendEaseType::OutIn);
// Click each real slider: its command updates the engine, and Undo restores the old value.
for (index, key) in [(0, "ramp"), (1, "colorShift")] {
let out = f.run(&mut app, vec![]);
let nodes = &out.platform_output.accesskit_update.as_ref().unwrap().nodes;
let mut sliders: Vec<_> = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Slider).collect();
// The tree is not in layout order: Ramp sits above Shift.
sliders.sort_by(|a, b| a.1.bounds().unwrap().y0.total_cmp(&b.1.bounds().unwrap().y0));
let slider = sliders[index];
let b = slider.1.bounds().unwrap();
let at = pos2((b.x0 + 0.3 * (b.x1 - b.x0)) as f32, ((b.y0 + b.y1) * 0.5) as f32);
let before = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone();
f.click(&mut app, at);
let after = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone();
assert_ne!(after, before, "{key}");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()[key], before);
}
app.run("ui.blendOptions", json!({})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert!(!text.contains("Ease In:") && !text.contains("Ramp:") && !text.contains("Color Ease"), "{text}");
}
+8 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -2
View File
File diff suppressed because one or more lines are too long