Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 106 additions & 6 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}
1 change: 1 addition & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions crates/rustmotion/skills/rules/animations-compose.md
Original file line number Diff line number Diff line change
@@ -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.
Loading