Skip to content

Commit bff1703

Browse files
authored
feat(clip-path): a morph can pass through intermediate shapes (#405)
#398 delivered clip-path animation in a different spelling from the one #384 asked for, and I left the issue open with the choice written down: finish the literal `property: "clip-path"` form with shape-valued keyframes, or re-scope. Neither, on reflection. The spelling was never the gap — the limit to two shapes was. A second vocabulary for one capability is worse than one good one, and a shape-valued keyframe would have to re-earn the easing, spring and loop handling that the scalar track already gives for free. So `Morph` gains `via`: shapes the sweep passes through, in order. "clip-path": { "kind": "morph", "from": { "kind": "circle", "radius": 20 }, "via": [ { "kind": "circle", "radius": 140 } ], "to": { "kind": "circle", "radius": 60 } } `progress` spans the whole chain rather than each leg, and the legs are equal in progress whatever the geometric distance between shapes — an uneven pace is the easing's job, not the chain's. Each consecutive pair follows the same interpolation rule as a two-shape morph, and an incompatible pair mid-chain warns and leaves the node unclipped exactly as a lone incompatible pair does. `via` absent is byte-for-byte what a morph has always been, which is what a unit test on `morph_leg` asserts directly rather than through pixels. Four tests. Three fail without the chain. The fourth is the compatibility pin and passes both ways on purpose. One of them replaced an earlier version of itself that was simply wrong: it compared a chain at progress 1.0 against a direct morph at 0.5, which land on different shapes by construction. It asserted something untrue and failed for the right reason. Closes #384
1 parent 7c5b659 commit bff1703

4 files changed

Lines changed: 188 additions & 2 deletions

File tree

‎crates/rustmotion-core/src/css/animation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ mod tests {
195195
radius: LengthPercentage::Px(50.0),
196196
origin: None,
197197
}),
198+
via: Vec::new(),
198199
progress: 0.0,
199200
}),
200201
..CssStyle::default()

‎crates/rustmotion-core/src/css/style.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,14 @@ pub enum ClipPath {
15131513
Morph {
15141514
from: Box<ClipPath>,
15151515
to: Box<ClipPath>,
1516+
/// Shapes the morph passes **through** on its way from `from` to
1517+
/// `to`, in order. Empty means a direct two-shape sweep, which is
1518+
/// what a `morph` without this field has always been.
1519+
///
1520+
/// `progress` spans the whole chain, not each leg: with one `via`
1521+
/// shape, `0.5` is exactly that shape.
1522+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
1523+
via: Vec<ClipPath>,
15161524
#[serde(default)]
15171525
progress: f32,
15181526
},

‎crates/rustmotion-core/src/engine/paint_pass.rs‎

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,8 +1971,14 @@ fn clip_path_to_skia(
19711971
None
19721972
}
19731973

1974-
ClipPath::Morph { from, to, progress } => {
1975-
morph_clip_path_to_skia(from, to, *progress, layout, ctx)
1974+
ClipPath::Morph {
1975+
from,
1976+
to,
1977+
via,
1978+
progress,
1979+
} => {
1980+
let (leg_from, leg_to, leg_t) = morph_leg(from, to, via, *progress);
1981+
morph_clip_path_to_skia(leg_from, leg_to, leg_t, layout, ctx)
19761982
}
19771983
}
19781984
}
@@ -1994,6 +2000,24 @@ fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
19942000
a + (b - a) * t
19952001
}
19962002

