From 2420cc8584a33a3a5c5e939c053103d178c03e2c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 23:24:45 +0200 Subject: [PATCH 1/3] feat(engine): real transform-origin and perspective-origin Both properties were parsed but the paint pivot was hardcoded to the box center. resolve_origin (pure) maps px/percent/keyword components to absolute pivots (absent = center, byte-identical non-regression); apply_transform gains three paths: unchanged 2D, single-pivot 3D, and dual-pivot 3D wrapping the perspective matrix in its own translate pair when perspective-origin differs. Keywords left/center/right/ top/bottom added additively to the length parser. Unlocks hinge/edge rotations (3D doors, side-pivoting cards); skill 3D rule documents the configurable pivot. --- .../skills/rustmotion/rules/3d-perspective.md | 28 + crates/rustmotion-core/src/css/units.rs | 17 + .../rustmotion-core/src/engine/paint_pass.rs | 542 +++++++++++++++++- 3 files changed, 577 insertions(+), 10 deletions(-) diff --git a/.claude/skills/rustmotion/rules/3d-perspective.md b/.claude/skills/rustmotion/rules/3d-perspective.md index ffc6a6b..74b06a1 100644 --- a/.claude/skills/rustmotion/rules/3d-perspective.md +++ b/.claude/skills/rustmotion/rules/3d-perspective.md @@ -82,6 +82,34 @@ Utilise la propriété `"name": "keyframes"` avec les propriétés `rotate_x`, ` Les rotations >30° déforment le contenu et rendent le texte illisible. Rester dans −30..30. +### Pivot configurable : `transform-origin` et `perspective-origin` + +Le pivot de rotation/scale est configurable via `style["transform-origin"]` (et `style["perspective-origin"]` pour le point de fuite de la perspective). Les deux propriétés acceptent : + +- Mots-clés : `"left"`, `"center"`, `"right"` (axe X) ; `"top"`, `"center"`, `"bottom"` (axe Y) +- Pourcentages : `"0%"` (coin), `"50%"` (centre, défaut), `"100%"` (bord opposé) +- Valeurs px : ex. `"20px"` (relatif au coin haut-gauche de la box) + +```json +{ + "style": { + "transform-origin": { "x": "left", "y": "center" }, + "animation": [ + { + "name": "keyframes", + "keyframes": [ + { "property": "rotate_y", "keyframes": [{ "time": 0, "value": -45 }, { "time": 1, "value": 0 }] }, + { "property": "perspective", "keyframes": [{ "time": 0, "value": 800 }, { "time": 1, "value": 800 }] } + ] + } + ] + } +} +``` + +L'absence de `transform-origin` = pivot au centre de la box (comportement par défaut, inchangé). +`perspective-origin` par défaut = même pivot que `transform-origin`. + --- ## Shadow 3D adaptatif (automatique) diff --git a/crates/rustmotion-core/src/css/units.rs b/crates/rustmotion-core/src/css/units.rs index 701c7fe..5398eeb 100644 --- a/crates/rustmotion-core/src/css/units.rs +++ b/crates/rustmotion-core/src/css/units.rs @@ -90,6 +90,23 @@ impl ParsedLength { } } +/// Parse a CSS length/percentage string that may also contain transform-origin +/// axis keywords (`left`, `center`, `right`, `top`, `bottom`). +/// +/// Keywords are normalised to `ParsedLength::Percent` so that the regular +/// `resolve` machinery handles them — the caller must still choose the correct +/// axis dimension (width for x, height for y) in the `LengthContext`. +pub fn parse_origin_component(s: &str) -> Option { + let lower = s.trim().to_ascii_lowercase(); + match lower.as_str() { + "left" | "top" => return Some(ParsedLength::Percent(0.0)), + "center" => return Some(ParsedLength::Percent(50.0)), + "right" | "bottom" => return Some(ParsedLength::Percent(100.0)), + _ => {} + } + parse_length(s) +} + /// Parse a CSS length/percentage string. Whitespace tolerated. pub fn parse_length(s: &str) -> Option { let s = s.trim(); diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index ad69f73..82ef758 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -24,9 +24,9 @@ use skia_safe::{ use crate::css::style::{ Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, Color, - CssStyle, Edges, Overflow, TransformFn, + CssStyle, Edges, Overflow, TransformFn, TransformOrigin, }; -use crate::css::units::{LengthContext, LengthPercentage, ParsedLength}; +use crate::css::units::{parse_origin_component, LengthContext, LengthPercentage, ParsedLength}; use crate::engine::box_tree::{BoxKind, BoxNode, NodeId}; use crate::engine::layout_pass::{BoxLayout, LayoutResult}; @@ -181,17 +181,35 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { // 2. transform if node.css.transform.is_some() || node.css.perspective.is_some() { - let pivot = ( - box_layout.x + box_layout.width / 2.0, - box_layout.y + box_layout.height / 2.0, - ); + let (tx, ty, _tz) = + resolve_origin(node.css.transform_origin.as_ref(), box_layout, &length_ctx); + let transform_pivot = (tx, ty); + + let perspective_pivot = if node.css.perspective_origin.is_some() { + let (px, py, _pz) = resolve_origin( + node.css.perspective_origin.as_ref(), + box_layout, + &length_ctx, + ); + (px, py) + } else { + transform_pivot + }; + let transform_list = node.css.transform.as_deref().unwrap_or(&[]); let perspective_d = node .css .perspective .as_ref() .map(|l| l.resolve(&length_ctx).max(1.0)); - apply_transform(canvas, transform_list, perspective_d, pivot, &length_ctx); + apply_transform( + canvas, + transform_list, + perspective_d, + transform_pivot, + perspective_pivot, + &length_ctx, + ); } // Hit-map: record the on-screen bbox of component-backed nodes. The canvas @@ -474,6 +492,75 @@ fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> { // ---- Transform ---- +/// Resolve a `transform-origin` / `perspective-origin` value to absolute +/// viewport coordinates `(x, y)`. +/// +/// # Resolution rules +/// +/// - Absent `origin` → centre of the box (the pre-existing hard-coded behaviour, +/// preserved byte-identically). +/// - `x` / `y` values follow the CSS spec: percentages are relative to the box +/// **width** (for x) and **height** (for y); px values are relative to the +/// box top-left corner. The result is in absolute viewport coordinates. +/// - Keywords (`left`, `center`, `right`, `top`, `bottom`) are handled by +/// `parse_origin_component` which maps them to 0%/50%/100%. +/// - An absent component (`None`) defaults to 50% on that axis. +/// - The `z` component is returned as `f32` for use in the 3-D path (defaults +/// to 0.0 when absent). +fn resolve_origin( + origin: Option<&TransformOrigin>, + layout: &BoxLayout, + ctx: &LengthContext, +) -> (f32, f32, f32) { + let Some(o) = origin else { + // Default: 50% 50% 0 — dead-centre of the box. + return ( + layout.x + layout.width / 2.0, + layout.y + layout.height / 2.0, + 0.0, + ); + }; + + // Resolve x against box width. + let ox = if let Some(lp) = &o.x { + let parsed = match lp { + LengthPercentage::String(s) => { + parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0)) + } + LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v), + }; + let local_ctx = LengthContext { + parent_size: layout.width, + ..*ctx + }; + layout.x + parsed.resolve(&local_ctx).unwrap_or(layout.width / 2.0) + } else { + layout.x + layout.width / 2.0 + }; + + // Resolve y against box height. + let oy = if let Some(lp) = &o.y { + let parsed = match lp { + LengthPercentage::String(s) => { + parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0)) + } + LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v), + }; + let local_ctx = LengthContext { + parent_size: layout.height, + ..*ctx + }; + layout.y + parsed.resolve(&local_ctx).unwrap_or(layout.height / 2.0) + } else { + layout.y + layout.height / 2.0 + }; + + // Resolve z (optional; only used in 3D path). + let oz = o.z.as_ref().map(|l| l.resolve(ctx)).unwrap_or(0.0); + + (ox, oy, oz) +} + fn has_3d_transform(list: &[TransformFn]) -> bool { list.iter().any(|t| { matches!( @@ -490,15 +577,31 @@ fn has_3d_transform(list: &[TransformFn]) -> bool { }) } +/// Apply CSS transform + perspective to the canvas. +/// +/// # Parameters +/// +/// - `transform_pivot`: the origin for the `transform` property (resolved from +/// `transform-origin`, defaults to box centre). +/// - `perspective_pivot`: the origin for the perspective projection (resolved +/// from `perspective-origin`). When `perspective-origin` is absent this equals +/// `transform_pivot` and we use the cheaper single-translate path. When they +/// differ we bracket the perspective matrix with its own translate pair. fn apply_transform( canvas: &Canvas, list: &[TransformFn], perspective_d: Option, - pivot: (f32, f32), + transform_pivot: (f32, f32), + perspective_pivot: (f32, f32), ctx: &LengthContext, ) { + // Detect whether perspective and transform pivots differ. + let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001 + && (transform_pivot.1 - perspective_pivot.1).abs() < 0.001; + if perspective_d.is_none() && !has_3d_transform(list) { // Fast path: 2D-only, use the native Skia 2D canvas API. + let pivot = transform_pivot; canvas.translate(Point::new(pivot.0, pivot.1)); for tr in list { match tr { @@ -545,8 +648,10 @@ fn apply_transform( } } canvas.translate(Point::new(-pivot.0, -pivot.1)); - } else { - // 3D path: compose a single M44 and apply via concat_44 for true perspective. + } else if pivots_equal { + // 3D path, single-pivot (fast): perspective + transforms share the same pivot. + // Structure: T(pivot) · Persp · Transforms · T(-pivot) + let pivot = transform_pivot; let mut m = M44::new_identity(); m.pre_concat(&M44::translate(pivot.0, pivot.1, 0.0)); if let Some(d) = perspective_d { @@ -556,6 +661,31 @@ fn apply_transform( m.pre_concat(&transform_to_m44(tr, ctx)); } m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0)); + canvas.concat_44(&m); + } else { + // 3D path, dual-pivot: perspective-origin ≠ transform-origin. + // Structure: T(pp) · Persp · T(-pp) · T(tp) · Transforms · T(-tp) + // + // This matches the CSS spec where `perspective-origin` shifts the + // vanishing point while `transform-origin` sets the local pivot. + let tp = transform_pivot; + let pp = perspective_pivot; + let mut m = M44::new_identity(); + + // Outer perspective bracket (perspective-origin). + m.pre_concat(&M44::translate(pp.0, pp.1, 0.0)); + if let Some(d) = perspective_d { + m.pre_concat(&css_perspective_m44(d)); + } + m.pre_concat(&M44::translate(-pp.0, -pp.1, 0.0)); + + // Inner transform bracket (transform-origin). + m.pre_concat(&M44::translate(tp.0, tp.1, 0.0)); + for tr in list { + m.pre_concat(&transform_to_m44(tr, ctx)); + } + m.pre_concat(&M44::translate(-tp.0, -tp.1, 0.0)); + canvas.concat_44(&m); } } @@ -1306,6 +1436,398 @@ mod hit_tests { } } +#[cfg(test)] +mod transform_origin_tests { + //! TDD tests for `resolve_origin` and the pivot integration in `paint_node`. + //! + //! Strategy: paint a coloured rect, read back pixel centroids / columns, and + //! verify that the transformed position matches the expected pivot behaviour. + + use super::*; + + use crate::css::style::{ + Background, Color as CssColor, CssStyle, Display, FlexDirection, Position, Size as CSize, + TransformFn, TransformOrigin, + }; + use crate::css::taffy_bridge::ConversionContext; + use crate::css::units::LengthPercentage as CLP; + use crate::engine::box_tree::{BoxKind, BoxNode}; + use crate::engine::layout_pass::run_layout; + + fn test_frame(w: u32, h: u32) -> PaintFrame { + PaintFrame { + time: 0.0, + frame_index: 0, + fps: 30, + video_width: w, + video_height: h, + scene_duration: 1.0, + } + } + + /// Render a tree and return the pixel buffer (RGBA8888). + fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + paint_tree( + surface.canvas(), + root, + &layout, + &test_frame(w, h), + &NoopDispatcher, + ); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0)); + buf + } + + /// Red channel at pixel (x, y) in a w-wide buffer. + fn r(buf: &[u8], w: u32, x: u32, y: u32) -> u8 { + buf[((y * w + x) * 4) as usize] + } + + /// Count pixels in `buf` where red > 200 (i.e., predominantly red). + fn count_red(buf: &[u8]) -> usize { + buf.chunks(4) + .filter(|px| px[0] > 200 && px[1] < 50 && px[2] < 50) + .count() + } + + /// Compute column centroid (weighted x) of pixels where red > 200. + fn red_centroid_x(buf: &[u8], w: u32, h: u32) -> f32 { + let mut sum_x = 0.0f64; + let mut count = 0.0f64; + for y in 0..h { + for x in 0..w { + if r(buf, w, x, y) > 200 + && buf[((y * w + x) * 4 + 1) as usize] < 50 + && buf[((y * w + x) * 4 + 2) as usize] < 50 + { + sum_x += x as f64; + count += 1.0; + } + } + } + if count == 0.0 { + 0.0 + } else { + (sum_x / count) as f32 + } + } + + /// Compute row centroid (weighted y) of pixels where red > 200. + fn red_centroid_y(buf: &[u8], w: u32, h: u32) -> f32 { + let mut sum_y = 0.0f64; + let mut count = 0.0f64; + for y in 0..h { + for x in 0..w { + if r(buf, w, x, y) > 200 + && buf[((y * w + x) * 4 + 1) as usize] < 50 + && buf[((y * w + x) * 4 + 2) as usize] < 50 + { + sum_y += y as f64; + count += 1.0; + } + } + } + if count == 0.0 { + 0.0 + } else { + (sum_y / count) as f32 + } + } + + fn red_box(position: Position, x: f32, y: f32, w: f32, h: f32) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(position), + left: Some(CLP::Px(x)), + top: Some(CLP::Px(y)), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn root_node(w: f32, h: f32, children: Vec) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + ..Default::default() + }, + children, + intrinsic: None, + source_path: None, + window: None, + } + } + + // ---- Test 1: no transform — pixel-identical to reference baseline ---- + + #[test] + fn no_transform_origin_unchanged_non_regression() { + // A red 100x100 box at (50, 50) with no transform: pixels must appear + // exactly at (50..150, 50..150) — no origin logic involved. + let mut root = root_node( + 300.0, + 300.0, + vec![{ + let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0); + n.css.transform = Some(vec![TransformFn::Scale { x: 1.0, y: 1.0 }]); + // transform-origin absent → should default to center (no change) + n + }], + ); + let buf = render_pixels(&mut root, 300, 300); + + // Centroid must be ~100, ~100 (center of the 50..150 range). + let cx = red_centroid_x(&buf, 300, 300); + let cy = red_centroid_y(&buf, 300, 300); + assert!((cx - 99.5).abs() < 2.0, "cx={cx}"); + assert!((cy - 99.5).abs() < 2.0, "cy={cy}"); + } + + // ---- Test 2: 50% 50% == absent (byte-identical behavior) ---- + + #[test] + fn transform_origin_50pct_is_center_identity() { + let make_root = |with_origin: bool| -> Vec { + let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0); + n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]); + if with_origin { + n.css.transform_origin = Some(TransformOrigin { + x: Some(CLP::String("50%".into())), + y: Some(CLP::String("50%".into())), + z: None, + }); + } + let mut root = root_node(300.0, 300.0, vec![n]); + render_pixels(&mut root, 300, 300) + }; + + let without = make_root(false); + let with_50 = make_root(true); + assert_eq!( + without, with_50, + "transform-origin: 50% 50% must be byte-identical to absent origin" + ); + } + + // ---- Test 3: rotate 90° around left-top vs center — different quadrants ---- + + #[test] + fn rotate_90_left_top_vs_center_occupy_different_quadrants() { + // A red 100x100 box at (100, 100) rotated 90° around: + // - center (150, 150): box stays centered on itself, centroid ~(150, 150) + // - left-top (100, 100): the box pivots around top-left; centroid moves + let make_root_with_origin = |ox: Option, oy: Option| -> Vec { + let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0); + n.css.transform = Some(vec![TransformFn::Rotate { deg: 90.0 }]); + if ox.is_some() || oy.is_some() { + n.css.transform_origin = Some(TransformOrigin { + x: ox, + y: oy, + z: None, + }); + } + let mut root = root_node(400.0, 400.0, vec![n]); + render_pixels(&mut root, 400, 400) + }; + + // Center pivot (default). + let buf_center = make_root_with_origin(None, None); + // Left-top pivot: x=0px (relative to box left), y=0px. + let buf_left_top = make_root_with_origin( + Some(CLP::String("0%".into())), + Some(CLP::String("0%".into())), + ); + + let cx_center = red_centroid_x(&buf_center, 400, 400); + let cy_center = red_centroid_y(&buf_center, 400, 400); + let cx_lt = red_centroid_x(&buf_left_top, 400, 400); + let cy_lt = red_centroid_y(&buf_left_top, 400, 400); + + // With center pivot (150, 150), rotate 90° CW → box centroid stays at ~(150, 150). + assert!( + (cx_center - 150.0).abs() < 5.0, + "center-pivot cx should be ~150, got {cx_center}" + ); + assert!( + (cy_center - 150.0).abs() < 5.0, + "center-pivot cy should be ~150, got {cy_center}" + ); + + // With left-top pivot (100, 100), rotating 90° CW around that point: + // box center (150,150) maps to (50, 150) — centroid moves left. + // cx_lt ≈ 50, while cx_center ≈ 150. The x-shift is the distinguishing axis. + assert!( + cx_lt < cx_center - 50.0, + "left-top pivot cx ({cx_lt}) should be well left of center-pivot cx ({cx_center})" + ); + // The overall pixel-centroid distance should be large. + let dist = ((cx_lt - cx_center).powi(2) + (cy_lt - cy_center).powi(2)).sqrt(); + assert!( + dist > 50.0, + "pivots should produce clearly distinct positions (dist={dist})" + ); + } + + // ---- Test 4: keyword "left top" == "0% 0%" ---- + + #[test] + fn keyword_left_top_equals_zero_percent() { + let make_root = |origin_x: CLP, origin_y: CLP| -> Vec { + let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0); + n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]); + n.css.transform_origin = Some(TransformOrigin { + x: Some(origin_x), + y: Some(origin_y), + z: None, + }); + let mut root = root_node(400.0, 400.0, vec![n]); + render_pixels(&mut root, 400, 400) + }; + + let buf_kw = make_root(CLP::String("left".into()), CLP::String("top".into())); + let buf_pct = make_root(CLP::String("0%".into()), CLP::String("0%".into())); + assert_eq!( + buf_kw, buf_pct, + "keyword 'left top' must produce same pixels as '0% 0%'" + ); + } + + // ---- Test 5: keyword "right bottom" == "100% 100%" ---- + + #[test] + fn keyword_right_bottom_equals_100_percent() { + let make_root = |origin_x: CLP, origin_y: CLP| -> Vec { + let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0); + n.css.transform = Some(vec![TransformFn::Scale { x: 1.5, y: 1.5 }]); + n.css.transform_origin = Some(TransformOrigin { + x: Some(origin_x), + y: Some(origin_y), + z: None, + }); + let mut root = root_node(400.0, 400.0, vec![n]); + render_pixels(&mut root, 400, 400) + }; + + let buf_kw = make_root(CLP::String("right".into()), CLP::String("bottom".into())); + let buf_pct = make_root(CLP::String("100%".into()), CLP::String("100%".into())); + assert_eq!( + buf_kw, buf_pct, + "keyword 'right bottom' must produce same pixels as '100% 100%'" + ); + } + + // ---- Test 6: 3D rotate_y with left-center origin — left edge stays fixed ---- + + #[test] + fn rotate_y_left_origin_left_edge_is_stable() { + // A 200x200 red box at (100, 100). RotateY with origin "left center" + // means the left edge (x=100) is the pivot — so the leftmost painted + // column should always be near x=100 regardless of angle. + // With center origin, the center (x=200) is the pivot and the left edge moves. + let make_root = |origin_x: Option| -> Vec { + let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0); + n.css.transform = Some(vec![TransformFn::RotateY { deg: 45.0 }]); + if let Some(ox) = origin_x { + n.css.transform_origin = Some(TransformOrigin { + x: Some(ox), + y: Some(CLP::String("50%".into())), + z: None, + }); + } + let mut root = root_node(500.0, 500.0, vec![n]); + render_pixels(&mut root, 500, 500) + }; + + let buf_left = make_root(Some(CLP::String("0%".into()))); + let buf_center = make_root(None); + + // Find the leftmost red pixel x for each render. + fn leftmost_red(buf: &[u8], w: u32, h: u32) -> Option { + for x in 0..w { + for y in 0..h { + if buf[((y * w + x) * 4) as usize] > 200 + && buf[((y * w + x) * 4 + 1) as usize] < 50 + && buf[((y * w + x) * 4 + 2) as usize] < 50 + { + return Some(x); + } + } + } + None + } + + let left_edge_left = + leftmost_red(&buf_left, 500, 500).expect("no red pixels in left-origin render") as f32; + let left_edge_center = leftmost_red(&buf_center, 500, 500) + .expect("no red pixels in center-origin render") as f32; + + // Left-origin: left edge stays near x=100 (pivot is exactly there). + assert!( + left_edge_left > 90.0 && left_edge_left < 115.0, + "left-origin left edge should be near 100, got {left_edge_left}" + ); + + // Center-origin: left edge moves away from 100. + assert!( + left_edge_center > left_edge_left + 20.0, + "center-origin left edge ({left_edge_center}) should be further right than left-origin ({left_edge_left})" + ); + } + + // ---- Test 7: perspective_origin ≠ transform_origin — must not panic ---- + + #[test] + fn different_perspective_and_transform_origins_no_panic() { + use crate::css::units::Length; + let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0); + n.css.transform = Some(vec![TransformFn::RotateY { deg: 30.0 }]); + n.css.perspective = Some(Length::Px(800.0)); + // perspective-origin at top-left, transform-origin at bottom-right + n.css.transform_origin = Some(TransformOrigin { + x: Some(CLP::String("100%".into())), + y: Some(CLP::String("100%".into())), + z: None, + }); + n.css.perspective_origin = Some(TransformOrigin { + x: Some(CLP::String("0%".into())), + y: Some(CLP::String("0%".into())), + z: None, + }); + let mut root = root_node(500.0, 500.0, vec![n]); + // Must not panic and must produce at least some red pixels. + let buf = render_pixels(&mut root, 500, 500); + let red_count = count_red(&buf); + assert!( + red_count > 0, + "expected some red pixels with distinct origins" + ); + } +} + #[cfg(test)] mod tests { use super::*; From 55989513235f2ca462e1a48a1cee321cdaa5d024 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 23:50:14 +0200 Subject: [PATCH 2/3] feat(engine): complete the glassmorphism kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gradient-border wired for real: css field (reusing the schema type) painted as a DRRect ring filled with a linear gradient shader, radius-aware (inner radii floored), replacing the standard border when both are set; the never-called components helper is deleted; angle follows the existing background-gradient convention - FilterFn::Noise{intensity, seed}: Skia fractal_noise -> luminance color-matrix scaled by intensity -> Overlay blend over the chain, usable in filter AND backdrop-filter — per-element frosted grain; determinism proven (same seed = byte-identical frames) - zombie resolution with a discovery: the legacy LayerStyle is entirely dead (zero consumers) so backdrop-blur/inner-shadow were hard-rejected by CssStyle deny_unknown_fields rather than silently ignored; they are now accepted compat fields with validator warnings pointing to backdrop-filter / box-shadow inset, and removed from the dead LayerStyle; skill docs corrected (they oversold all three fields) --- .claude/skills/rustmotion/SKILL.md | 19 +- .../skills/rustmotion/rules/glassmorphism.md | 39 +- .../src/commands/validate_schema.rs | 44 ++ crates/rustmotion-components/src/lib.rs | 55 -- crates/rustmotion-core/src/css/style.rs | 33 +- .../rustmotion-core/src/engine/paint_pass.rs | 552 +++++++++++++++++- crates/rustmotion-core/src/schema/style.rs | 15 +- crates/rustmotion-core/src/schema/video.rs | 4 +- 8 files changed, 686 insertions(+), 75 deletions(-) diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index 0c07ee2..9fd0636 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -2247,13 +2247,24 @@ New style fields available on all components: | Style field | Type | Default | Description | | ----------------- | ------ | ------- | -------------------------------------------------------- | -| `backdrop-blur` | f32 | `null` | Glassmorphism blur effect (pixels) | -| `gradient-border` | object | `null` | `{ "colors": [...], "width": 2, "angle": 0 }` — gradient-colored border | -| `inner-shadow` | object | `null` | `{ "color": "#000", "offset_x": 0, "offset_y": 0, "blur": 10 }` — inset shadow | +| `gradient-border` | object | `null` | `{ "colors": ["#f00", "#00f"], "width": 2, "angle": 0 }` — gradient-colored border ring, border-radius aware, painted instead of `border` when both are set | | `motion-path` | string | `null` | SVG path string that the element follows during animation | | `stagger` | f32 | `null` | Auto-delay offset per child in a container (seconds) | | `timeline` | array | `[]` | Intra-scene timeline steps — sequential animation phases | +**Deprecated (accepted but never rendered — the validator warns):** + +| Legacy field | Use instead | +| --------------- | ------------------------------------------------------------ | +| `backdrop-blur` | `backdrop-filter: [{ "fn": "blur", "radius": N }]` | +| `inner-shadow` | `box-shadow: [{ ..., "inset": true }]` | + +**Film grain (`noise` filter):** works in both `filter` and `backdrop-filter` chains. Deterministic — same `seed` produces identical grain on every frame. + +```json +{ "backdrop-filter": [{ "fn": "blur", "radius": 24 }, { "fn": "noise", "intensity": 0.15, "seed": 42 }] } +``` + --- ### 3D Perspective Transforms @@ -2269,7 +2280,7 @@ Any component can be rendered with true 3D perspective using keyframe animations "height": 400, "background": "#FFFFFF08", "border-radius": 24, - "backdrop-blur": 15, + "backdrop-filter": [{ "fn": "blur", "radius": 15 }], "border": { "color": "#FFFFFF14", "width": 1 }, "box-shadow": { "color": "#00000060", "offset_x": 0, "offset_y": 20, "blur": 60 }, "animation": [{ diff --git a/.claude/skills/rustmotion/rules/glassmorphism.md b/.claude/skills/rustmotion/rules/glassmorphism.md index ba096f3..3579ec6 100644 --- a/.claude/skills/rustmotion/rules/glassmorphism.md +++ b/.claude/skills/rustmotion/rules/glassmorphism.md @@ -19,7 +19,7 @@ Sans bordure, la carte flotte sans contour visible. Sans ombre, elle ne se détache pas du fond. **Propriété correcte : `backdrop-filter`, pas `backdrop-blur`.** -`backdrop-blur` n'existe pas dans CssStyle — utiliser `backdrop-filter` avec `{ "fn": "blur", "radius": N }`. +`backdrop-blur` est accepté pour compat mais **jamais rendu** (le validateur émet un warning) — utiliser `backdrop-filter` avec `{ "fn": "blur", "radius": N }`. Idem pour `inner-shadow` → `box-shadow` avec `"inset": true`. --- @@ -82,6 +82,43 @@ Le `box-shadow` inset blanc (`offset-y: -1`) simule un reflet de lumière en hau --- +## Grain « frosted glass » : le filtre `noise` + +Le vrai verre dépoli a une micro-texture. Le filtre `noise` (déterministe : même `seed` = même grain à chaque frame) s'ajoute à la chaîne `backdrop-filter` après le blur : + +```json +{ + "backdrop-filter": [ + { "fn": "blur", "radius": 24 }, + { "fn": "noise", "intensity": 0.12, "seed": 42 } + ] +} +``` + +| Paramètre | Défaut | Plage utile | +|---|---|---| +| `intensity` | 0.15 | 0.08–0.20 (subtil), 0.25–0.40 (texture visible) | +| `seed` | 42 | n'importe quel entier — varier entre panels pour éviter un grain identique | + +Le filtre marche aussi dans `filter` (grain sur le contenu de l'élément lui-même, style pellicule photo). + +--- + +## Bordure gradient : `gradient-border` + +Alternative premium à la bordure translucide unie — un anneau dégradé, border-radius aware, peint **à la place** de `border` quand les deux sont présents : + +```json +{ + "border-radius": 32, + "gradient-border": { "colors": ["#FFFFFF50", "#FFFFFF08"], "width": 1.5, "angle": 180 } +} +``` + +L'angle suit la même convention que les gradients `background`. Un dégradé blanc→transparent vertical simule la lumière qui accroche le haut de la carte. + +--- + ## Fond : les éléments qui brillent à travers le verre Le glassmorphisme n'a d'intérêt que s'il y a quelque chose à voir derrière. Placer des blobs colorés **derrière** la carte (`z-index: 0`) : diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index 08e2527..256f369 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -75,6 +75,20 @@ fn validate_children( p )); } + if style.backdrop_blur.is_some() { + warnings.push(format!( + "{}: style.backdrop-blur is accepted but not rendered — use \ + backdrop-filter: [{{\"fn\": \"blur\", \"radius\": N}}]", + p + )); + } + if style.inner_shadow.is_some() { + warnings.push(format!( + "{}: style.inner-shadow is accepted but not rendered — use \ + box-shadow with \"inset\": true", + p + )); + } if let Some(timed) = child.component.as_timed() { let (start, end) = timed.timing(); @@ -375,6 +389,36 @@ mod style_warning_tests { ); } + #[test] + fn warns_on_legacy_backdrop_blur_and_inner_shadow() { + // Legacy glassmorphism fields are accepted for compat but never + // rendered; validate must point at the working CSS equivalents. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "card", + "style": { + "backdrop-blur": 20, + "inner-shadow": { "color": "#000000", "offset_y": 2, "blur": 8 } + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert!( + warnings + .iter() + .any(|w| w.contains("backdrop-blur") && w.contains("backdrop-filter")), + "missing backdrop-blur warning: {warnings:?}" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("inner-shadow") && w.contains("inset")), + "missing inner-shadow warning: {warnings:?}" + ); + } + #[test] fn time_scale_zero_is_an_error() { let child: ChildComponent = serde_json::from_value(serde_json::json!({ diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 6e26112..9fe2973 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -494,58 +494,3 @@ impl Component { } } } - -/// Draw a gradient border on a rounded rectangle. -pub fn draw_gradient_border( - canvas: &skia_safe::Canvas, - rrect: &skia_safe::RRect, - gb: &rustmotion_core::schema::GradientBorder, -) { - use skia_safe::{gradient_shader::GradientShaderColors, Paint, PaintStyle, Point}; - - if gb.colors.len() < 2 { - return; - } - - let bounds = rrect.bounds(); - let angle_rad = gb.angle * std::f32::consts::PI / 180.0; - let cx = bounds.center_x(); - let cy = bounds.center_y(); - let half_diag = (bounds.width().powi(2) + bounds.height().powi(2)).sqrt() / 2.0; - - let start = Point::new( - cx - angle_rad.cos() * half_diag, - cy - angle_rad.sin() * half_diag, - ); - let end = Point::new( - cx + angle_rad.cos() * half_diag, - cy + angle_rad.sin() * half_diag, - ); - - let colors: Vec = gb - .colors - .iter() - .map(|c| { - let (r, g, b, a) = rustmotion_core::engine::renderer::parse_hex_color(c); - skia_safe::Color::from_argb(a, r, g, b) - }) - .collect(); - - let shader = skia_safe::shader::Shader::linear_gradient( - (start, end), - GradientShaderColors::Colors(&colors), - None, - skia_safe::TileMode::Clamp, - None, - None, - ); - - if let Some(shader) = shader { - let mut paint = Paint::default(); - paint.set_style(PaintStyle::Stroke); - paint.set_stroke_width(gb.width); - paint.set_anti_alias(true); - paint.set_shader(shader); - canvas.draw_rrect(rrect, &paint); - } -} diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index fd27621..c10b4b6 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -8,7 +8,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::units::{Length, LengthPercentage}; -use crate::schema::{deserialize_animation_effects, AnimationEffect}; +// `GradientBorder` / `InnerShadow` are reused from the schema layer rather +// than mirrored: same crate, same serde/JsonSchema derives, identical JSON +// shape either way — a css-local mirror would only duplicate the struct. +use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow}; /// Top-level CSS style block. All fields are optional; `None` means "not set" /// and lets the cascade fill in inherited / initial values. @@ -80,6 +83,16 @@ pub struct CssStyle { pub opacity: Option, pub mix_blend_mode: Option, pub clip_path: Option, + /// Gradient-colored border painted instead of `border` when present. + /// `{ "colors": [...], "width": 2, "angle": 0 }` — angle follows the same + /// convention as `background` linear gradients. + pub gradient_border: Option, + + // ---- Legacy compat (accepted, never rendered — validator warns) ---- + /// Deprecated: use `backdrop-filter: [{ "fn": "blur", "radius": N }]`. + pub backdrop_blur: Option, + /// Deprecated: use `box-shadow` with `"inset": true`. + pub inner_shadow: Option, // ---- Filters / effects ---- pub filter: Option>, @@ -1011,6 +1024,24 @@ pub enum FilterFn { Opacity { value: f32, }, + /// Deterministic film-grain noise. Works in both `filter` and + /// `backdrop-filter` chains (frosted-glass grain). + Noise { + /// Grain strength in 0..1 (alpha of the noise layer). Default 0.15. + #[serde(default = "default_noise_intensity")] + intensity: f32, + /// Perlin-noise seed — same seed ⇒ identical grain on every frame. + #[serde(default = "default_noise_seed")] + seed: u64, + }, +} + +fn default_noise_intensity() -> f32 { + 0.15 +} + +fn default_noise_seed() -> u64 { + 42 } // ---- Blend ---- diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 82ef758..ede7dcd 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -310,8 +310,11 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { paint_background(canvas, box_layout, &node.css, bg, &length_ctx); } - // 7. border - if let Some(border) = node.css.border.as_ref() { + // 7. border — `gradient-border` replaces the standard border when present + // (a box has one border, not two stacked ones). + if let Some(gb) = node.css.gradient_border.as_ref() { + paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx); + } else if let Some(border) = node.css.border.as_ref() { paint_border(canvas, box_layout, &node.css, border, &length_ctx); } @@ -389,6 +392,19 @@ fn filters_to_image_filter( None, ) } + FilterFn::Noise { intensity, seed } => { + match noise_image_filter(*intensity, *seed) { + // Sequential CSS composition: the chain so far is the + // background, the grain layer blends on top of it. + Some(noise) => image_filters::blend( + skia_safe::BlendMode::Overlay, + chain, + Some(noise), + None, + ), + None => chain, + } + } other => color_matrix_for(other) .map(|m| skia_safe::color_filters::matrix_row_major(&m, None)) .and_then(|cf| image_filters::color_filter(cf, chain, None)), @@ -397,6 +413,40 @@ fn filters_to_image_filter( chain } +/// Build the film-grain layer for `FilterFn::Noise` as an `ImageFilter`. +/// +/// Composition formula: +/// 1. `fractal_noise(base_frequency = (0.9, 0.9), octaves = 2, seed)` — +/// high frequency ⇒ fine per-pixel grain; the Skia Perlin shader is a +/// pure function of (x, y, seed): no implicit time, so the grain is +/// byte-identical across frames/renders for a given seed. +/// 2. A 4×5 color matrix collapses RGB to luminance (0.213/0.715/0.072) for +/// monochrome grain and scales alpha by `intensity` (0..1). +/// 3. The caller blends the result over the chain input with +/// `BlendMode::Overlay` — grain brightens/darkens the underlying pixels +/// proportionally to the noise-layer alpha (`intensity`). +fn noise_image_filter(intensity: f32, seed: u64) -> Option { + use skia_safe::image_filters; + + let intensity = intensity.clamp(0.0, 1.0); + if intensity <= 0.0 { + return None; + } + let noise = skia_safe::shaders::fractal_noise((0.9, 0.9), 2, seed as f32, None)?; + // Luminance conversion + alpha scaling in one matrix. + let (r, g, b) = (0.213, 0.715, 0.072); + #[rustfmt::skip] + let m = [ + r, g, b, 0.0, 0.0, + r, g, b, 0.0, 0.0, + r, g, b, 0.0, 0.0, + 0.0, 0.0, 0.0, intensity, 0.0, + ]; + let cf = skia_safe::color_filters::matrix_row_major(&m, None); + let mono = noise.with_color_filter(cf); + image_filters::shader(mono, None) +} + /// 4x5 row-major color matrix for a CSS color filter function (translation /// column in normalized 0..1 space), or `None` for the non-matrix functions. fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> { @@ -486,7 +536,7 @@ fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> { ]; Some(m) } - FilterFn::Blur { .. } | FilterFn::DropShadow { .. } => None, + FilterFn::Blur { .. } | FilterFn::DropShadow { .. } | FilterFn::Noise { .. } => None, } } @@ -976,6 +1026,80 @@ fn paint_border( canvas.draw_drrect(outer, inner, &paint); } +/// Paint a gradient-colored border ring (issue #87). +/// +/// Painted **instead of** the standard `border` when both are set. Unlike +/// `border`, `gradient-border` is a pure paint decoration: it does not consume +/// layout space (taffy never sees it), the ring is inset from the border-box +/// edge by `gb.width`. +/// +/// The gradient is linear along `gb.angle` with the **same angle convention as +/// `background` linear gradients** (see [`gradient_endpoints`]) so the two +/// stay visually consistent within one style block. Colors are evenly spaced. +fn paint_gradient_border( + canvas: &Canvas, + layout: &BoxLayout, + css: &CssStyle, + gb: &crate::schema::GradientBorder, + ctx: &LengthContext, +) { + if gb.colors.len() < 2 || gb.width <= 0.0 { + return; + } + let width = gb.width.min(layout.width / 2.0).min(layout.height / 2.0); + + let radius = css + .border_radius + .as_ref() + .map(|r| resolve_border_radius(r, layout, ctx)) + .unwrap_or([0.0; 4]); + + // Outer ring edge = border box; inner edge = inset by the border width. + let outer = border_rrect(layout, radius); + let inner_rect = Rect::from_xywh( + layout.x + width, + layout.y + width, + (layout.width - width * 2.0).max(0.0), + (layout.height - width * 2.0).max(0.0), + ); + let inner_radius = [ + (radius[0] - width).max(0.0), + (radius[1] - width).max(0.0), + (radius[2] - width).max(0.0), + (radius[3] - width).max(0.0), + ]; + let inner = rrect_from_corners(inner_rect, inner_radius); + + let colors: Vec = gb + .colors + .iter() + .map(|c| parse_color_string(c).unwrap_or(SColor::BLACK)) + .collect(); + let n = colors.len(); + let positions: Vec = (0..n) + .map(|i| i as f32 / (n.saturating_sub(1).max(1) as f32)) + .collect(); + + let bounds = outer.bounds(); + let (p0, p1) = gradient_endpoints(*bounds, gb.angle); + let Some(shader) = skia_safe::gradient_shader::linear( + (p0, p1), + colors.as_slice(), + positions.as_slice(), + skia_safe::TileMode::Clamp, + None, + None, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + paint.set_shader(shader); + canvas.draw_drrect(outer, inner, &paint); +} + fn border_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { let rect = Rect::from_xywh(layout.x, layout.y, layout.width, layout.height); rrect_from_corners(rect, radius) @@ -1828,6 +1952,428 @@ mod transform_origin_tests { } } +#[cfg(test)] +mod glassmorphism_tests { + //! TDD tests for issue #87: gradient-border painting and the `noise` + //! filter function (film grain, deterministic per seed). + + use super::*; + + use crate::css::style::{ + Background, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection, Position, + Size as CSize, + }; + use crate::css::taffy_bridge::ConversionContext; + use crate::css::units::LengthPercentage as CLP; + use crate::engine::box_tree::{BoxKind, BoxNode}; + use crate::engine::layout_pass::run_layout; + use crate::schema::GradientBorder; + + fn test_frame(w: u32, h: u32) -> PaintFrame { + PaintFrame { + time: 0.0, + frame_index: 0, + fps: 30, + video_width: w, + video_height: h, + scene_duration: 1.0, + } + } + + fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + paint_tree( + surface.canvas(), + root, + &layout, + &test_frame(w, h), + &NoopDispatcher, + ); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0)); + buf + } + + fn px(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) { + let i = ((y * w + x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]) + } + + /// Unique (r,g,b,a) values within a rectangular region. + fn unique_colors_in( + buf: &[u8], + w: u32, + x0: u32, + y0: u32, + x1: u32, + y1: u32, + ) -> std::collections::HashSet<(u8, u8, u8, u8)> { + let mut set = std::collections::HashSet::new(); + for y in y0..y1 { + for x in x0..x1 { + set.insert(px(buf, w, x, y)); + } + } + set + } + + fn leaf(css: CssStyle) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn root_node(w: f32, h: f32, background: Option<&str>, children: Vec) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: background.map(|c| Background::Color(CssColor::String(c.to_string()))), + ..Default::default() + }, + children, + intrinsic: None, + source_path: None, + window: None, + } + } + + fn abs_box(x: f32, y: f32, w: f32, h: f32, css_extra: CssStyle) -> BoxNode { + let mut css = css_extra; + css.position = Some(Position::Absolute); + css.left = Some(CLP::Px(x)); + css.top = Some(CLP::Px(y)); + css.width = Some(CSize::Length(CLP::Px(w))); + css.height = Some(CSize::Length(CLP::Px(h))); + leaf(css) + } + + // ---- gradient-border ---- + + #[test] + fn gradient_border_paints_both_colors_on_perimeter_center_intact() { + // 200x200 box at (100, 100) on a white root; gradient-border 12px + // red→blue along the horizontal axis (angle 90). Expect: one vertical + // border edge red-dominant, the other blue-dominant, centre untouched. + let node = abs_box( + 100.0, + 100.0, + 200.0, + 200.0, + CssStyle { + gradient_border: Some(GradientBorder { + colors: vec!["#ff0000".into(), "#0000ff".into()], + width: 12.0, + angle: 90.0, + }), + ..Default::default() + }, + ); + let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]); + let buf = render_pixels(&mut root, 400, 400); + + // Sample border midpoints: left edge (x=106, y=200), right edge (x=294, y=200). + let left = px(&buf, 400, 106, 200); + let right = px(&buf, 400, 294, 200); + + // One side red-dominant, the other blue-dominant (convention-agnostic). + let red_side = if left.0 > left.2 { left } else { right }; + let blue_side = if left.0 > left.2 { right } else { left }; + assert!( + red_side.0 > 150 && red_side.2 < 100, + "expected a red-dominant border edge, got {red_side:?}" + ); + assert!( + blue_side.2 > 150 && blue_side.0 < 100, + "expected a blue-dominant border edge, got {blue_side:?}" + ); + // Both extremes must actually differ (it's a gradient, not a flat color). + assert_ne!( + left, right, + "border edges must show different gradient stops" + ); + + // Centre of the box stays the root background (white). + let center = px(&buf, 400, 200, 200); + assert_eq!( + center, + (255, 255, 255, 255), + "box centre must not be painted by the gradient border" + ); + + // Top border midpoint is painted (not background). + let top_mid = px(&buf, 400, 200, 106); + assert_ne!( + top_mid, + (255, 255, 255, 255), + "top border edge must be painted" + ); + } + + #[test] + fn gradient_border_respects_border_radius() { + use crate::css::style::BorderRadius; + // Rounded 200x200 box: the square corner pixel must stay background, + // while edge midpoints are painted. + let node = abs_box( + 100.0, + 100.0, + 200.0, + 200.0, + CssStyle { + border_radius: Some(BorderRadius::Uniform(CLP::Px(60.0))), + gradient_border: Some(GradientBorder { + colors: vec!["#ff0000".into(), "#0000ff".into()], + width: 10.0, + angle: 90.0, + }), + ..Default::default() + }, + ); + let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]); + let buf = render_pixels(&mut root, 400, 400); + + // Square corner (inside the box bounds but outside the rounded path). + let corner = px(&buf, 400, 104, 104); + assert_eq!( + corner, + (255, 255, 255, 255), + "square corner must stay background with border-radius" + ); + // Edge midpoints painted. + let left_mid = px(&buf, 400, 104, 200); + let top_mid = px(&buf, 400, 200, 104); + assert_ne!(left_mid, (255, 255, 255, 255), "left edge must be painted"); + assert_ne!(top_mid, (255, 255, 255, 255), "top edge must be painted"); + } + + #[test] + fn gradient_border_replaces_standard_border() { + use crate::css::style::{BorderEdges, BorderStyle, Edges}; + // A node with BOTH border (solid green) and gradient-border (red/blue): + // the gradient border wins; no green pixels appear. + let node = abs_box( + 100.0, + 100.0, + 200.0, + 200.0, + CssStyle { + border: Some(BorderEdges { + width: Some(Edges::Uniform(CLP::Px(10.0))), + style: Some(BorderStyle::Solid), + color: Some(CssColor::String("#00ff00".into())), + ..Default::default() + }), + gradient_border: Some(GradientBorder { + colors: vec!["#ff0000".into(), "#0000ff".into()], + width: 10.0, + angle: 90.0, + }), + ..Default::default() + }, + ); + let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]); + let buf = render_pixels(&mut root, 400, 400); + + let green_pixels = buf + .chunks(4) + .filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60) + .count(); + assert_eq!( + green_pixels, 0, + "standard border must not be painted when gradient-border is set" + ); + } + + // ---- noise filter ---- + + fn gray_box_with_filter(filter: Option>) -> BoxNode { + abs_box( + 50.0, + 50.0, + 200.0, + 200.0, + CssStyle { + background: Some(Background::Color(CssColor::String("#808080".into()))), + filter, + ..Default::default() + }, + ) + } + + #[test] + fn noise_filter_explodes_unique_color_count() { + let mut root_plain = root_node( + 300.0, + 300.0, + Some("#ffffff"), + vec![gray_box_with_filter(None)], + ); + let buf_plain = render_pixels(&mut root_plain, 300, 300); + + let mut root_noise = root_node( + 300.0, + 300.0, + Some("#ffffff"), + vec![gray_box_with_filter(Some(vec![FilterFn::Noise { + intensity: 0.5, + seed: 42, + }]))], + ); + let buf_noise = render_pixels(&mut root_noise, 300, 300); + + // Sample well inside the box to avoid AA edges. + let plain = unique_colors_in(&buf_plain, 300, 70, 70, 230, 230); + let noisy = unique_colors_in(&buf_noise, 300, 70, 70, 230, 230); + assert!( + plain.len() <= 4, + "plain box interior should be near-uniform, got {} colors", + plain.len() + ); + assert!( + noisy.len() > 50, + "noise filter should explode the unique color count, got {}", + noisy.len() + ); + } + + #[test] + fn noise_same_seed_is_byte_identical_across_renders() { + let render = || { + let mut root = root_node( + 300.0, + 300.0, + Some("#ffffff"), + vec![gray_box_with_filter(Some(vec![FilterFn::Noise { + intensity: 0.4, + seed: 7, + }]))], + ); + render_pixels(&mut root, 300, 300) + }; + let a = render(); + let b = render(); + assert_eq!(a, b, "same seed must produce byte-identical grain"); + } + + #[test] + fn noise_different_seeds_differ() { + let render = |seed: u64| { + let mut root = root_node( + 300.0, + 300.0, + Some("#ffffff"), + vec![gray_box_with_filter(Some(vec![FilterFn::Noise { + intensity: 0.4, + seed, + }]))], + ); + render_pixels(&mut root, 300, 300) + }; + let a = render(1); + let b = render(2); + assert_ne!(a, b, "different seeds must produce different grain"); + } + + #[test] + fn backdrop_noise_grains_only_the_panel_region() { + // Uniform gray root; a panel at (50, 50, 100, 100) with + // backdrop-filter noise. Inside the panel: grain (many colors). + // Outside: untouched uniform gray. + let panel = abs_box( + 50.0, + 50.0, + 100.0, + 100.0, + CssStyle { + backdrop_filter: Some(vec![FilterFn::Noise { + intensity: 0.5, + seed: 42, + }]), + ..Default::default() + }, + ); + let mut root = root_node(300.0, 300.0, Some("#808080"), vec![panel]); + let buf = render_pixels(&mut root, 300, 300); + + let inside = unique_colors_in(&buf, 300, 60, 60, 140, 140); + let outside = unique_colors_in(&buf, 300, 180, 180, 280, 280); + assert!( + inside.len() > 30, + "panel interior should be grained, got {} colors", + inside.len() + ); + assert_eq!( + outside.len(), + 1, + "outside the panel must stay uniform, got {} colors", + outside.len() + ); + } + + // ---- serde ---- + + #[test] + fn css_gradient_border_and_noise_deserialize() { + let json = r##"{ + "gradient-border": { "colors": ["#ff0000", "#0000ff"], "width": 3, "angle": 45 }, + "filter": [{ "fn": "noise", "intensity": 0.3, "seed": 9 }], + "backdrop-filter": [{ "fn": "noise" }] + }"##; + let s: CssStyle = serde_json::from_str(json).unwrap(); + let gb = s.gradient_border.expect("gradient-border parsed"); + assert_eq!(gb.colors.len(), 2); + assert_eq!(gb.width, 3.0); + assert_eq!(gb.angle, 45.0); + assert!(matches!( + s.filter.as_deref(), + Some([FilterFn::Noise { + intensity, + seed: 9 + }]) if (intensity - 0.3).abs() < 1e-6 + )); + // Defaults: intensity 0.15, seed 42. + assert!(matches!( + s.backdrop_filter.as_deref(), + Some([FilterFn::Noise { + intensity, + seed: 42 + }]) if (intensity - 0.15).abs() < 1e-6 + )); + } + + #[test] + fn css_legacy_zombies_accepted() { + // backdrop-blur / inner-shadow parse into CssStyle (accepted for + // compat) — rendering is intentionally not wired; validate warns. + let json = r##"{ + "backdrop-blur": 20, + "inner-shadow": { "color": "#000000", "offset_x": 0, "offset_y": 2, "blur": 8 } + }"##; + let s: CssStyle = serde_json::from_str(json).unwrap(); + assert_eq!(s.backdrop_blur, Some(20.0)); + assert!(s.inner_shadow.is_some()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rustmotion-core/src/schema/style.rs b/crates/rustmotion-core/src/schema/style.rs index c854265..582544f 100644 --- a/crates/rustmotion-core/src/schema/style.rs +++ b/crates/rustmotion-core/src/schema/style.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use super::video::{ AnimationEffect, BlendMode, CardBorder, CardShadow, DropShadow, Fill, FilterConfig, - GradientBorder, InnerShadow, Stroke, TextBackground, TextGradient, TextShadow, + GradientBorder, Stroke, TextBackground, TextGradient, TextShadow, }; // --- Card types --- @@ -389,9 +389,11 @@ pub struct LayerStyle { // Text highlight background #[serde(default, rename = "text-background")] pub text_background: Option, - // Glassmorphism / advanced effects - #[serde(default, rename = "backdrop-blur")] - pub backdrop_blur: Option, + // Glassmorphism / advanced effects. + // `backdrop-blur` / `inner-shadow` were removed (issue #87): they were + // never consumed — the working equivalents are the CSS `backdrop-filter` + // and `box-shadow` with `inset: true`. `CssStyle` still accepts them for + // compat and the validator warns. #[serde(default, rename = "gradient-border")] pub gradient_border: Option, // Visual effects @@ -407,9 +409,6 @@ pub struct LayerStyle { pub aspect_ratio: Option, #[serde(default, rename = "text-gradient")] pub text_gradient: Option, - // Inner shadow (inset shadow) - #[serde(default, rename = "inner-shadow")] - pub inner_shadow: Option, // Motion path: SVG path string that elements follow #[serde(default, rename = "motion-path")] pub motion_path: Option, @@ -473,7 +472,6 @@ impl Default for LayerStyle { grid_column: None, grid_row: None, text_background: None, - backdrop_blur: None, gradient_border: None, filter: None, drop_shadow: None, @@ -481,7 +479,6 @@ impl Default for LayerStyle { clip_path: None, aspect_ratio: None, text_gradient: None, - inner_shadow: None, motion_path: None, stagger: None, animation: Vec::new(), diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index b4743b0..54c68c0 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -600,7 +600,7 @@ pub struct CardBorder { pub width: f32, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct GradientBorder { pub colors: Vec, #[serde(default = "default_gradient_border_width")] @@ -625,7 +625,7 @@ pub struct CardShadow { } /// Inner shadow configuration (inset shadow). -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct InnerShadow { pub color: String, #[serde(default)] From 4c9adb9e48b6ced8fcb3dd29565caba92d647888 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 20 Jul 2026 00:09:37 +0200 Subject: [PATCH 3/3] feat(engine): spring easing on any animation preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnimationTiming gains spring: Option (serde-additive). A single generic post-pass (apply_spring_to_motion) remaps a preset's motion keyframes to the spring solver — builders stay oblivious: - motion allowlist only (translate/scale/rotation axes); opacity keeps its ease (alpha overshoot flashes), blur/draw_progress excluded (out-of-range overshoot) - 2-keyframe motions get spring easing directly; manual-overshoot entrances (scale_in 0->1.08->1) collapse to endpoints so the spring supplies the overshoot instead of doubling it; same-endpoint oscillators (pulse/shake/float) are untouched (a spring to the same value freezes the effect) - bounce_in/elastic_in hardcoded springs become their defaults, overridden by user config; duration stays the keyframe window (solver already runs in real seconds and clamps at window end) - HTML DSL: spring:true coerces to {} (SpringConfig::default 15/100/1 as the single source of truth — accepted deviation), spring:false absent, anything else a clear error pointing at the JSON form --- .../rustmotion/rules/easing-guidelines.md | 12 + .../skills/rustmotion/rules/prefer-presets.md | 2 + .../src/legacy_dispatch.rs | 4 +- crates/rustmotion-core/src/engine/animator.rs | 247 ++++++++++++++++++ .../rustmotion-core/src/schema/animation.rs | 5 + crates/rustmotion-core/src/schema/video.rs | 10 +- crates/rustmotion-html/src/style.rs | 58 +++- 7 files changed, 333 insertions(+), 5 deletions(-) diff --git a/.claude/skills/rustmotion/rules/easing-guidelines.md b/.claude/skills/rustmotion/rules/easing-guidelines.md index 61c39ab..a5f56d4 100644 --- a/.claude/skills/rustmotion/rules/easing-guidelines.md +++ b/.claude/skills/rustmotion/rules/easing-guidelines.md @@ -25,3 +25,15 @@ When easing is `spring`, configure with: "spring": { "damping": 15, "stiffness": 100, "mass": 1 } } ``` + +**Tout preset accepte `spring`.** Ajouter un objet `spring` à n'importe quel preset applique la physique de ressort à ses keyframes de mouvement (translate/scale/rotate) — l'opacity garde son ease (pas de flash d'alpha en overshoot) : + +```json +{ "name": "fade_in_up", "duration": 0.8, "spring": { "damping": 8, "stiffness": 120 } } +``` + +- `bounce_in` / `elastic_in` : leurs springs intégrés sont les défauts ; un `spring` utilisateur les remplace. +- `scale_in` + spring : l'overshoot manuel est remplacé par celui du ressort. +- Oscillateurs continus (`pulse`, `shake`, `float`) : non affectés (leur forme est leur raison d'être). +- La `duration` reste la fenêtre de l'animation : le ressort est résolu en secondes réelles dans cette fenêtre et la valeur se cale sur la cible à la fin — choisir une duration suffisante (≥ 0.6s avec les défauts) pour laisser le ressort converger. +- Dialecte HTML : la DSL compacte accepte `spring:true` (défauts damping 15 / stiffness 100 / mass 1) ; la config fine passe par la forme JSON de `anim`. diff --git a/.claude/skills/rustmotion/rules/prefer-presets.md b/.claude/skills/rustmotion/rules/prefer-presets.md index 7c4d3ed..838d6b0 100644 --- a/.claude/skills/rustmotion/rules/prefer-presets.md +++ b/.claude/skills/rustmotion/rules/prefer-presets.md @@ -35,3 +35,5 @@ Presets are simpler, less error-prone, and produce consistent motion design. Onl | Char (text only) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` | `scale_in` and `scale_out` support `overshoot` (default 0.08 = 8%). Char presets support `stagger`, `granularity`, `easing`, and `overshoot`. + +**Tout preset standard accepte `spring`** — un objet `{ "damping", "stiffness", "mass" }` qui remplace la courbe de mouvement du preset (translate/scale/rotate) par une physique de ressort ; l'opacity garde son ease. Exemple : `{ "name": "slide_in_left", "spring": { "damping": 10 } }`. Détails dans easing-guidelines.md. (Char presets et `tilt_in` : hors scope v1.) diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index e2ece47..4bf360f 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -375,9 +375,7 @@ mod tests { height: Some(CSize::Length(CLP::Px(100.0))), animation: vec![AnimationEffect::FadeIn(AnimationTiming { duration: 0.5, - delay: 0.0, - repeat: false, - overshoot: None, + ..Default::default() })], ..Default::default() }, diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 189b29d..e79aae2 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -887,11 +887,72 @@ fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { // ─── Preset expansion ─────────────────────────────────────────────────────── +/// Properties eligible for the preset-level `spring` override: motion only. +/// Opacity keeps its ease (an alpha overshoot flashes), blur/draw_progress +/// would go out of range on overshoot. +fn is_motion_property(property: &str) -> bool { + matches!( + property, + "position.x" + | "position.y" + | "translate_x" + | "translate_y" + | "scale" + | "scale.x" + | "scale.y" + | "rotation" + | "rotate_x" + | "rotate_y" + ) +} + +/// Apply a user-provided spring to a preset's motion animations (issue #88). +/// +/// Implementation note: a single generic post-processing pass was chosen over +/// editing each of the ~40 preset builders — the eligibility rules are uniform +/// and the builders stay oblivious to springs. Rules per animation: +/// - non-motion property (opacity, blur, …): untouched; +/// - 2 keyframes: easing → `Spring` with the given config. For `bounce_in` / +/// `elastic_in` this *overrides* their built-in spring, which thereby acts +/// as the default when no user config is provided; +/// - more than 2 keyframes with different endpoints (manual-overshoot +/// entrances like `scale_in`): collapsed to [first, last] + spring — the +/// spring supplies the overshoot itself, keeping the manual peak would +/// double it; +/// - more than 2 keyframes with identical endpoints (continuous oscillators: +/// pulse, shake, float): untouched — a spring toward the same value is a +/// no-op and would freeze the effect. +fn apply_spring_to_motion(animations: &mut [Animation], spring: &SpringConfig) { + for anim in animations.iter_mut() { + if !is_motion_property(&anim.property) || anim.keyframes.len() < 2 { + continue; + } + if anim.keyframes.len() > 2 { + let first = anim.keyframes.first().unwrap().clone(); + let last = anim.keyframes.last().unwrap().clone(); + if (first.value.as_f64() - last.value.as_f64()).abs() < 1e-9 { + continue; // oscillator — leave its shape alone + } + anim.keyframes = vec![first, last]; + } + anim.easing = EasingType::Spring; + anim.spring = Some(spring.clone()); + } +} + fn expand_preset( preset: &AnimationPreset, config: &PresetConfig, _scene_duration: f64, ) -> Vec { + let mut animations = expand_preset_inner(preset, config); + if let Some(spring) = &config.spring { + apply_spring_to_motion(&mut animations, spring); + } + animations +} + +fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec { let delay = config.delay; let dur = config.duration; let end = delay + dur; @@ -1438,3 +1499,189 @@ fn kf_anim_loop(property: &str, min: f64, max: f64) -> Animation { spring: None, } } + +#[cfg(test)] +mod spring_preset_tests { + //! TDD tests for issue #88: spring easing on any preset via + //! `AnimationTiming.spring`. + + use super::*; + use crate::schema::AnimationEffect; + use crate::schema::AnimationTiming; + + fn timing(duration: f64, spring: Option) -> AnimationTiming { + AnimationTiming { + duration, + spring, + ..Default::default() + } + } + + fn underdamped() -> SpringConfig { + SpringConfig { + damping: 8.0, + stiffness: 120.0, + mass: 1.0, + } + } + + /// Sample translate_y and opacity over the animation window. + fn sample(effects: &[AnimationEffect], duration: f64) -> Vec<(f64, f64, f64)> { + let steps = 80; + (0..=steps) + .map(|i| { + let t = duration * i as f64 / steps as f64; + let p = resolve_props_for_effects(effects, t, 5.0); + (t, p.translate_y as f64, p.opacity as f64) + }) + .collect() + } + + #[test] + fn fade_in_up_spring_overshoots_position() { + // Without spring: translate_y eases 60 → 0, never negative. + let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8); + let min_plain = plain.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min); + assert!( + min_plain >= -0.01, + "without spring translate_y must never overshoot below 0, got min {min_plain}" + ); + + // With an underdamped spring: the position overshoots past the final + // value (goes measurably negative) somewhere inside the window. + let sprung = sample( + &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))], + 0.8, + ); + let min_sprung = sprung.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min); + assert!( + min_sprung < -0.5, + "with spring translate_y must overshoot below 0, got min {min_sprung}" + ); + + // At ~70% of the duration the two positions differ measurably. + let y_plain_70 = plain[56].1; + let y_sprung_70 = sprung[56].1; + assert!( + (y_plain_70 - y_sprung_70).abs() > 0.5, + "at 70% duration spring vs plain must differ: {y_plain_70} vs {y_sprung_70}" + ); + } + + #[test] + fn fade_in_up_spring_does_not_touch_opacity() { + let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8); + let sprung = sample( + &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))], + 0.8, + ); + for (i, ((_, _, a_plain), (_, _, a_sprung))) in plain.iter().zip(sprung.iter()).enumerate() + { + assert!( + (a_plain - a_sprung).abs() < 1e-6, + "opacity must be identical with/without spring at sample {i}: {a_plain} vs {a_sprung}" + ); + } + // And alpha stays monotone non-decreasing (no overshoot flashes). + for w in sprung.windows(2) { + assert!( + w[1].2 >= w[0].2 - 1e-6, + "opacity must be monotone, got {} then {}", + w[0].2, + w[1].2 + ); + } + } + + #[test] + fn bounce_in_custom_spring_differs_from_default() { + let scale_at = |spring: Option, t: f64| -> f64 { + let fx = [AnimationEffect::BounceIn(timing(0.8, spring))]; + resolve_props_for_effects(&fx, t, 5.0).scale_x as f64 + }; + // Default (damping 12/stiffness 100) vs a heavily overdamped custom + // spring must produce different scales mid-flight. + let overdamped = SpringConfig { + damping: 40.0, + stiffness: 100.0, + mass: 1.0, + }; + let d = scale_at(None, 0.3); + let c = scale_at(Some(overdamped), 0.3); + assert!( + (d - c).abs() > 0.01, + "custom spring must change bounce_in: default {d} vs custom {c}" + ); + } + + #[test] + fn scale_in_spring_collapses_manual_overshoot() { + // ScaleIn's 3-keyframe manual overshoot (0 → 1.08 → 1) collapses to a + // 2-keyframe spring (0 → 1): the spring provides the overshoot itself, + // so scale must exceed 1.0 somewhere (underdamped) and converge to 1. + let fx = [AnimationEffect::ScaleIn(timing(0.8, Some(underdamped())))]; + let mut max_scale = f64::MIN; + for i in 0..=80 { + let t = 0.8 * i as f64 / 80.0; + let s = resolve_props_for_effects(&fx, t, 5.0).scale_x as f64; + max_scale = max_scale.max(s); + } + // The manual overshoot keyframe peaks at exactly 1.08; the collapsed + // underdamped spring (damping 8 / stiffness 120) peaks well above it — + // this discriminates the spring path from the manual keyframe path. + assert!( + max_scale > 1.12, + "spring scale_in must overshoot past the manual 1.08 peak, got max {max_scale}" + ); + let end = resolve_props_for_effects(&fx, 0.8, 5.0).scale_x as f64; + assert!( + (end - 1.0).abs() < 1e-3, + "scale must converge to 1.0 at window end, got {end}" + ); + } + + #[test] + fn pulse_oscillator_ignores_spring() { + // Pulse's scale loop (1 → 1.05 → 1) has identical endpoints — a + // spring toward the same value would freeze the effect, so the + // oscillator keeps its own shape. + let at = |spring: Option, t: f64| -> f64 { + let fx = [AnimationEffect::Pulse(timing(1.0, spring))]; + resolve_props_for_effects(&fx, t, 5.0).scale_x as f64 + }; + for i in 0..=20 { + let t = i as f64 / 20.0; + let plain = at(None, t); + let sprung = at(Some(underdamped()), t); + assert!( + (plain - sprung).abs() < 1e-9, + "pulse must be unaffected by spring at t={t}: {plain} vs {sprung}" + ); + } + } + + #[test] + fn animation_timing_spring_serde_round_trip() { + let json = r#"{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 8, "stiffness": 120 } }"#; + let fx: AnimationEffect = serde_json::from_str(json).unwrap(); + let AnimationEffect::FadeInUp(t) = &fx else { + panic!("wrong variant"); + }; + let s = t.spring.as_ref().expect("spring parsed"); + assert_eq!(s.damping, 8.0); + assert_eq!(s.stiffness, 120.0); + assert_eq!(s.mass, 1.0, "mass defaults to 1"); + + // Round-trip. + let re = serde_json::to_string(&fx).unwrap(); + let back: AnimationEffect = serde_json::from_str(&re).unwrap(); + assert_eq!(fx, back); + + // Absent spring stays absent. + let plain: AnimationEffect = serde_json::from_str(r#"{ "name": "fade_in_up" }"#).unwrap(); + let AnimationEffect::FadeInUp(t) = &plain else { + panic!("wrong variant"); + }; + assert!(t.spring.is_none()); + } +} diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index e3948c7..da5f661 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -166,6 +166,10 @@ pub struct PresetConfig { /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%). #[serde(default)] pub overshoot: Option, + /// Spring physics applied to the preset's motion keyframes (see + /// `AnimationTiming::spring`). + #[serde(default)] + pub spring: Option, } impl Default for PresetConfig { @@ -175,6 +179,7 @@ impl Default for PresetConfig { duration: 0.8, repeat: false, overshoot: None, + spring: None, } } } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 54c68c0..0687584 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -1,7 +1,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig}; +use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig}; use super::style::{FontWeight, TextAlign, VerticalAlign}; // --- Animation effects (nested inside LayerStyle as typed array) --- @@ -171,6 +171,12 @@ pub struct AnimationTiming { /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%). #[serde(default)] pub overshoot: Option, + /// Spring physics for the preset's motion keyframes (translate/scale/ + /// rotate — opacity keeps its ease to avoid alpha overshoot flashes). + /// `bounce_in` / `elastic_in` use their built-in springs as defaults; + /// this overrides them. + #[serde(default)] + pub spring: Option, } fn default_animation_duration() -> f64 { @@ -207,6 +213,7 @@ impl Default for AnimationTiming { duration: 0.8, repeat: false, overshoot: None, + spring: None, } } } @@ -316,6 +323,7 @@ impl AnimationTiming { duration: self.duration, repeat: self.repeat, overshoot: self.overshoot, + spring: self.spring.clone(), } } } diff --git a/crates/rustmotion-html/src/style.rs b/crates/rustmotion-html/src/style.rs index da5e63d..f47e6d4 100644 --- a/crates/rustmotion-html/src/style.rs +++ b/crates/rustmotion-html/src/style.rs @@ -98,7 +98,28 @@ pub fn parse_anim_attr(raw: &str) -> Result { "'{pair}' has an empty key or value (in effect '{effect}')" ))); } - obj.insert(kebab_to_snake(k), coerce_dsl_value(v)); + let key = kebab_to_snake(k); + // `spring` is an object in the schema; the compact DSL cannot + // express one, so `spring:true` coerces to `{}` (all SpringConfig + // defaults) and `spring:false` is simply absent. Fine-grained + // damping/stiffness/mass requires the JSON `anim` form. + if key == "spring" { + match v { + "true" => { + obj.insert(key, Value::Object(Map::new())); + } + "false" => {} + other => { + return Err(HtmlError::InvalidAnimDsl(format!( + "spring only accepts true/false in the compact DSL \ + (got 'spring:{other}'); use the JSON anim form for \ + a full spring config" + ))); + } + } + continue; + } + obj.insert(key, coerce_dsl_value(v)); } effects.push(Value::Object(obj)); } @@ -159,4 +180,39 @@ mod tests { let m = parse_inline_style("grid-template-columns: 1fr 1fr"); assert_eq!(m.get("grid-template-columns"), Some(&json!(["1fr", "1fr"]))); } + + #[test] + fn anim_dsl_spring_true_coerces_to_default_object() { + let v = parse_anim_attr("bounce-in spring:true duration:0.8").unwrap(); + assert_eq!( + v, + json!([{ "name": "bounce_in", "spring": {}, "duration": 0.8 }]) + ); + } + + #[test] + fn anim_dsl_spring_false_is_absent() { + let v = parse_anim_attr("bounce-in spring:false").unwrap(); + assert_eq!(v, json!([{ "name": "bounce_in" }])); + } + + #[test] + fn anim_dsl_spring_non_bool_is_an_error() { + let err = parse_anim_attr("fade-in-up spring:8").unwrap_err(); + assert!( + err.to_string().contains("spring"), + "error should mention spring: {err}" + ); + } + + #[test] + fn anim_json_form_with_spring_object_passes_intact() { + let v = + parse_anim_attr(r#"[{"name":"fade_in_up","spring":{"damping":8,"stiffness":120}}]"#) + .unwrap(); + assert_eq!( + v, + json!([{ "name": "fade_in_up", "spring": { "damping": 8, "stiffness": 120 } }]) + ); + } }