From 5712e0e1ef105632258545d705ed5db7aeb1b0b5 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 09:37:12 +0200 Subject: [PATCH] feat(clip-path): a morph can pass through intermediate shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- crates/rustmotion-core/src/css/animation.rs | 1 + crates/rustmotion-core/src/css/style.rs | 8 + .../rustmotion-core/src/engine/paint_pass.rs | 152 +++++++++++++++++- .../skills/rules/clip-path-morph.md | 29 ++++ 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index 99a08e3..3803170 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -195,6 +195,7 @@ mod tests { radius: LengthPercentage::Px(50.0), origin: None, }), + via: Vec::new(), progress: 0.0, }), ..CssStyle::default() diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index f3fe47f..3bba26a 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1513,6 +1513,14 @@ pub enum ClipPath { Morph { from: Box, to: Box, + /// Shapes the morph passes **through** on its way from `from` to + /// `to`, in order. Empty means a direct two-shape sweep, which is + /// what a `morph` without this field has always been. + /// + /// `progress` spans the whole chain, not each leg: with one `via` + /// shape, `0.5` is exactly that shape. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + via: Vec, #[serde(default)] progress: f32, }, diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index a7c0000..b62756c 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -1971,8 +1971,14 @@ fn clip_path_to_skia( None } - ClipPath::Morph { from, to, progress } => { - morph_clip_path_to_skia(from, to, *progress, layout, ctx) + ClipPath::Morph { + from, + to, + via, + progress, + } => { + let (leg_from, leg_to, leg_t) = morph_leg(from, to, via, *progress); + morph_clip_path_to_skia(leg_from, leg_to, leg_t, layout, ctx) } } } @@ -1994,6 +2000,24 @@ fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t } +fn morph_leg<'a>( + from: &'a ClipPath, + to: &'a ClipPath, + via: &'a [ClipPath], + progress: f32, +) -> (&'a ClipPath, &'a ClipPath, f32) { + if via.is_empty() { + return (from, to, progress); + } + let legs = via.len() + 1; + let scaled = (progress.clamp(0.0, 1.0) * legs as f32).min(legs as f32 - f32::EPSILON); + let leg = scaled.floor() as usize; + let local = scaled - leg as f32; + let leg_from = if leg == 0 { from } else { &via[leg - 1] }; + let leg_to = if leg == via.len() { to } else { &via[leg] }; + (leg_from, leg_to, local) +} + fn morph_clip_path_to_skia( from: &ClipPath, to: &ClipPath, @@ -3850,6 +3874,123 @@ mod paint_order_tests { 0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32 } + fn morph_chain(progress: f32) -> BoxNode { + let square = |inset: f32| ClipPath::Inset { + top: CLP::Px(inset), + right: CLP::Px(inset), + bottom: CLP::Px(inset), + left: CLP::Px(inset), + radius: None, + }; + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(0.0)), + top: Some(CLP::Px(0.0)), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(200.0))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + clip_path: Some(ClipPath::Morph { + from: Box::new(square(0.0)), + to: Box::new(square(0.0)), + via: vec![square(80.0)], + progress, + }), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn red_pixels(buf: &[u8]) -> usize { + buf.as_chunks::<4>() + .0 + .iter() + .filter(|px| px[0] > 200 && px[1] < 60) + .count() + } + + #[test] + fn a_morph_chain_passes_through_its_via_shape_at_the_midpoint() { + let start = red_pixels(&render_lit(morph_chain(0.0), None)); + let middle = red_pixels(&render_lit(morph_chain(0.5), None)); + let end = red_pixels(&render_lit(morph_chain(1.0), None)); + + assert!( + middle * 3 < start, + "with one `via` shape, progress 0.5 lands exactly on it — an 80px inset of a \ + 200px box, so far smaller than either end: start={start}px, middle={middle}px" + ); + assert!( + (start as i64 - end as i64).abs() < 40, + "the chain starts and ends on the same shape here, so both ends must match: \ + start={start}px, end={end}px" + ); + } + + #[test] + fn a_morph_chain_keeps_moving_between_its_legs() { + let quarter = red_pixels(&render_lit(morph_chain(0.25), None)); + let middle = red_pixels(&render_lit(morph_chain(0.5), None)); + let three_quarters = red_pixels(&render_lit(morph_chain(0.75), None)); + assert!( + quarter > middle && three_quarters > middle, + "a quarter of the way along each leg is between the endpoints, not at one of \ + them: 0.25={quarter}px, 0.5={middle}px, 0.75={three_quarters}px" + ); + } + + #[test] + fn an_empty_via_leaves_the_two_shape_morph_exactly_as_it_was() { + let a = ClipPath::Circle { + radius: CLP::Px(10.0), + origin: None, + }; + let b = ClipPath::Circle { + radius: CLP::Px(90.0), + origin: None, + }; + let (from, to, t) = morph_leg(&a, &b, &[], 0.37); + assert_eq!(from, &a); + assert_eq!(to, &b); + assert_eq!( + t, 0.37, + "with no `via`, the leg is the whole morph and progress passes through \ + untouched — which is what makes every morph written before this render \ + identically" + ); + } + + #[test] + fn a_via_shape_splits_the_sweep_into_equal_legs() { + let a = ClipPath::Circle { + radius: CLP::Px(10.0), + origin: None, + }; + let mid = ClipPath::Circle { + radius: CLP::Px(50.0), + origin: None, + }; + let b = ClipPath::Circle { + radius: CLP::Px(90.0), + origin: None, + }; + let via = [mid.clone()]; + + let (f0, t0, p0) = morph_leg(&a, &b, &via, 0.25); + assert_eq!((f0, t0), (&a, &mid), "the first half travels a -> via"); + assert!((p0 - 0.5).abs() < 1e-5, "halfway along that leg: {p0}"); + + let (f1, t1, p1) = morph_leg(&a, &b, &via, 0.75); + assert_eq!((f1, t1), (&mid, &b), "the second half travels via -> b"); + assert!((p1 - 0.5).abs() < 1e-5, "halfway along that leg: {p1}"); + } + fn star_points() -> Vec<(CLP, CLP)> { (0..10) .map(|i| { @@ -4586,6 +4727,7 @@ mod paint_order_tests { radius: CLP::Px(150.0), origin: None, }), + via: Vec::new(), progress, }; @@ -4631,6 +4773,7 @@ mod paint_order_tests { left: CLP::Px(0.0), radius: None, }), + via: Vec::new(), progress, }; assert!( @@ -4653,6 +4796,7 @@ mod paint_order_tests { ry: CLP::Px(40.0), origin: None, }), + via: Vec::new(), progress, }; assert!( @@ -4686,6 +4830,7 @@ mod paint_order_tests { let morph = ClipPath::Morph { from: Box::new(big), to: Box::new(small), + via: Vec::new(), progress: 1.0, }; assert!( @@ -4712,6 +4857,7 @@ mod paint_order_tests { (CLP::Px(200.0), CLP::Px(400.0)), ], }), + via: Vec::new(), progress: 0.5, }; for (x, y) in [(20, 20), (200, 200), (380, 380)] { @@ -4745,6 +4891,7 @@ mod paint_order_tests { let mismatched = ClipPath::Morph { from: Box::new(triangle), to: Box::new(square), + via: Vec::new(), progress: 0.5, }; for (x, y) in [(20, 20), (200, 200), (380, 380)] { @@ -4778,6 +4925,7 @@ mod paint_order_tests { radius: CLP::Px(150.0), origin: None, }), + via: Vec::new(), progress: 0.0, }), ..Default::default() diff --git a/crates/rustmotion/skills/rules/clip-path-morph.md b/crates/rustmotion/skills/rules/clip-path-morph.md index 992a0f9..03c479e 100644 --- a/crates/rustmotion/skills/rules/clip-path-morph.md +++ b/crates/rustmotion/skills/rules/clip-path-morph.md @@ -61,3 +61,32 @@ cas « non clippé + message stderr ». [timeline-sequencing.md](timeline-sequencing.md)) : un `clip-path` posé par un pas de `timeline` continue de sauter à l'instant du pas, `morph` ou pas. `morph` ne s'adresse qu'au cas `keyframes`. + + +## Plus de deux formes : `via` + +Un `morph` va de `from` à `to`. Pour traverser des formes intermédiaires, liste-les +dans `via`, dans l'ordre : + +```json +"clip-path": { + "kind": "morph", + "from": { "kind": "circle", "radius": 20 }, + "via": [ { "kind": "circle", "radius": 140 } ], + "to": { "kind": "circle", "radius": 60 } +} +``` + +`progress` balaie **toute la chaîne**, pas chaque segment : avec un `via`, `0.5` +tombe exactement dessus, et `0.25` est à mi-chemin du premier segment. Les +segments sont de longueur égale en `progress`, quelle que soit la distance +géométrique entre les formes — si tu veux qu'une étape dure plus longtemps, +c'est l'easing de la piste `clip_path_progress` qui le décide, pas `via`. + +Chaque paire consécutive suit la même règle d'interpolation que le morph à deux +formes : même `kind`, même nombre de sommets pour un polygone. Une paire +incompatible au milieu d'une chaîne se signale sur stderr et laisse le nœud non +clippé, comme une paire incompatible toute seule. + +`via` absent ou vide, c'est exactement le morph à deux formes d'avant — rien de +ce qui a été écrit avant ne change.