diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 73c7614..6138595 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -660,11 +660,11 @@ impl Default for AnimatedProperties { scale_x: 1.0, scale_y: 1.0, rotation: 0.0, - blur: 0.0, - blur_x: 0.0, + blur: -1.0, + blur_x: -1.0, letter_spacing: f32::NAN, draw_start: -1.0, - blur_y: 0.0, + blur_y: -1.0, visible_chars: -1, visible_chars_progress: -1.0, color: None, @@ -709,10 +709,10 @@ impl AnimatedProperties { if other.rotation.abs() > 0.01 { self.rotation += other.rotation; } - if other.blur > 0.001 { + if other.blur >= 0.0 { self.blur = other.blur; } - if other.blur_x > 0.001 { + if other.blur_x >= 0.0 { self.blur_x = other.blur_x; } @@ -722,7 +722,7 @@ impl AnimatedProperties { if other.draw_start >= 0.0 { self.draw_start = other.draw_start; } - if other.blur_y > 0.001 { + if other.blur_y >= 0.0 { self.blur_y = other.blur_y; } if other.visible_chars >= 0 { @@ -3355,3 +3355,103 @@ mod burst_progress_tests { assert_eq!(tail, head, "a phased stroke still ends with the window"); } } + +#[cfg(test)] +mod merge_contract_tests { + use super::*; + + fn bucket() -> AnimatedProperties { + AnimatedProperties::default() + } + + #[test] + fn opacity_and_scale_multiply_across_buckets() { + let mut props = bucket(); + props.opacity = 0.5; + props.scale_x = 2.0; + props.scale_y = 2.0; + + let mut other = bucket(); + other.opacity = 0.8; + other.scale_x = 1.5; + other.scale_y = 1.5; + props.merge(&other); + + assert!((props.opacity - 0.4).abs() < 1e-6, "got {}", props.opacity); + assert!((props.scale_x - 3.0).abs() < 1e-6, "got {}", props.scale_x); + assert!((props.scale_y - 3.0).abs() < 1e-6, "got {}", props.scale_y); + } + + #[test] + fn translation_and_rotation_add_across_buckets() { + let mut props = bucket(); + props.translate_x = 10.0; + props.translate_y = -4.0; + props.rotation = 30.0; + + let mut other = bucket(); + other.translate_x = 5.0; + other.translate_y = 4.0; + other.rotation = 15.0; + props.merge(&other); + + assert!((props.translate_x - 15.0).abs() < 1e-6); + assert!((props.translate_y - 0.0).abs() < 1e-6); + assert!((props.rotation - 45.0).abs() < 1e-6); + } + + #[test] + fn a_neutral_value_in_a_composing_property_is_a_no_op_by_arithmetic() { + let mut props = bucket(); + props.opacity = 0.5; + props.scale_x = 2.0; + props.translate_x = 10.0; + + let mut other = bucket(); + other.opacity = 1.0; + other.scale_x = 1.0; + other.translate_x = 0.0; + props.merge(&other); + + assert!( + (props.opacity - 0.5).abs() < 1e-6 + && (props.scale_x - 2.0).abs() < 1e-6 + && (props.translate_x - 10.0).abs() < 1e-6, + "1 is the identity for a product and 0 for a sum, so skipping a neutral value and \ + applying it are the same answer — the guard here is an optimisation, not a rule" + ); + } + + #[test] + fn a_later_bucket_can_take_blur_back_to_zero() { + let mut props = bucket(); + props.blur = 5.0; + + let mut other = bucket(); + other.blur = 0.0; + props.merge(&other); + + assert_eq!( + props.blur, 0.0, + "blur is last-wins, not a product, so zero is a value and not an absence — guarding \ + on `> 0.001` left an element blurred for the rest of the scene" + ); + } + + #[test] + fn a_bucket_that_never_touched_blur_leaves_an_earlier_one_alone() { + let mut props = bucket(); + props.blur = 5.0; + props.blur_x = 3.0; + props.blur_y = 2.0; + + props.merge(&bucket()); + + assert_eq!( + (props.blur, props.blur_x, props.blur_y), + (5.0, 3.0, 2.0), + "the resting value is negative precisely so that `not animated` and `animated to \ + zero` are two different things" + ); + } +} diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 3eb80ee..31e0719 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -294,6 +294,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con ### Design quality (nouvelles règles) - [rules/animation-completion-budget.md](rules/animation-completion-budget.md) - **CRITICAL:** Animation budget formula — every animation must complete within its scene duration +- [rules/animations-compose.md](rules/animations-compose.md) - Two effects on one property combine — a product for `opacity`/`scale`, a sum for `translate`/`rotation`, last-written for the rest — and why a resting value of `-1` is what tells `not animated` apart from `animated to zero` - [rules/typography-readability.md](rules/typography-readability.md) - **CRITICAL:** Minimum font sizes per device/role, line-height rules, contrast hard rules - [rules/scene-pacing.md](rules/scene-pacing.md) - Scene duration formula (reading time + animation budget), density limits, dense/breathing alternation - [rules/color-palettes.md](rules/color-palettes.md) - 4 ready-to-use palettes (Dark Tech / Corporate / Playful / Minimal), consistency rules diff --git a/crates/rustmotion/skills/rules/animations-compose.md b/crates/rustmotion/skills/rules/animations-compose.md new file mode 100644 index 0000000..315dbd3 --- /dev/null +++ b/crates/rustmotion/skills/rules/animations-compose.md @@ -0,0 +1,61 @@ +# Deux animations sur la même propriété : elles se composent + +Empiler deux effets qui touchent la même propriété est légitime et produit un +résultat **combiné**, pas « le dernier gagne ». C'est le contrat du moteur, et il +n'est pas celui des animations CSS — d'où cette règle. + +```json +"animation": [ + { "name": "fade_in", "duration": 0.6 }, + { "name": "pulse", "loop": true } +] +``` + +`fade_in` et `pulse` touchent tous les deux `opacity`. À un instant où `fade_in` +vaut `0.5` et `pulse` vaut `0.8`, l'opacité rendue est **0.40**, leur produit — +pas `0.8`. + +## Comment chaque propriété se combine + +| Comportement | Propriétés | +|---|---| +| **Produit** | `opacity`, `scale_x`, `scale_y` | +| **Somme** | `translate_x`, `translate_y`, `rotation`, `rotate_x`, `rotate_y` | +| **Dernière valeur écrite** | tout le reste : `blur`, `blur_x`, `blur_y`, `color`, `border_radius`, `font_size`, `width`, `height`, `gap`, `padding`, `stroke_width`, `letter_spacing`, `draw_progress`, `draw_start`, … | + +Le regroupement se fait par **famille d'effets**, pas par entrée du tableau : tous +les presets sont résolus ensemble, toutes les `keyframes` ensemble, puis les +résultats sont combinés. Deux presets qui animent `opacity` se multiplient donc +entre eux avant même d'arriver là. + +## La valeur neutre n'est pas « ne rien faire » + +Pour une propriété qui se **compose**, `1` (produit) et `0` (somme) sont les +éléments neutres : les appliquer ou les sauter donne la même réponse. Aucune +subtilité. + +Pour une propriété en **dernière-valeur-écrite**, c'est différent : `blur: 0` est +une valeur, pas une absence. Une deuxième animation qui ramène le flou à zéro doit +effacer le flou posé par la première. + +C'est pour ça que ces propriétés ont une valeur au repos **négative** (`-1`) et pas +`0` : le moteur distingue « cette animation n'a pas touché la propriété » de +« cette animation l'a amenée à zéro ». Un garde du genre `if other.blur > 0.001` +confond les deux et laisse l'élément flou pour le reste de la scène — c'est le bug +que l'issue #322 a relevé. + +## Si on veut vraiment qu'une seule gagne + +Il n'y a pas de mot-clé pour ça. On borne les fenêtres pour qu'elles ne se +chevauchent pas : + +```json +"animation": [ + { "name": "fade_in", "delay": 0.0, "duration": 0.6 }, + { "name": "pulse", "delay": 0.6, "duration": 1.2, "loop": true } +] +``` + +Hors de sa fenêtre, un effet ne contribue rien, donc la question de la composition +ne se pose plus. Voir [animation-completion-budget.md](animation-completion-budget.md) +pour le calcul des fenêtres.