2003+
fn morph_leg<'a>(
2004+
from: &'a ClipPath,
2005+
to: &'a ClipPath,
2006+
via: &'a [ClipPath],
2007+
progress: f32,
2008+
) -> (&'a ClipPath, &'a ClipPath, f32) {
2009+
if via.is_empty() {
2010+
return (from, to, progress);
2011+
}
2012+
let legs = via.len() + 1;
2013+
let scaled = (progress.clamp(0.0, 1.0) * legs as f32).min(legs as f32 - f32::EPSILON);
2014+
let leg = scaled.floor() as usize;
2015+
let local = scaled - leg as f32;
2016+
let leg_from = if leg == 0 { from } else { &via[leg - 1] };
2017+
let leg_to = if leg == via.len() { to } else { &via[leg] };
2018+
(leg_from, leg_to, local)
2019+
}
2020+
19972021
fn morph_clip_path_to_skia(
19982022
from: &ClipPath,
19992023
to: &ClipPath,
@@ -3850,6 +3874,123 @@ mod paint_order_tests {
38503874
0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32
38513875
}
38523876

3877+
fn morph_chain(progress: f32) -> BoxNode {
3878+
let square = |inset: f32| ClipPath::Inset {
3879+
top: CLP::Px(inset),
3880+
right: CLP::Px(inset),
3881+
bottom: CLP::Px(inset),
3882+
left: CLP::Px(inset),
3883+
radius: None,
3884+
};
3885+
BoxNode {
3886+
id: 0,
3887+
kind: BoxKind::Container,
3888+
css: CssStyle {
3889+
position: Some(Position::Absolute),
3890+
left: Some(CLP::Px(0.0)),
3891+
top: Some(CLP::Px(0.0)),
3892+
width: Some(CSize::Length(CLP::Px(200.0))),
3893+
height: Some(CSize::Length(CLP::Px(200.0))),
3894+
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
3895+
clip_path: Some(ClipPath::Morph {
3896+
from: Box::new(square(0.0)),
3897+
to: Box::new(square(0.0)),
3898+
via: vec![square(80.0)],
3899+
progress,
3900+
}),
3901+
..Default::default()
3902+
},
3903+
children: vec![],
3904+
intrinsic: None,
3905+
source_path: None,
3906+
window: None,
3907+
}
3908+
}
3909+
3910+
fn red_pixels(buf: &[u8]) -> usize {
3911+
buf.as_chunks::<4>()
3912+
.0
3913+
.iter()
3914+
.filter(|px| px[0] > 200 && px[1] < 60)
3915+
.count()
3916+
}
3917+
3918+
#[test]
3919+
fn a_morph_chain_passes_through_its_via_shape_at_the_midpoint() {
3920+
let start = red_pixels(&render_lit(morph_chain(0.0), None));
3921+
let middle = red_pixels(&render_lit(morph_chain(0.5), None));
3922+
let end = red_pixels(&render_lit(morph_chain(1.0), None));
3923+
3924+
assert!(
3925+
middle * 3 < start,
3926+
"with one `via` shape, progress 0.5 lands exactly on it — an 80px inset of a \
3927+
200px box, so far smaller than either end: start={start}px, middle={middle}px"
3928+
);
3929+
assert!(
3930+
(start as i64 - end as i64).abs() < 40,
3931+
"the chain starts and ends on the same shape here, so both ends must match: \
3932+
start={start}px, end={end}px"
3933+
);
3934+
}
3935+
3936+
#[test]
3937+
fn a_morph_chain_keeps_moving_between_its_legs() {
3938+
let quarter = red_pixels(&render_lit(morph_chain(0.25), None));
3939+
let middle = red_pixels(&render_lit(morph_chain(0.5), None));
3940+
let three_quarters = red_pixels(&render_lit(morph_chain(0.75), None));
3941+
assert!(
3942+
quarter > middle && three_quarters > middle,
3943+
"a quarter of the way along each leg is between the endpoints, not at one of \
3944+
them: 0.25={quarter}px, 0.5={middle}px, 0.75={three_quarters}px"
3945+
);
3946+
}
3947+
3948+
#[test]
3949+
fn an_empty_via_leaves_the_two_shape_morph_exactly_as_it_was() {
3950+
let a = ClipPath::Circle {
3951+
radius: CLP::Px(10.0),
3952+
origin: None,
3953+
};
3954+
let b = ClipPath::Circle {
3955+
radius: CLP::Px(90.0),
3956+
origin: None,
3957+
};
3958+
let (from, to, t) = morph_leg(&a, &b, &[], 0.37);
3959+
assert_eq!(from, &a);
3960+
assert_eq!(to, &b);
3961+
assert_eq!(
3962+
t, 0.37,
3963+
"with no `via`, the leg is the whole morph and progress passes through \
3964+
untouched — which is what makes every morph written before this render \
3965+
identically"
3966+
);
3967+
}
3968+
3969+
#[test]
3970+
fn a_via_shape_splits_the_sweep_into_equal_legs() {
3971+
let a = ClipPath::Circle {
3972+
radius: CLP::Px(10.0),
3973+
origin: None,
3974+
};
3975+
let mid = ClipPath::Circle {
3976+
radius: CLP::Px(50.0),
3977+
origin: None,
3978+
};
3979+
let b = ClipPath::Circle {
3980+
radius: CLP::Px(90.0),
3981+
origin: None,
3982+
};
3983+
let via = [mid.clone()];
3984+
3985+
let (f0, t0, p0) = morph_leg(&a, &b, &via, 0.25);
3986+
assert_eq!((f0, t0), (&a, &mid), "the first half travels a -> via");
3987+
assert!((p0 - 0.5).abs() < 1e-5, "halfway along that leg: {p0}");
3988+
3989+
let (f1, t1, p1) = morph_leg(&a, &b, &via, 0.75);
3990+
assert_eq!((f1, t1), (&mid, &b), "the second half travels via -> b");
3991+
assert!((p1 - 0.5).abs() < 1e-5, "halfway along that leg: {p1}");
3992+
}
3993+
38533994
fn star_points() -> Vec<(CLP, CLP)> {
38543995
(0..10)
38553996
.map(|i| {
@@ -4586,6 +4727,7 @@ mod paint_order_tests {
45864727
radius: CLP::Px(150.0),
45874728
origin: None,
45884729
}),
4730+
via: Vec::new(),
45894731
progress,
45904732
};
45914733

@@ -4631,6 +4773,7 @@ mod paint_order_tests {
46314773
left: CLP::Px(0.0),
46324774
radius: None,
46334775
}),
4776+
via: Vec::new(),
46344777
progress,
46354778
};
46364779
assert!(
@@ -4653,6 +4796,7 @@ mod paint_order_tests {
46534796
ry: CLP::Px(40.0),
46544797
origin: None,
46554798
}),
4799+
via: Vec::new(),
46564800
progress,
46574801
};
46584802
assert!(
@@ -4686,6 +4830,7 @@ mod paint_order_tests {
46864830
let morph = ClipPath::Morph {
46874831
from: Box::new(big),
46884832
to: Box::new(small),
4833+
via: Vec::new(),
46894834
progress: 1.0,
46904835
};
46914836
assert!(
@@ -4712,6 +4857,7 @@ mod paint_order_tests {
47124857
(CLP::Px(200.0), CLP::Px(400.0)),
47134858
],
47144859
}),
4860+
via: Vec::new(),
47154861
progress: 0.5,
47164862
};
47174863
for (x, y) in [(20, 20), (200, 200), (380, 380)] {
@@ -4745,6 +4891,7 @@ mod paint_order_tests {
47454891
let mismatched = ClipPath::Morph {
47464892
from: Box::new(triangle),
47474893
to: Box::new(square),
4894+
via: Vec::new(),
47484895
progress: 0.5,
47494896
};
47504897
for (x, y) in [(20, 20), (200, 200), (380, 380)] {
@@ -4778,6 +4925,7 @@ mod paint_order_tests {
47784925
radius: CLP::Px(150.0),
47794926
origin: None,
47804927
}),
4928+
via: Vec::new(),
47814929
progress: 0.0,
47824930
}),
47834931
..Default::default()

