From 5eb8b54b20ec3b7303767a295280df2fcd17ae79 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 21:19:46 +0200 Subject: [PATCH 1/2] fix(paint): border-radius keyframes, border.style dashes, clip-path morph (#373, #375, #384) Three "a declared thing the paint pass ignores" bugs, grouped because they all live in paint_pass.rs/animator.rs and reinforce the same lesson: a property that validates must actually move a pixel. - #373: apply_animated_props mapped translate/scale/rotate/opacity/blur/ glow/perspective/width/height from AnimatedProperties onto CssStyle, but never border_radius, even though the animator resolved it correctly and border_radius has been in KNOWN_MOTION_PROPERTIES all along. A keyframed square never rounded into a circle. One missing branch; now animator -> apply_animated_props -> paint_border is a straight line for this property too, same as the timeline/style.transition path already was. - #375: paint_border read border.style only to gate BorderStyle::None; every other variant (dashed, dotted, double) fell into the same filled-drrect path as solid. Dashed/dotted now stroke the border's centerline with a sized PathEffect::dash (3x width on/off for dashed, near-zero "on" + round cap for width-diameter dots); double draws two width/3 strokes with a width/3 gap between them, matching the CSS box model exactly. - #384: animating clip-path is new territory this codebase's vocabulary doesn't reach cleanly. The literal ask -- a `property: "clip-path"` entry inside a `keyframes` animation whose values are ClipPath-shaped objects -- needs `KeyframeValue` (schema::animation) to carry a ClipPath variant, and that type, plus the AnimationEffect exhaustiveness check in rustmotion/cli/commands/validate_schema.rs, belong to other writers in this parallel workstream. Reworked the ask instead of dropping it: a new `ClipPath::Morph { from, to, progress }` variant fixes the two endpoints once as an ordinary style value, and `progress` rides the *existing* generic scalar keyframes engine via a new `clip_path_progress` motion property -- same machinery opacity/border_radius already use, so easing, springs and loops all come for free. Interpolation is resolved geometry to resolved geometry (mirroring how clip_path_to_skia already resolves each static shape), covering polygon/inset/circle/ellipse. A kind or polygon-arity mismatch is reported on stderr naming both shapes and leaves the node unclipped for that frame, rather than snapping between two incompatible shapes -- the same "loud, not silent" precedent this file already sets for `clip-path: { kind: node-path }`. Every fix is proven by a pixel test in paint_order_tests: each was reverted, confirmed red, and restored (see PR description / handback for the exact failure messages). Two new French rules docs land under skills/rules/ (border-style.md, clip-path-morph.md) documenting the new surface; SKILL.md is deliberately left unlinked since the orchestrator owns that index across several parallel PRs. --- crates/rustmotion-core/src/css/animation.rs | 86 ++- crates/rustmotion-core/src/css/style.rs | 20 + crates/rustmotion-core/src/engine/animator.rs | 7 + .../rustmotion-core/src/engine/paint_pass.rs | 634 +++++++++++++++++- crates/rustmotion-core/src/schema/video.rs | 1 + .../rustmotion/skills/rules/border-style.md | 59 ++ .../skills/rules/clip-path-morph.md | 63 ++ 7 files changed, 862 insertions(+), 8 deletions(-) create mode 100644 crates/rustmotion/skills/rules/border-style.md create mode 100644 crates/rustmotion/skills/rules/clip-path-morph.md diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index 67f9d95..7cf260f 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -1,5 +1,5 @@ use crate::css::style::CssStyle; -use crate::css::style::{FilterFn, Size, TransformFn}; +use crate::css::style::{BorderRadius, ClipPath, FilterFn, Size, TransformFn}; use crate::css::units::{Length, LengthPercentage}; use crate::engine::animator::AnimatedProperties; @@ -74,6 +74,18 @@ pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) { if props.height >= 0.0 { css.height = Some(Size::Length(LengthPercentage::Px(props.height))); } + + if props.border_radius >= 0.0 { + css.border_radius = Some(BorderRadius::Uniform(LengthPercentage::Px( + props.border_radius, + ))); + } + + if props.clip_path_progress >= 0.0 { + if let Some(ClipPath::Morph { progress, .. }) = css.clip_path.as_mut() { + *progress = props.clip_path_progress; + } + } } #[cfg(test)] @@ -152,6 +164,78 @@ mod tests { } } + #[test] + fn animated_border_radius_reaches_the_css_style() { + let mut css = CssStyle::default(); + let props = AnimatedProperties { + border_radius: 100.0, + ..AnimatedProperties::default() + }; + apply_animated_props(&mut css, &props); + + match css.border_radius { + Some(BorderRadius::Uniform(LengthPercentage::Px(v))) => { + assert!((v - 100.0).abs() < 1e-6) + } + other => panic!("expected a uniform 100px border-radius, got {other:?}"), + } + } + + #[test] + fn animated_clip_path_progress_writes_onto_the_morph_variant() { + let mut css = CssStyle { + clip_path: Some(ClipPath::Morph { + from: Box::new(ClipPath::Circle { + radius: LengthPercentage::Px(10.0), + origin: None, + }), + to: Box::new(ClipPath::Circle { + radius: LengthPercentage::Px(50.0), + origin: None, + }), + progress: 0.0, + }), + ..CssStyle::default() + }; + let props = AnimatedProperties { + clip_path_progress: 0.75, + ..AnimatedProperties::default() + }; + apply_animated_props(&mut css, &props); + + match css.clip_path { + Some(ClipPath::Morph { progress, .. }) => { + assert!((progress - 0.75).abs() < 1e-6) + } + other => panic!("expected the morph's progress to be updated, got {other:?}"), + } + } + + #[test] + fn no_clip_path_progress_leaves_a_non_morph_clip_path_untouched() { + let mut css = CssStyle { + clip_path: Some(ClipPath::Circle { + radius: LengthPercentage::Px(10.0), + origin: None, + }), + ..CssStyle::default() + }; + let props = AnimatedProperties { + clip_path_progress: 0.5, + ..AnimatedProperties::default() + }; + apply_animated_props(&mut css, &props); + + assert_eq!( + css.clip_path, + Some(ClipPath::Circle { + radius: LengthPercentage::Px(10.0), + origin: None, + }), + "a static (non-morph) clip-path must not be mutated by clip_path_progress" + ); + } + #[test] fn blur_and_glow_compose_into_filter_list() { let mut css = CssStyle::default(); diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 2202b18..e51015f 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1442,6 +1442,26 @@ pub enum ClipPath { NodePath { id: String, }, + /// A shape that sweeps continuously from `from` to `to` as `progress` + /// goes `0.0` → `1.0` (#384) — the chamfered-wall-style beat where the + /// clip itself is the animation, not a transform riding on top of a + /// frozen shape. + /// + /// `progress` is meant to be driven by an ordinary `keyframes` animation + /// on the `clip_path_progress` property (see `KNOWN_MOTION_PROPERTIES`); + /// the literal value here is only the resting position when nothing + /// animates it. + /// + /// `from` and `to` must be the same variant — and, for `polygon`, the + /// same vertex count — for the paint pass to have a correspondence to + /// interpolate along. A mismatch is reported on stderr and the node is + /// left unclipped for that frame; see `engine::paint_pass::morph_clip_path_to_skia`. + Morph { + from: Box, + to: Box, + #[serde(default)] + progress: f32, + }, } #[cfg(test)] diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index d247b02..ce7ec1f 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -534,6 +534,7 @@ pub struct AnimatedProperties { pub perspective: f32, pub draw_progress: f32, pub motion_progress: f32, + pub clip_path_progress: f32, pub char_animation: Option, } @@ -565,6 +566,7 @@ impl Default for AnimatedProperties { perspective: -1.0, draw_progress: -1.0, motion_progress: -1.0, + clip_path_progress: -1.0, char_animation: None, } } @@ -647,6 +649,9 @@ impl AnimatedProperties { if other.motion_progress >= 0.0 { self.motion_progress = other.motion_progress; } + if other.clip_path_progress >= 0.0 { + self.clip_path_progress = other.clip_path_progress; + } if other.char_animation.is_some() { self.char_animation = other.char_animation.clone(); } @@ -911,6 +916,7 @@ fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) { "perspective" => props.perspective = value as f32, "draw_progress" => props.draw_progress = value as f32, "motion_progress" => props.motion_progress = value as f32, + "clip_path_progress" => props.clip_path_progress = value as f32, _ => {} } } @@ -1137,6 +1143,7 @@ fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { "perspective" => props.perspective as f64, "draw_progress" => props.draw_progress as f64, "motion_progress" => props.motion_progress as f64, + "clip_path_progress" => props.clip_path_progress as f64, _ => 0.0, } } diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 4292f8f..2a0048f 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -1301,15 +1301,79 @@ fn paint_border( .map(|r| resolve_border_radius(r, layout, ctx)) .unwrap_or([0.0; 4]); - let outer = border_rrect(layout, radius); - let inner = inner_rrect(layout, radius); + match style { + BorderStyle::None => {} + BorderStyle::Solid => { + let outer = border_rrect(layout, radius); + let inner = inner_rrect(layout, radius); - let mut paint = Paint::default(); - paint.set_anti_alias(true); - paint.set_style(PaintStyle::Fill); - paint.set_color(color); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + paint.set_color(color); - canvas.draw_drrect(outer, inner, &paint); + canvas.draw_drrect(outer, inner, &paint); + } + BorderStyle::Dashed | BorderStyle::Dotted => { + let centerline = border_stroke_rrect(layout, radius, max_w / 2.0); + let mut builder = PathBuilder::new(); + builder.add_rrect(centerline, None, None); + let path = builder.detach(); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Stroke); + paint.set_color(color); + paint.set_stroke_width(max_w); + + let intervals = if matches!(style, BorderStyle::Dotted) { + paint.set_stroke_cap(skia_safe::PaintCap::Round); + [0.001, max_w * 2.0] + } else { + [max_w * 3.0, max_w * 3.0] + }; + if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { + paint.set_path_effect(dash); + } + + canvas.draw_path(&path, &paint); + } + BorderStyle::Double => { + let stroke_w = max_w / 3.0; + if stroke_w <= 0.0 { + return; + } + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Stroke); + paint.set_color(color); + paint.set_stroke_width(stroke_w); + + for inset in [stroke_w / 2.0, max_w - stroke_w / 2.0] { + let centerline = border_stroke_rrect(layout, radius, inset); + let mut builder = PathBuilder::new(); + builder.add_rrect(centerline, None, None); + canvas.draw_path(&builder.detach(), &paint); + } + } + } +} + +fn border_stroke_rrect(layout: &BoxLayout, radius: [f32; 4], inset: f32) -> RRect { + let rect = Rect::from_xywh( + layout.x + inset, + layout.y + inset, + (layout.width - inset * 2.0).max(0.0), + (layout.height - inset * 2.0).max(0.0), + ); + let r = [ + (radius[0] - inset).max(0.0), + (radius[1] - inset).max(0.0), + (radius[2] - inset).max(0.0), + (radius[3] - inset).max(0.0), + ]; + rrect_from_corners(rect, r) } fn paint_gradient_border( @@ -1688,6 +1752,187 @@ fn clip_path_to_skia( ); None } + + ClipPath::Morph { from, to, progress } => { + morph_clip_path_to_skia(from, to, *progress, layout, ctx) + } + } +} + +fn clip_path_kind_name(clip: &ClipPath) -> &'static str { + match clip { + ClipPath::None => "none", + ClipPath::Inset { .. } => "inset", + ClipPath::Circle { .. } => "circle", + ClipPath::Ellipse { .. } => "ellipse", + ClipPath::Polygon { .. } => "polygon", + ClipPath::Path { .. } => "path", + ClipPath::NodePath { .. } => "node-path", + ClipPath::Morph { .. } => "morph", + } +} + +fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +fn morph_clip_path_to_skia( + from: &ClipPath, + to: &ClipPath, + progress: f32, + layout: &BoxLayout, + ctx: &LengthContext, +) -> Option { + let ctx_w = LengthContext { + parent_size: layout.width, + ..*ctx + }; + let ctx_h = LengthContext { + parent_size: layout.height, + ..*ctx + }; + let t = progress; + + match (from, to) { + ( + ClipPath::Inset { + top: t0, + right: r0, + bottom: b0, + left: l0, + radius: rad0, + }, + ClipPath::Inset { + top: t1, + right: r1, + bottom: b1, + left: l1, + radius: rad1, + }, + ) => { + let top = lerp_f32(t0.resolve(&ctx_h), t1.resolve(&ctx_h), t); + let right = lerp_f32(r0.resolve(&ctx_w), r1.resolve(&ctx_w), t); + let bottom = lerp_f32(b0.resolve(&ctx_h), b1.resolve(&ctx_h), t); + let left = lerp_f32(l0.resolve(&ctx_w), l1.resolve(&ctx_w), t); + let width = (layout.width - left - right).max(0.0); + let height = (layout.height - top - bottom).max(0.0); + let rect = Rect::from_xywh(layout.x + left, layout.y + top, width, height); + let corners0 = rad0 + .as_ref() + .map(|r| resolve_border_radius(r, layout, ctx)) + .unwrap_or([0.0; 4]); + let corners1 = rad1 + .as_ref() + .map(|r| resolve_border_radius(r, layout, ctx)) + .unwrap_or([0.0; 4]); + let corners = [ + lerp_f32(corners0[0], corners1[0], t), + lerp_f32(corners0[1], corners1[1], t), + lerp_f32(corners0[2], corners1[2], t), + lerp_f32(corners0[3], corners1[3], t), + ]; + let mut builder = PathBuilder::new(); + builder.add_rrect(rrect_from_corners(rect, corners), None, None); + Some(builder.detach()) + } + + ( + ClipPath::Circle { + radius: r0, + origin: o0, + }, + ClipPath::Circle { + radius: r1, + origin: o1, + }, + ) => { + let (cx0, cy0, _) = resolve_origin(o0.as_ref(), layout, ctx); + let (cx1, cy1, _) = resolve_origin(o1.as_ref(), layout, ctx); + let reference = LengthContext { + parent_size: (layout.width.powi(2) + layout.height.powi(2)).sqrt() + / std::f32::consts::SQRT_2, + ..*ctx + }; + let radius = lerp_f32(r0.resolve(&reference), r1.resolve(&reference), t); + let cx = lerp_f32(cx0, cx1, t); + let cy = lerp_f32(cy0, cy1, t); + if radius <= 0.0 { + return Some(PathBuilder::new().detach()); + } + let mut builder = PathBuilder::new(); + builder.add_circle((cx, cy), radius, None); + Some(builder.detach()) + } + + ( + ClipPath::Ellipse { + rx: rx0, + ry: ry0, + origin: o0, + }, + ClipPath::Ellipse { + rx: rx1, + ry: ry1, + origin: o1, + }, + ) => { + let (cx0, cy0, _) = resolve_origin(o0.as_ref(), layout, ctx); + let (cx1, cy1, _) = resolve_origin(o1.as_ref(), layout, ctx); + let a = lerp_f32(rx0.resolve(&ctx_w), rx1.resolve(&ctx_w), t); + let b = lerp_f32(ry0.resolve(&ctx_h), ry1.resolve(&ctx_h), t); + let cx = lerp_f32(cx0, cx1, t); + let cy = lerp_f32(cy0, cy1, t); + if a <= 0.0 || b <= 0.0 { + return Some(PathBuilder::new().detach()); + } + let mut builder = PathBuilder::new(); + builder.add_oval( + Rect::from_xywh(cx - a, cy - b, a * 2.0, b * 2.0), + None, + None, + ); + Some(builder.detach()) + } + + (ClipPath::Polygon { points: p0 }, ClipPath::Polygon { points: p1 }) => { + if p0.len() != p1.len() { + eprintln!( + "rustmotion: clip-path morph between two polygons with different vertex \ + counts ({} vs {}) has no point-by-point correspondence to interpolate \ + along. Nothing is clipped this frame — give both keyframes the same \ + number of points.", + p0.len(), + p1.len() + ); + return None; + } + if p0.len() < 3 { + return Some(PathBuilder::new().detach()); + } + let mut builder = PathBuilder::new(); + for (i, ((x0, y0), (x1, y1))) in p0.iter().zip(p1.iter()).enumerate() { + let x = layout.x + lerp_f32(x0.resolve(&ctx_w), x1.resolve(&ctx_w), t); + let y = layout.y + lerp_f32(y0.resolve(&ctx_h), y1.resolve(&ctx_h), t); + if i == 0 { + builder.move_to((x, y)); + } else { + builder.line_to((x, y)); + } + } + builder.close(); + Some(builder.detach()) + } + + _ => { + eprintln!( + "rustmotion: clip-path morph needs both keyframes to be the same kind \ + (inset/circle/ellipse/polygon, each with itself) — got \"{}\" and \"{}\". \ + Nothing is clipped this frame.", + clip_path_kind_name(from), + clip_path_kind_name(to) + ); + None + } } } @@ -3198,6 +3443,144 @@ mod paint_order_tests { ); } + #[test] + fn animated_border_radius_reaches_the_paint_pass() { + use crate::css::apply_animated_props; + use crate::engine::animator::AnimatedProperties; + + let mut css = CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(100.0)), + top: Some(CLP::Px(50.0)), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(200.0))), + background: Some(Background::Color(CssColor::String("#000000".into()))), + ..Default::default() + }; + apply_animated_props( + &mut css, + &AnimatedProperties { + border_radius: 100.0, + ..AnimatedProperties::default() + }, + ); + + let node = BoxNode { + id: 0, + kind: BoxKind::Container, + css, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = root_node(400.0, 300.0, "#ffffff", vec![node]); + let buf = render_pixels(&mut root, 400, 300); + + let corner = probe(&buf, 400, 105, 55); + assert_eq!( + corner, + (255, 255, 255), + "a border-radius produced by an animation (not a literal style value) must round \ + the square's corner away — got {corner:?}, meaning the animated value never \ + reached css.border_radius" + ); + } + + fn bordered_box(w: f32, h: f32, style: BorderStyle) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(20.0)), + top: Some(CLP::Px(20.0)), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + border: Some(BorderEdges { + width: Some(Edges::Uniform(CLP::Px(8.0))), + style: Some(style), + color: Some(CssColor::String("#000000".into())), + ..Default::default() + }), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn count_background_gaps_on_top_edge(buf: &[u8], w: u32, x0: u32, x1: u32, y: u32) -> usize { + let mut gaps = 0; + let mut in_gap = false; + for x in x0..x1 { + let i = ((y * w + x) * 4) as usize; + let is_background = buf[i] > 200 && buf[i + 1] > 200 && buf[i + 2] > 200; + if is_background { + if !in_gap { + gaps += 1; + } + in_gap = true; + } else { + in_gap = false; + } + } + gaps + } + + #[test] + fn dashed_border_leaves_background_coloured_gaps_along_the_edge() { + let mut solid_root = root_node( + 400.0, + 400.0, + "#ffffff", + vec![bordered_box(300.0, 100.0, BorderStyle::Solid)], + ); + let solid_buf = render_pixels(&mut solid_root, 400, 400); + let solid_gaps = count_background_gaps_on_top_edge(&solid_buf, 400, 40, 300, 24); + assert_eq!( + solid_gaps, 0, + "a solid border's top edge must be painted over its full length — got {solid_gaps} \ + background-coloured gaps" + ); + + let mut dashed_root = root_node( + 400.0, + 400.0, + "#ffffff", + vec![bordered_box(300.0, 100.0, BorderStyle::Dashed)], + ); + let dashed_buf = render_pixels(&mut dashed_root, 400, 400); + let dashed_gaps = count_background_gaps_on_top_edge(&dashed_buf, 400, 40, 300, 24); + assert!( + dashed_gaps > 0, + "a dashed border must leave background-coloured gaps along its top edge — got \ + {dashed_gaps}, meaning border.style was read only to gate BorderStyle::None and \ + every other variant painted the same solid ring" + ); + } + + #[test] + fn dotted_and_double_borders_are_not_painted_as_a_solid_fill() { + for style in [BorderStyle::Dotted, BorderStyle::Double] { + let mut root = root_node( + 400.0, + 400.0, + "#ffffff", + vec![bordered_box(300.0, 100.0, style)], + ); + let buf = render_pixels(&mut root, 400, 400); + let gaps = count_background_gaps_on_top_edge(&buf, 400, 40, 300, 24); + assert!( + gaps > 0, + "{style:?} border must not paint a solid filled ring — got {gaps} \ + background-coloured gaps along the top edge" + ); + } + } + fn material_tile(material: Option) -> BoxNode { BoxNode { id: 0, @@ -3768,6 +4151,243 @@ mod paint_order_tests { "no effect means no fringe on the right edge either, got {right_edge:?}" ); } + + #[test] + fn clip_path_morph_interpolates_a_circles_radius_between_two_keyframes() { + let morph_at = |progress: f32| ClipPath::Morph { + from: Box::new(ClipPath::Circle { + radius: CLP::Px(50.0), + origin: None, + }), + to: Box::new(ClipPath::Circle { + radius: CLP::Px(150.0), + origin: None, + }), + progress, + }; + + assert!( + is_red_at(Some(morph_at(0.0)), 230, 200), + "progress 0: 30px from centre must be inside the `from` circle (r=50)" + ); + assert!( + !is_red_at(Some(morph_at(0.0)), 280, 200), + "progress 0 must match the `from` shape exactly (r=50), not something in between" + ); + + assert!( + is_red_at(Some(morph_at(1.0)), 280, 200), + "progress 1 must match the `to` shape's larger radius (r=150)" + ); + + assert!( + is_red_at(Some(morph_at(0.5)), 280, 200), + "progress 0.5 interpolates the radius to 100 (halfway between 50 and 150); 80px \ + from centre must be inside" + ); + assert!( + !is_red_at(Some(morph_at(0.5)), 350, 200), + "150px from centre must be outside the interpolated radius of 100" + ); + } + + #[test] + fn clip_path_morph_supports_inset_and_ellipse_too() { + let inset_morph_at = |progress: f32| ClipPath::Morph { + from: Box::new(ClipPath::Inset { + top: CLP::Px(0.0), + right: CLP::Px(200.0), + bottom: CLP::Px(0.0), + left: CLP::Px(0.0), + radius: None, + }), + to: Box::new(ClipPath::Inset { + top: CLP::Px(0.0), + right: CLP::Px(0.0), + bottom: CLP::Px(0.0), + left: CLP::Px(0.0), + radius: None, + }), + progress, + }; + assert!( + !is_red_at(Some(inset_morph_at(0.0)), 380, 200), + "an inset morph at progress 0 must match the `from` shape (right:200 cuts x=380)" + ); + assert!( + is_red_at(Some(inset_morph_at(1.0)), 380, 200), + "an inset morph at progress 1 must match the `to` shape (the full box, right:0)" + ); + + let ellipse_morph_at = |progress: f32| ClipPath::Morph { + from: Box::new(ClipPath::Ellipse { + rx: CLP::Px(10.0), + ry: CLP::Px(10.0), + origin: None, + }), + to: Box::new(ClipPath::Ellipse { + rx: CLP::Px(180.0), + ry: CLP::Px(40.0), + origin: None, + }), + progress, + }; + assert!( + !is_red_at(Some(ellipse_morph_at(0.0)), 360, 200), + "an ellipse morph at progress 0 must match the `from` shape (rx=10 excludes x=360)" + ); + assert!( + is_red_at(Some(ellipse_morph_at(1.0)), 360, 200), + "an ellipse morph at progress 1 must match the `to` shape's rx=180" + ); + } + + #[test] + fn clip_path_morph_interpolates_polygon_points_when_vertex_counts_match() { + let big = ClipPath::Polygon { + points: vec![ + (CLP::Px(0.0), CLP::Px(0.0)), + (CLP::Px(400.0), CLP::Px(0.0)), + (CLP::Px(400.0), CLP::Px(400.0)), + (CLP::Px(0.0), CLP::Px(400.0)), + ], + }; + let small = ClipPath::Polygon { + points: vec![ + (CLP::Px(150.0), CLP::Px(150.0)), + (CLP::Px(250.0), CLP::Px(150.0)), + (CLP::Px(250.0), CLP::Px(250.0)), + (CLP::Px(150.0), CLP::Px(250.0)), + ], + }; + let morph = ClipPath::Morph { + from: Box::new(big), + to: Box::new(small), + progress: 1.0, + }; + assert!( + is_red_at(Some(morph.clone()), 200, 200), + "the shrunk square's centre must stay inside at progress 1" + ); + assert!( + !is_red_at(Some(morph), 20, 20), + "the corner must be outside the fully-morphed small square at progress 1" + ); + } + + #[test] + fn clip_path_morph_between_mismatched_kinds_leaves_the_node_unclipped() { + let mismatched = ClipPath::Morph { + from: Box::new(ClipPath::Circle { + radius: CLP::Px(50.0), + origin: None, + }), + to: Box::new(ClipPath::Polygon { + points: vec![ + (CLP::Px(0.0), CLP::Px(0.0)), + (CLP::Px(400.0), CLP::Px(0.0)), + (CLP::Px(200.0), CLP::Px(400.0)), + ], + }), + progress: 0.5, + }; + for (x, y) in [(20, 20), (200, 200), (380, 380)] { + assert_eq!( + is_red_at(Some(mismatched.clone()), x, y), + is_red_at(None, x, y), + "a clip-path morph between two different kinds cannot be interpolated — it \ + must leave the node unclipped rather than silently snap between the two \ + shapes, at ({x}, {y})" + ); + } + } + + #[test] + fn clip_path_morph_between_polygons_with_different_vertex_counts_leaves_the_node_unclipped() { + let triangle = ClipPath::Polygon { + points: vec![ + (CLP::Px(200.0), CLP::Px(0.0)), + (CLP::Px(400.0), CLP::Px(400.0)), + (CLP::Px(0.0), CLP::Px(400.0)), + ], + }; + let square = ClipPath::Polygon { + points: vec![ + (CLP::Px(0.0), CLP::Px(0.0)), + (CLP::Px(400.0), CLP::Px(0.0)), + (CLP::Px(400.0), CLP::Px(400.0)), + (CLP::Px(0.0), CLP::Px(400.0)), + ], + }; + let mismatched = ClipPath::Morph { + from: Box::new(triangle), + to: Box::new(square), + progress: 0.5, + }; + for (x, y) in [(20, 20), (200, 200), (380, 380)] { + assert_eq!( + is_red_at(Some(mismatched.clone()), x, y), + is_red_at(None, x, y), + "a polygon vertex-count mismatch (3 vs 4) cannot be interpolated point-by-point \ + — it must leave the node unclipped, at ({x}, {y})" + ); + } + } + + #[test] + fn animated_clip_path_progress_reaches_the_paint_pass() { + use crate::css::apply_animated_props; + use crate::engine::animator::AnimatedProperties; + + let mut css = CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(0.0)), + top: Some(CLP::Px(0.0)), + width: Some(CSize::Length(CLP::Px(400.0))), + height: Some(CSize::Length(CLP::Px(400.0))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + clip_path: Some(ClipPath::Morph { + from: Box::new(ClipPath::Circle { + radius: CLP::Px(50.0), + origin: None, + }), + to: Box::new(ClipPath::Circle { + radius: CLP::Px(150.0), + origin: None, + }), + progress: 0.0, + }), + ..Default::default() + }; + apply_animated_props( + &mut css, + &AnimatedProperties { + clip_path_progress: 1.0, + ..AnimatedProperties::default() + }, + ); + + let node = BoxNode { + id: 0, + kind: BoxKind::Container, + css, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = root_node(400.0, 400.0, "#000000", vec![node]); + let buf = render_pixels(&mut root, 400, 400); + + let inside_the_grown_circle = probe(&buf, 400, 280, 200); + assert_eq!( + inside_the_grown_circle, + (255, 0, 0), + "clip_path_progress written by an animation must reach the morph's `progress` \ + field — got {inside_the_grown_circle:?}, expected the fully-swept 150px `to` \ + circle to cover this point (80px from centre)" + ); + } } #[cfg(test)] diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 2f836ae..8c5c1ee 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -636,6 +636,7 @@ const KNOWN_MOTION_PROPERTIES: &[&str] = &[ "draw_progress", "motion_progress", "color", + "clip_path_progress", ]; fn validate_motion_property(value: &str) -> Result<(), E> { diff --git a/crates/rustmotion/skills/rules/border-style.md b/crates/rustmotion/skills/rules/border-style.md new file mode 100644 index 0000000..48b8f66 --- /dev/null +++ b/crates/rustmotion/skills/rules/border-style.md @@ -0,0 +1,59 @@ +# `border.style` — dashed, dotted, double + +`BorderStyle` accepte `none | solid | dashed | dotted | double`. Les cinq peignent +désormais un rendu distinct (issue #375) — avant, tout sauf `none` produisait le +même anneau plein que `solid`. + +| `style` | Rendu | +|---|---| +| `none` | rien | +| `solid` | anneau plein (comportement historique, inchangé) | +| `dashed` | tirets de longueur `3×width`, espacés de `3×width` | +| `dotted` | points ronds de diamètre `width`, espacés d'environ `2×width` | +| `double` | deux traits de `width/3`, séparés par un espace de `width/3` | + +```json +{ + "type": "div", + "style": { + "width": 300, "height": 200, "border-radius": 20, + "border": { "color": "#FD2E92", "width": 4, "style": "dashed" } + } +} +``` + +Le trait suit le contour arrondi (`border-radius` s'applique normalement) : +`dashed`/`dotted` tracent le tracé au **centre** de l'épaisseur de bordure +(comme la version pleine), `double` place un trait au bord externe et un au +bord interne, avec l'écart au milieu. + +**Limite connue :** les quatre côtés partagent une seule épaisseur de trait — +celle du côté le plus large (`border.width` par côté n'est pas pris en compte +séparément pour choisir la cadence des tirets/points). Un besoin de bordure +dashed asymétrique (haut ≠ droite) n'est pas couvert. + +## Animer `border-radius` en `keyframes` + +`border_radius` est une propriété scalaire acceptée par l'animation +`keyframes` — un carré qui devient un cercle, ou l'inverse (issue #373) : + +```json +{ + "type": "div", + "style": { + "width": 200, "height": 200, "background": "#000000", + "animation": [ + { "name": "keyframes", "keyframes": [ + { "property": "border_radius", "keyframes": [ + { "time": 0, "value": 0 }, { "time": 1, "value": 100 } + ] } + ] } + ] + } +} +``` + +Elle interpole toujours vers un rayon **uniforme** (un seul nombre en px) — +comme pour `style.transition`/`timeline` (voir +[timeline-sequencing.md](timeline-sequencing.md)), les rayons par coin +(`{ "top-left": ..., ... }`) ne sont pas animables. diff --git a/crates/rustmotion/skills/rules/clip-path-morph.md b/crates/rustmotion/skills/rules/clip-path-morph.md new file mode 100644 index 0000000..992a0f9 --- /dev/null +++ b/crates/rustmotion/skills/rules/clip-path-morph.md @@ -0,0 +1,63 @@ +# Animer `clip-path` : `kind: "morph"` + +[clip-path.md](clip-path.md) décrit les six formes statiques. Aucune ne +s'anime — un `clip-path` posé en `style` est figé pour toute la scène. `morph` +(issue #384) est la septième forme, et la seule pensée pour bouger : elle fixe +deux formes (`from`/`to`) et une `progress` qui balaie l'une vers l'autre. + +```json +{ + "type": "div", + "style": { + "width": 1920, "height": 1080, + "clip-path": { + "kind": "morph", + "from": { "kind": "polygon", "points": [[320,0],[1600,0],[1920,320],[1920,1080],[0,1080],[0,320]] }, + "to": { "kind": "polygon", "points": [[0,0],[1920,0],[1920,780],[1620,1080],[300,1080],[0,780]] }, + "progress": 0 + }, + "animation": [ + { "name": "keyframes", "keyframes": [ + { "property": "clip_path_progress", "easing": "ease_in_out", + "keyframes": [ { "time": 0, "value": 0 }, { "time": 2, "value": 1 } ] } + ] } + ] + } +} +``` + +`progress` littéral dans `clip-path` n'est que la position au repos ; c'est +l'animation `keyframes` sur la propriété `clip_path_progress` (une propriété +scalaire ordinaire, au même titre que `opacity` ou `border_radius`) qui la +fait bouger — `spring`, `loop`, easing par keyframe : tout ce que `keyframes` +sait déjà faire sur un scalaire s'applique donc ici aussi. + +## Ce qui s'interpole + +| `from`/`to` | Interpolation | +|---|---| +| `polygon` / `polygon`, **même nombre de points** | point par point | +| `inset` / `inset` | `top`/`right`/`bottom`/`left`/`radius` un par un | +| `circle` / `circle` | `radius` et `origin` | +| `ellipse` / `ellipse` | `rx`/`ry` et `origin` | + +`from` et `to` doivent être **le même `kind`**, et pour `polygon` **le même +nombre de points** — sans ça il n'existe aucune correspondance point-à-point à +interpoler. Le mismatch n'est pas absorbé silencieusement : un message est +écrit sur stderr nommant les deux `kind` (ou les deux comptes de points), et le +nœud reste **non clippé** pour la frame — pas de snap brutal entre les deux +formes. + +`none`, `path` et `node-path` ne sont pas interpolables (pas de correspondance +géométrique évidente) ; les utiliser comme `from` ou `to` tombe dans le même +cas « non clippé + message stderr ». + +## Ce que `morph` ne fait pas + +- Un seul couple `from`/`to` par nœud — pas de chaîne à N formes sur une seule + propriété. Pour plusieurs silhouettes successives, il faut plusieurs beats + (scènes ou nœuds superposés avec un crossfade), pas un seul `morph`. +- `timeline` + `style.transition` ne lisse toujours pas `clip-path` (voir + [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`. From 40c91420f907ddd97ffea661c404a243cd857b39 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 22:28:57 +0200 Subject: [PATCH 2/2] docs(skills): link the border-style and clip-path morph rules, and correct clip-path.md The line saying clip-path never interpolates was written when that was true of every path. Keyframes reach it now through the morph variant; a timeline still snaps it. --- crates/rustmotion/skills/SKILL.md | 2 ++ crates/rustmotion/skills/rules/clip-path.md | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 870c2a7..36784c5 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -246,6 +246,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/vhs-tear.md](rules/vhs-tear.md) - The `vhs` scene effect: bands slid sideways, noise, scanlines and a travelling tracking line — bounded by `at`/`duration` because it is a beat, not a filter - [rules/rich-text-pills.md](rules/rich-text-pills.md) - Pill spans in `rich_text`: padding that moves the following span, `box-decoration-break: clone` on a wrap, and rotation that turns the box without touching layout - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds +- [rules/border-style.md](rules/border-style.md) - `dashed`, `dotted` and `double` borders, their cadence formulas, and animating `border-radius` by keyframe +- [rules/clip-path-morph.md](rules/clip-path-morph.md) - Animating a `clip-path` via `kind: "morph"` and the scalar `clip_path_progress`, what interpolates, and what a kind mismatch does - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding diff --git a/crates/rustmotion/skills/rules/clip-path.md b/crates/rustmotion/skills/rules/clip-path.md index 56a305a..ecce190 100644 --- a/crates/rustmotion/skills/rules/clip-path.md +++ b/crates/rustmotion/skills/rules/clip-path.md @@ -47,7 +47,12 @@ attendant. ## Ce que `clip-path` ne fait pas -Il ne s'interpole pas dans un `timeline`. C'est une propriété de peinture non +Il s'anime par `keyframes`, mais pas comme les autres propriétés : on déclare les +deux formes d'un coup avec `kind: "morph"` et c'est un scalaire, +`clip_path_progress`, qui balaie de l'une à l'autre. Voir +[clip-path-morph.md](clip-path-morph.md). + +En revanche il ne s'interpole pas dans un `timeline`. C'est une propriété de peinture non supportée à l'animation — voir [timeline-sequencing.md](timeline-sequencing.md). Pour une révélation progressive, animer un `transform` sous un parent `overflow: hidden` reste la voie.