‎crates/rustmotion/skills/rules/clip-path-morph.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,32 @@ cas « non clippé + message stderr ».
6161
[timeline-sequencing.md](timeline-sequencing.md)) : un `clip-path` posé par
6262
un pas de `timeline` continue de sauter à l'instant du pas, `morph` ou pas.
6363
`morph` ne s'adresse qu'au cas `keyframes`.
64+
65+
66+
## Plus de deux formes : `via`
67+
68+
Un `morph` va de `from` à `to`. Pour traverser des formes intermédiaires, liste-les
69+
dans `via`, dans l'ordre :
70+
71+
```json
72+
"clip-path": {
73+
"kind": "morph",
74+
"from": { "kind": "circle", "radius": 20 },
75+
"via": [ { "kind": "circle", "radius": 140 } ],
76+
"to": { "kind": "circle", "radius": 60 }
77+
}
78+
```
79+
80+
`progress` balaie **toute la chaîne**, pas chaque segment : avec un `via`, `0.5`
81+
tombe exactement dessus, et `0.25` est à mi-chemin du premier segment. Les
82+
segments sont de longueur égale en `progress`, quelle que soit la distance
83+
géométrique entre les formes — si tu veux qu'une étape dure plus longtemps,
84+
c'est l'easing de la piste `clip_path_progress` qui le décide, pas `via`.
85+
86+
Chaque paire consécutive suit la même règle d'interpolation que le morph à deux
87+
formes : même `kind`, même nombre de sommets pour un polygone. Une paire
88+
incompatible au milieu d'une chaîne se signale sur stderr et laisse le nœud non
89+
clippé, comme une paire incompatible toute seule.
90+
91+
`via` absent ou vide, c'est exactement le morph à deux formes d'avant — rien de
92+
ce qui a été écrit avant ne change.

0 commit comments

Comments
 (0)