diff --git a/crates/rustmotion/skills/rules/animations-compose.md b/crates/rustmotion/skills/rules/animations-compose.md index 315dbd3..0094a7c 100644 --- a/crates/rustmotion/skills/rules/animations-compose.md +++ b/crates/rustmotion/skills/rules/animations-compose.md @@ -1,8 +1,6 @@ -# Deux animations sur la même propriété : elles se composent +# Rule: two animations on the same property compose -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. +Stacking two effects that touch the same property is legitimate, and it produces a **combined** result, not "the last one wins". That is the engine's contract, and it is not the one CSS animations have — hence this rule. ```json "animation": [ @@ -11,43 +9,29 @@ n'est pas celui des animations CSS — d'où cette règle. ] ``` -`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`. +`fade_in` and `pulse` both touch `opacity`. At an instant where `fade_in` resolves to `0.5` and `pulse` to `0.8`, the rendered opacity is **0.40**, their product — not `0.8`. -## Comment chaque propriété se combine +## How each property combines -| Comportement | Propriétés | +| Behaviour | Properties | |---|---| -| **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`, … | +| **Product** | `opacity`, `scale_x`, `scale_y` | +| **Sum** | `translate_x`, `translate_y`, `rotation`, `rotate_x`, `rotate_y` | +| **Last value written** | everything else: `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à. +The grouping is by **effect family**, not by array entry: every preset resolves together, every `keyframes` entry resolves together, and the two results are then combined. Two presets animating `opacity` therefore multiply with each other before they ever get there. -## La valeur neutre n'est pas « ne rien faire » +## A neutral value is not "do nothing" -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é. +For a **composing** property, `1` (product) and `0` (sum) are the identity elements: applying them and skipping them give the same answer. Nothing subtle. -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. +For a **last-value-written** property it is different: `blur: 0` is a value, not an absence. A second animation that takes the blur back to zero must clear the blur the first one set. -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é. +That is why those properties have a **negative** resting value (`-1`) rather than `0`: the engine tells "this animation did not touch the property" apart from "this animation took it to zero". A guard of the form `if other.blur > 0.001` conflates the two and leaves the element blurred for the rest of the scene — the bug issue #322 reported. -## Si on veut vraiment qu'une seule gagne +## If you really want only one to win -Il n'y a pas de mot-clé pour ça. On borne les fenêtres pour qu'elles ne se -chevauchent pas : +There is no keyword for it. Bound the windows so they do not overlap: ```json "animation": [ @@ -56,6 +40,4 @@ chevauchent pas : ] ``` -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. +Outside its own window an effect contributes nothing, so the question of composition no longer arises. See [animation-completion-budget.md](animation-completion-budget.md) for the window arithmetic. diff --git a/crates/rustmotion/skills/rules/burst.md b/crates/rustmotion/skills/rules/burst.md index 29f0d75..c23177f 100644 --- a/crates/rustmotion/skills/rules/burst.md +++ b/crates/rustmotion/skills/rules/burst.md @@ -1,11 +1,11 @@ -# Rule: `burst` — l'éclaboussure de traits autour d'un élément qui apparaît +# Rule: `burst` — the ring of strokes an element throws when it pops -`burst` (`style.animation`) peint une couronne de traits courts qui partent vers l'extérieur juste au large de la boîte du nœud, puis se résorbent. C'est l'accent qu'on met sur une pastille, un badge ou une coche au moment où elle *pop* — l'équivalent graphique du petit « tchac ». +`burst` (`style.animation`) paints a ring of short strokes that shoot outward from just off the node's own box edge, then retract. It is the accent on a pill, a badge or a checkmark at the moment it *pops* — the graphic equivalent of the little "tchak". ```json { "type": "badge", - "text": "Livré", + "text": "Shipped", "style": { "animation": [ { "name": "pop_in", "delay": 0.2, "duration": 0.45 }, @@ -17,47 +17,47 @@ } ``` -| Champ | Rôle | Défaut | +| Field | Role | Default | |---|---|---| -| `delay` | Attente avant le départ des traits (s) | `0` | -| `duration` | Durée totale aller-retour (s) ; la tête atteint le bout de sa course à mi-parcours | `0.4` | -| `count` | Nombre de traits dans la couronne (borné en interne à `1..=64`) | `8` | -| `length` | Longueur de la course de chaque trait, en px, mesurée à partir de `gap` | `40` | -| `gap` | Distance en px entre le bord de la boîte et le départ de chaque trait | `12` | -| `width` | Épaisseur du trait en px | `4` | -| `color` | Couleur du trait (chaîne hex) | `"#FFB020"` | -| `seed` | Graine du jitter d'angle, de longueur et de phase | `0` | -| `jitter` | Écart maximal d'un trait par rapport à sa part régulière de la couronne, en fraction de l'espacement entre deux traits ; module aussi la longueur et la phase | `0.2` | +| `delay` | Wait before the strokes shoot out (s) | `0` | +| `duration` | Full out-and-back duration (s); the head reaches the far end of its track at the halfway point | `0.4` | +| `count` | Number of strokes in the ring (clamped internally to `1..=64`) | `8` | +| `length` | Length of each stroke's track, in px, measured outward from `gap` | `40` | +| `gap` | Distance in px between the node's box edge and the near end of every stroke | `12` | +| `width` | Stroke width in px | `4` | +| `color` | Stroke colour (hex string) | `"#FFB020"` | +| `seed` | Seed for the per-stroke angle, length and phase jitter | `0` | +| `jitter` | How far a stroke may stray from its even share of the ring, as a fraction of the spacing between two strokes; it also scales the per-stroke length and phase | `0.2` | -## La tête part, la queue rattrape +## The head leaves, the tail catches up -Chaque trait est défini par deux extrémités qui parcourent la même piste une fois chacune : la **tête** sort sur la première moitié de la fenêtre (`ease_out`), la **queue** la suit sur la seconde (`ease_in`). Le trait s'allonge, atteint sa pleine longueur à mi-parcours, puis se referme **vers l'extérieur** — il s'envole et disparaît, il ne rentre pas dans la boîte. +Each stroke is defined by two ends that cross the same track once each: the **head** runs out over the first half of the window (`ease_out`), the **tail** follows over the second (`ease_in`). The stroke lengthens, reaches full length at the halfway point, then closes **outward** — it flies away and vanishes, it does not retract into the box. -Conséquence directe : aux deux bornes de la fenêtre, tête et queue sont au même endroit, donc le trait a une longueur nulle. **La garantie « zéro aux deux bouts » est géométrique ici, pas seulement temporelle.** C'est une nuance qui compte : `burst_progress` court-circuite bien en dehors de `[delay, delay + duration)`, mais même si une frame tombait *dans* la fenêtre à un ULP près de sa borne (ce qui arrive : `delay + duration - delay != duration` en flottant dès que `duration` n'est pas représentable exactement, `0.4` par exemple), le trait mesuré serait de longueur nulle et rien ne serait peint. Les deux protections existent, et elles ne couvrent pas le même cas. +One consequence matters: at both ends of the window the head and the tail are in the same place, so the stroke has zero length. **The zero-at-both-ends guarantee here is geometric, not only temporal.** `burst_progress` does short-circuit outside `[delay, delay + duration)`, but even if a frame landed *inside* the window within one ULP of its boundary — which happens: `delay + duration - delay != duration` in floating point as soon as `duration` is not exactly representable, `0.4` for instance — the measured stroke would have zero length and nothing would be painted. Both protections exist, and they do not cover the same case. -## Rien de ce qui appartient au nœud n'est touché +## Nothing that belongs to the node is touched -Les traits sont peints **par-dessus** le nœud, **hors de sa boîte**, après son propre rendu, à l'intérieur de sa transformation. Trois conséquences : +The strokes are painted **over** the node, **outside its box**, after its own render and inside its transform. Three consequences: -- Ils ne prennent **aucune place dans le layout** — un `burst` ne pousse jamais un voisin en flex. -- Ils suivent le nœud : si celui-ci tourne ou se déplace (`pop_in`, `transform`), la couronne tourne et se déplace avec lui. -- `gap` garantit que rien ne mord sur la boîte. `gap: 0` colle les traits au bord ; une valeur négative est ramenée à `0`, jamais un chevauchement. +- They take **no layout space** — a `burst` never pushes a flex neighbour. +- They follow the node: if it rotates or moves (`pop_in`, `transform`), the ring rotates and moves with it. +- `gap` guarantees nothing bites into the box. `gap: 0` puts the strokes against the edge; a negative value is clamped to `0`, never an overlap. -La couronne peut en revanche sortir du **viewport** si le nœud est près d'un bord. Le validateur de géométrie ne la voit pas (il inspecte les boîtes de layout, et `burst` n'en a pas) : c'est à la mise en page de laisser `gap + length` de marge autour du nœud. +The ring can still leave the **viewport** if the node sits near an edge. The geometry validator does not see it (it inspects layout boxes, and a `burst` has none): it is up to the layout to leave `gap + length` of margin around the node. -## Le budget d'animation s'applique +## The animation budget applies -Comme [shatter.md](shatter.md), `burst` entre dans le calcul de [animation-completion-budget.md](animation-completion-budget.md) : `start_at + delay + duration ≤ scene_duration`. Ce n'est pas un preset de sortie exempté. +Like [shatter.md](shatter.md), `burst` counts toward [animation-completion-budget.md](animation-completion-budget.md): `start_at + delay + duration ≤ scene_duration`. It is not an exempt exit preset. -En pratique on le déclenche **légèrement après** l'entrée qu'il accentue, pas en même temps : l'éclaboussure doit répondre au *pop*, pas le précéder. Dans l'exemple ci-dessus, `pop_in` part à `0.2` et `burst` à `0.28`. +In practice it fires **slightly after** the entry it accents, not at the same time: the splash answers the pop, it does not precede it. In the example above, `pop_in` starts at `0.2` and `burst` at `0.28`. -## Cas dégénérés +## Degenerate cases -- `count: 0` est traité comme `1` — un unique trait, ce qui ressemble davantage à un accident qu'à un éclat. -- `length: 0` ou `width: 0` n'affiche rien du tout : il n'y a pas d'erreur, l'effet est simplement inerte. -- `jitter: 0` donne une couronne parfaitement régulière, qui lit comme un soleil de schéma technique ; `jitter: 1` autorise un trait à empiéter sur le créneau de son voisin, ce qui lit comme une projection. Entre les deux, `0.2` à `0.35` est la plage qui a l'air « dessinée à la main ». -- `duration` nulle ou négative désactive l'effet à chaque frame, comme `chromatic_aberration` et `shatter`. +- `count: 0` is treated as `1` — a single stroke, which reads more like an accident than a burst. +- `length: 0` or `width: 0` paints nothing at all: no error, the effect is simply inert. +- `jitter: 0` gives a perfectly regular ring, which reads like a technical-diagram sunburst; `jitter: 1` lets a stroke encroach on its neighbour's slot, which reads like a spatter. In between, `0.2` to `0.35` is the range that looks hand-drawn. +- A zero or negative `duration` disables the effect on every frame, like `chromatic_aberration` and `shatter`. -## Ce n'est pas `emitter` +## It is not `emitter` -`burst` est **borné** : un aller-retour, `count` traits, puis plus rien. `emitter` ([emitter-lifecycle.md](emitter-lifecycle.md)) est un **flux continu** : des particules naissent, voyagent, meurent et renaissent tant que la scène dure. Un badge qui apparaît → `burst`. Un tunnel de warp ou un champ d'étoiles → `emitter`. +`burst` is **bounded**: one round trip, `count` strokes, then nothing. `emitter` ([emitter-lifecycle.md](emitter-lifecycle.md)) is a **continuous stream**: particles are born, travel, die and are reborn for as long as the scene lasts. A badge appearing → `burst`. A warp tunnel or a starfield → `emitter`. diff --git a/crates/rustmotion/skills/rules/continuous-presets.md b/crates/rustmotion/skills/rules/continuous-presets.md index 116aa1c..44a1674 100644 --- a/crates/rustmotion/skills/rules/continuous-presets.md +++ b/crates/rustmotion/skills/rules/continuous-presets.md @@ -14,23 +14,17 @@ The presets `pulse`, `float`, `shake`, and `spin` are continuous animations. Wit Continuous presets: `pulse`, `float`, `shake`, `spin`. -## `speed` + `direction` : seuls quatre fonds se laissent faire défiler +## `speed` + `direction`: only four backgrounds accept being scrolled -`direction` translate la texture du fond. Ça n'a de sens que pour un motif -**périodique sous translation**, qui n'a par ailleurs aucun mouvement propre : +`direction` translates the background's texture. That only makes sense for a pattern that is **periodic under translation** and has no motion of its own: -| Preset | `direction` | Pourquoi | +| Preset | `direction` | Why | |---|---|---| -| `grid_dots`, `grid_lines`, `pixel_grid`, `heropattern` | **actif** | Motifs pavés, dessinés avec une période entière de marge de chaque côté. Le défilement extérieur est leur seul mouvement. | -| `gradient_shift` | **inerte** | `speed` pilote déjà le sens de rotation du dégradé (`direction` vaut `cw`/`ccw` ici), et le shader est peint sur le rectangle du cadre **sans marge** : toute translation laissait une bande découverte. | -| `concentric_circles` | **inerte** | Calcule déjà son propre `offset = (time * speed) % spacing`. Translater un motif radial déplace son centre — c'était une double animation. | -| `halo` | **inerte** | Anime ses zones lui-même. | - -Sur les trois derniers, déclarer une `direction` ne produit plus **rien du tout** -(vérifié frame par frame, pixel pour pixel). C'est un changement de rendu visible -pour un scénario existant qui en déclarait une — mais le mouvement supprimé est -celui qui faisait sortir le fond du cadre, pas un effet qu'on perd. - -> `pixel_grid` a son propre champ `motion` (`twinkle`, `sweep`), qui ne translate -> rien : il compose avec le défilement au lieu de le doubler. Son défaut, -> `motion: none`, en fait une texture immobile — exactement le cas de `grid_dots`. +| `grid_dots`, `grid_lines`, `pixel_grid`, `heropattern` | **active** | Tiled patterns, drawn with a whole period of margin on each side. The outer scroll is their only motion. | +| `gradient_shift` | **inert** | `speed` already drives the gradient's rotation sense (`direction` means `cw`/`ccw` here), and the shader is painted over the frame rect with **no margin**: any translation left an uncovered band. | +| `concentric_circles` | **inert** | It already computes its own `offset = (time * speed) % spacing`. Translating a radial pattern moves its centre — that was a double animation. | +| `halo` | **inert** | It animates its zones itself. | + +On those last three, declaring a `direction` now produces **nothing at all** (verified frame by frame, pixel for pixel). This is a visible rendering change for an existing scenario that declares one — but the motion being removed is the motion that dragged the background off the frame, not an effect worth keeping. + +> `pixel_grid` has its own `motion` field (`twinkle`, `sweep`), which translates nothing: it composes with the scroll rather than doubling it. Its default, `motion: none`, makes it a still texture — exactly `grid_dots`'s case. diff --git a/crates/rustmotion/skills/rules/draw-progress-stroke.md b/crates/rustmotion/skills/rules/draw-progress-stroke.md index acaef48..b1cb5c7 100644 --- a/crates/rustmotion/skills/rules/draw-progress-stroke.md +++ b/crates/rustmotion/skills/rules/draw-progress-stroke.md @@ -1,20 +1,12 @@ -# `draw_progress` : rien à 0, et un trait qui ne change pas d'apparence en finissant +# Rule: `draw_progress` — nothing at 0, and a stroke that does not change appearance as it finishes -`draw_progress` révèle un trait progressivement. On le pilote par un preset -`draw_in`/`stroke_reveal`, ou par des `keyframes` sur la propriété du même nom. -Trois pièges à connaître sur `line` et `svg` (`reveal: "stroke"`, celui par -défaut). +`draw_progress` reveals a stroke progressively. It is driven by a `draw_in`/`stroke_reveal` preset, or by `keyframes` on the property of the same name. Three traps to know about on `line` and `svg` (`reveal: "stroke"`, the default). -## `svg` : `draw: true` n'est pas un pilote +## `svg`: `draw: true` is not a driver -`draw: true` force le **chemin de rendu** « draw-on ». Il ne fait pas avancer -`draw_progress`. Sans pilote, la propriété reste à sa valeur au repos, le -peintre prend la branche « fini » (`progress >= 1.0`, qui délègue simplement à -resvg) et la marque se rend **exactement comme avec `draw: false`** — vérifié -octet pour octet sur deux PNG. +`draw: true` forces the draw-on **render path**. It does not advance `draw_progress`. With no driver the property stays at its resting value, the painter takes the "finished" branch (`progress >= 1.0`, which simply delegates to resvg) and the mark renders **exactly as it would with `draw: false`** — verified byte for byte on two PNGs. -Le validateur refuse donc `draw: true` sans pilote, plutôt que de laisser le -drapeau avoir l'air de faire quelque chose : +The validator therefore rejects `draw: true` without a driver, rather than letting the flag look as though it did something: ``` draw: true but nothing animates draw_progress — the mark renders finished, @@ -22,44 +14,19 @@ pixel-identical to draw: false. Add a 'draw_in' or 'stroke_reveal' preset, or keyframes on 'draw_progress'. ``` -En pratique on n'a d'ailleurs pas besoin de `draw: true` : un preset `draw_in` -suffit à lui seul, puisque le peintre bascule dès que `draw_progress` est dans -`[0, 1[`. +In practice `draw: true` is not needed at all: a `draw_in` preset is enough on its own, since the painter switches as soon as `draw_progress` is inside `[0, 1)`. -## `line` : `draw_progress: 0` ne doit rien peindre +## `line`: `draw_progress: 0` must paint nothing -`Line::paint` force un cap arrondi (`PaintCap::Round`) et construit un -pointillé `[longueur_dessinée, reste]` pour révéler le trait. À -`draw_progress: 0`, `longueur_dessinée` vaut `0` — un pointillé de longueur -nulle avec un cap arrondi se peint quand même : Skia dessine un point plein -d'un diamètre égal à `width`, exactement au point de départ. Le composant -retourne maintenant sans rien peindre dès que `draw_progress <= 0` (dans la -fenêtre `[0, 1[` — `draw_progress` absent ou `>= 1` reste le trait complet, -inchangé). +`Line::paint` forces a round cap (`PaintCap::Round`) and builds a dash pattern `[drawn_length, remainder]` to reveal the stroke. At `draw_progress: 0`, `drawn_length` is `0` — and a zero-length dash with a round cap still paints: Skia draws a solid dot of diameter `width`, exactly at the start point. The component now returns without painting anything as soon as `draw_progress <= 0` (within the `[0, 1)` window — an absent `draw_progress`, or one `>= 1`, still means the whole stroke, unchanged). -## `svg` en train de se dessiner doit ressembler au trait fini +## An `svg` in the middle of drawing itself must look like the finished stroke -Pendant le tracé (`paint_draw_on`, `draw_progress` dans `]0, 1[`), le trait -doit avoir la **même** épaisseur, le même `stroke-linecap` et le même -`stroke-linejoin` que le rendu final (`progress >= 1`, peint par `resvg`) — -sinon la dernière frame du tracé et la première frame « finie » ne se -raccordent pas visuellement (saut d'épaisseur, apparition brusque d'un cap). +While tracing (`paint_draw_on`, `draw_progress` in `(0, 1)`), the stroke must have the **same** width, the same `stroke-linecap` and the same `stroke-linejoin` as the final render (`progress >= 1`, painted by resvg) — otherwise the last frame of the trace and the first "finished" frame do not join up visually (a jump in width, a cap appearing out of nowhere). -Concrètement : +Concretely: -- Le canevas est déjà mis à l'échelle du `viewBox` vers la taille du nœud - (`canvas.scale((scale_x, scale_y))`) avant de peindre chaque segment : le - `stroke-width` du SVG source doit être posé tel quel sur le `Paint`, sans - compensation supplémentaire. Diviser par le facteur d'échelle annule cette - mise à l'échelle et fige le trait à sa largeur SVG brute, quelle que soit - la taille du nœud — le bug qu'un remaniement futur ne doit pas - réintroduire. -- `stroke-linecap`/`stroke-linejoin` du `` source (lus sur - `usvg::Stroke`) doivent être posés sur le `Paint` de chaque segment, pas - seulement utilisés pour le rendu final. Un cap `round` sur le trait fini - mais `butt` (le défaut de Skia) pendant le tracé fait apparaître le cap - d'un coup à `draw_progress = 1`, avec une extension visible du trait - (le rayon du cap). +- The canvas is already scaled from the `viewBox` to the node's size (`canvas.scale((scale_x, scale_y))`) before each segment is painted: the source SVG's `stroke-width` must be set on the `Paint` as-is, with no further compensation. Dividing by the scale factor undoes that scaling and pins the stroke to its raw SVG width whatever the node's size — the bug a future rework must not reintroduce. +- The source ``'s `stroke-linecap`/`stroke-linejoin` (read from `usvg::Stroke`) must be set on each segment's `Paint`, not merely used for the final render. A `round` cap on the finished stroke but `butt` (Skia's default) during the trace makes the cap appear all at once at `draw_progress = 1`, with a visible extension of the stroke (the cap's radius). -`marquee` et `cursor` restent hors sujet ici : ce ne sont pas des traits -révélés par `draw_progress`. +`marquee` and `cursor` are out of scope here: they are not strokes revealed by `draw_progress`. diff --git a/crates/rustmotion/skills/rules/emitter-lifecycle.md b/crates/rustmotion/skills/rules/emitter-lifecycle.md index d34c11d..713ba34 100644 --- a/crates/rustmotion/skills/rules/emitter-lifecycle.md +++ b/crates/rustmotion/skills/rules/emitter-lifecycle.md @@ -1,14 +1,13 @@ -# `emitter` : un champ de particules avec un cycle de vie +# Rule: Radial Particle Emitter (`emitter`) -Pour un tunnel de warp, un champ d'étoiles, un flux de lumière ou de braises — -n'importe quel champ de petites marques en mouvement qui doit paraître **vivant -et continu**, pas une cohorte figée qui apparaît d'un bloc. - -`emitter` remplace le `particle` déprécié et la recette artisanale -`for-each` + `rand($seed, $i)` + `sin($t)` : aucune des deux n'a de cycle de vie, -donc elles gèlent à un seul âge pour tout le plan, ou demandent des centaines de -nœuds keyframés à la main. Une étude réelle a généré **460 nœuds** et des -centaines de kilo-octets de JSON pour trois secondes de tunnel. +For a warp-tunnel, a starfield, an ambient stream of light or embers — any +field of many small moving marks that should look alive and continuous, not +a fixed cohort that pops in together — use `emitter`. It supersedes the +deprecated `particle` and the hand-rolled `for-each` + `rand($seed, $i)` + +`sin($t)` recipe: neither has a real lifecycle, so both either freeze at one +age for the whole clip or require hundreds of individually keyframed nodes +to fake motion (a real study needed 460 nodes and hundreds of kilobytes of +JSON for three seconds of tunnel). ```json { @@ -27,75 +26,66 @@ centaines de kilo-octets de JSON pour trois secondes de tunnel. } ``` -| Champ | Rôle | +| Field | Role | |---|---| -| `origin` | `{x, y}` dans la boîte de l'émetteur, en pixels. Défaut : son centre. | -| `rate` | Particules nées par seconde, en moyenne. | -| `life` | `[min, max]` en secondes. Chaque particule tire la sienne une fois, depuis `seed` et son index. | -| `direction` | `radial` uniquement pour l'instant : naissance sur un anneau, trajet droit vers l'extérieur. | -| `speed` | `{from, to, easing}` — pixels/seconde à la naissance et à la mort, et comment le trajet se répartit sur la vie. | -| `spawn_radius` | `[min, max]` de l'anneau de naissance, en pixels depuis `origin`. **Un minimum non nul est ce qui creuse l'œil sombre** au centre d'un tunnel. | -| `shape` | `streak` (un trait aligné sur la direction) ou `dot`. | -| `length` | `[min, max]` de la longueur du trait. Ignoré pour `dot`. | -| `color` | Chaîne hexadécimale. | -| `width` | Épaisseur du trait, ou diamètre du point. | -| `seed` | Graine. Même graine, même instant, mêmes pixels — toujours. | - -## Il n'y a pas de nombre de particules à régler - -`rate` et `life` suffisent. Combien de particules vivent à un instant donné en -découle : `concurrence = rate × moyenne(life)`. Un `rate: 180` avec -`life: [0.7, 1.2]` (moyenne 0,95 s) garde environ **171** particules vivantes en -continu. - -C'est délibéré : un champ `count` séparé serait une troisième valeur à tenir -d'accord avec les deux autres, et la première chose à désynchroniser en réglant -l'une sans l'autre. - -## Le cycle de vie est en forme close, pas simulé - -L'âge de chaque particule se déduit directement de `(seed, index, time)` : +| `origin` | `{x, y}` in the emitter's own box, in pixels. Defaults to the box centre. | +| `rate` | Average particles born per second. | +| `life` | `[min, max]` lifetime in seconds. Each particle draws its own value once, from `seed` and its index. | +| `direction` | Only `radial` today: born on a ring, travel straight outward. | +| `speed` | `{from, to, easing}` — pixels/second at birth and at death, and how the travel between them distributes over the particle's life. | +| `spawn_radius` | `[min, max]` ring, in pixels from `origin`, particles are born on. A non-zero minimum is what carves the dark "eye" out of the middle of a tunnel. | +| `shape` | `streak` (a short line aligned with the travel direction) or `dot`. | +| `length` | `[min, max]` streak length in pixels. Unused for `dot`. | +| `color` | Hex string. | +| `width` | Stroke width (`streak`) or diameter (`dot`) in pixels. | +| `seed` | Deterministic seed. Same seed, same instant, same pixels — always. | + +## There is no particle count to set + +`rate` and `life` are enough. How many particles are alive at any instant +follows from them: concurrency = `rate * average(life)`. A `rate: 180` with +`life: [0.7, 1.2]` (average 0.95 s) keeps roughly 171 particles alive at +once, continuously — there is no separate count field to keep in sync by +hand, and nothing to desync if you tune one without the other. + +## The lifecycle is closed-form, not simulated + +Every particle's age is derived directly from `(seed, index, time)`: ``` -phase[i] = décalage aléatoire dans sa propre vie, tiré une fois depuis seed et i -age(t) = (t + phase[i]) mod life[i] -progress = age / life[i] // 0 à la naissance, →1 à la mort +phase[i] = a per-particle random offset into its own life, drawn once from seed and i +age(t) = (t + phase[i]) mod life[i] +progress = age / life[i] // 0 at birth, →1 at death ``` -Rien n'avance de frame en frame, aucun historique n'est gardé. **C'est ce qui rend -l'émetteur cherchable** : `still --time 1.7` produit exactement les pixels de la -frame 51 d'un `render` complet, parce que les deux appellent la même fonction pure -du temps. Une simulation, elle, dépendrait de tout ce qui précède — et `still` n'a -rien qui précède. - -C'est aussi ce qui empêche le champ de paraître synchronisé : deux particules ne -partagent une phase que si leurs tirages se rencontrent, donc le tunnel lit comme -un flux d'âges mélangés **dès la première frame**, pas comme une cohorte née à -`t=0`. - -`speed.from`/`speed.to` décrivent la vitesse moyenne sur toute la vie ; -`speed.easing` décide ensuite comment cette distance totale se répartit sur -`progress` — `ease_in` en dépense l'essentiel vers la fin, ce qui lit comme une -accélération vers l'extérieur. Chaque particule s'estompe brièvement à la -naissance et avant la mort, pour qu'aucune apparition ne claque. - -## C'est un composant décoratif, plein cadre - -Comme `particle`, il vaut `100%` en largeur et hauteur par défaut et il est -**décoratif** : il peint en couche plein écran derrière le flux flex de la scène, -et il est exempté du contrôle de débordement viewport — un tunnel est censé -déborder. Donne-lui un `style.width`/`style.height` explicite pour le contenir -dans une carte. - -## Un plafond, et pourquoi - -Le nombre de particules est plafonné à 6000. `rate` est piloté par l'auteur et -multiplie directement les appels de dessin par frame ; sans plafond, une valeur -aberrante n'échoue pas, elle fait ramer le rendu sans rien dire. - -## `particle` n'est pas étendu pour ça - -Ses cinq presets (`confetti`, `snow`, `stars`, `bubbles`, `halo`) restent là, -dépréciés, pour la compatibilité. Les compositions figées qu'ils dessinent n'ont -aucune sémantique de naissance, mort et renaissance ; en ajouter une reviendrait -à réécrire `emitter` avec une indirection de plus. +Nothing is stepped frame-to-frame and no history is kept. That's what makes +`still --time 1.7` on frame 51 of a 30fps render produce the exact same +pixels as decoding frame 51 out of a full `render` — both call the same pure +function of `time`. It also means the field never looks synchronized: two +particles never share a phase unless their random draws collide, so the +tunnel reads as a continuous flow of mixed ages from the very first frame, +not a cohort that was all born at `t=0`. + +`speed.from`/`speed.to` describe the average velocity across a particle's +whole life; `speed.easing` then decides how that total travel distributes +across `progress` — `ease_in` spends most of the distance near the end, +which reads as acceleration outward. Each particle briefly fades in at +birth and fades out just before death, so appearance/disappearance is never +a hard pop. + +## It's a decorative, full-bleed component + +Like `particle`, `emitter` defaults to `100%` width/height and is treated as +**decorative**: it paints as a fullscreen layer behind the rest of the +scene's flex flow, and it is exempt from the viewport-overflow check (a +tunnel is expected to bleed past the frame edge by design). Give it an +explicit `style.width`/`style.height` if you want a contained effect inside +a card instead of the whole frame. + +## `particle` is not extended for this + +`particle`'s five presets (`confetti`, `snow`, `stars`, `bubbles`, `halo`) +are still there, deprecated, for compatibility — the fixed compositions +each preset draws have no life/death/respawn semantics and adding one to +that struct would just be `emitter` again with extra indirection. New +full-bleed particle work should reach for `emitter` directly. diff --git a/crates/rustmotion/skills/rules/shatter.md b/crates/rustmotion/skills/rules/shatter.md index 9ceefa8..bf27764 100644 --- a/crates/rustmotion/skills/rules/shatter.md +++ b/crates/rustmotion/skills/rules/shatter.md @@ -1,8 +1,8 @@ -# Rule: `shatter` — fragments de Voronoi qui s'envolent (ou s'assemblent) +# Rule: `shatter` — Voronoi fragments that fly apart (or assemble) -`shatter` (`style.animation`) découpe le rendu déjà peint d'un nœud — fond, bordure, enfants, tout son sous-arbre — en cellules polygonales déterministes (partition de Voronoi) et envoie chaque morceau voler loin d'un point d'origine, avec sa propre rotation et son propre fondu. C'est la brique pour une carte, une miniature ou une vitre qui explose en éclats et révèle ce qu'il y a derrière — voir issue #378. +`shatter` (`style.animation`) cuts a node's already-painted render — background, border, children, its whole subtree — into deterministic polygonal cells (a Voronoi partition) and sends each piece flying away from an origin point, with its own rotation and its own fade. It is the building block for a card, a thumbnail or a pane of glass that shatters and reveals what is behind it — see issue #378. -## La forme +## The shape ```json { @@ -27,46 +27,46 @@ } ``` -| Champ | Rôle | Défaut | +| Field | Role | Default | |---|---|---| -| `delay` | Attente avant que les éclats commencent à bouger (s) | `0` | -| `duration` | Durée de la dispersion (ou de l'assemblage en `mode: "in"`) (s) | `0.6` | -| `mode` | `"out"` / `"in"` / `"hold"` — voir plus bas | `"out"` | -| `pieces` | Nombre de cellules de Voronoi (borné en interne à `1..=64`) | `24` | -| `seed` | Graine de la partition et du jitter par éclat (direction, spin, depth) | `0` | -| `origin` | Point dont les éclats s'éloignent (ou vers lequel ils convergent en `"in"`), fraction `0..1` de la boîte du nœud — pas des px | `{ "x": 0.5, "y": 0.5 }` | -| `spread` | Multiplicateur du trajet radial à dispersion complète, relatif à la diagonale du nœud | `1.0` | -| `spin` | Rotation max en degrés à dispersion complète ; signe et amplitude tirés par éclat depuis `seed` | `90` | -| `depth` | Modulation d'échelle par éclat à dispersion complète — même sémantique « 0 = aucune » que `OrbitConfig.depth` (certains éclats grossissent, d'autres rétrécissent) | `0.4` | -| `fade` | Fait tomber l'opacité de chaque éclat à zéro à pleine dispersion (et l'inverse en `mode: "in"`) | `true` | +| `delay` | Wait before the shards start moving (s) | `0` | +| `duration` | How long the dispersal (or the assembly, in `mode: "in"`) takes (s) | `0.6` | +| `mode` | `"out"` / `"in"` / `"hold"` — see below | `"out"` | +| `pieces` | Number of Voronoi cells (clamped internally to `1..=64`) | `24` | +| `seed` | Seed for the partition and for every shard's jitter (direction, spin, depth) | `0` | +| `origin` | The point shards fly away from (or converge toward in `"in"`), as a fraction `0..1` of the node's own box — not pixels | `{ "x": 0.5, "y": 0.5 }` | +| `spread` | Radial travel multiplier at full dispersal, relative to the node's own diagonal | `1.0` | +| `spin` | Maximum rotation in degrees at full dispersal; each shard's sign and magnitude are drawn from `seed` | `90` | +| `depth` | Per-shard scale modulation at full dispersal — the same "0 = none" semantics as `OrbitConfig.depth` (some shards grow, others shrink) | `0.4` | +| `fade` | Drop each shard's opacity to zero at full dispersal (and the reverse in `mode: "in"`) | `true` | -## `mode` décide quelle extrémité est le nœud intact +## `mode` decides which end is the intact node -- **`"out"`** (défaut) : assemblé à `delay`, dispersé à `delay + duration`. **En dehors de cette fenêtre, l'effet ne contribue rigoureusement rien** — le nœud est pixel pour pixel identique à un nœud sans `shatter` dans sa liste `animation`. C'est le même court-circuit dur que `chromatic_aberration` et `zoom_blur` (voir [chromatic-aberration.md](chromatic-aberration.md)) plutôt qu'un fondu qui ne fait que s'approcher de zéro. -- **`"in"`** est le miroir : dispersé à `delay`, assemblé à `delay + duration` — et, comme `"out"`, en dehors de sa fenêtre le nœud se rend comme si l'effet était absent. La différence entre les deux modes n'est donc **pas** l'état aux bornes (les deux sont « pas d'effet » avant `delay` et après `delay + duration`) mais le sens dans lequel `progress` parcourt la fenêtre : `0` (assemblé) → `1` (dispersé) en `"out"`, l'inverse en `"in"`. -- **`"hold"`** joue la même dispersion que `"out"` mais se **fige** à pleine dispersion une fois `delay + duration` atteint, au lieu de revenir au nœud plein — il ne reconverge jamais. C'est le seul des trois modes dont l'état final diffère d'un nœud sans l'effet. +- **`"out"`** (default): assembled at `delay`, dispersed at `delay + duration`. **Outside that window the effect contributes strictly nothing** — the node is pixel for pixel identical to one with no `shatter` in its `animation` list. That is the same hard short-circuit `chromatic_aberration` and `zoom_blur` use (see [chromatic-aberration.md](chromatic-aberration.md)) rather than a fade that merely approaches zero. +- **`"in"`** is the mirror: dispersed at `delay`, assembled at `delay + duration` — and, like `"out"`, outside its window the node renders as if the effect were absent. The difference between the two modes is therefore **not** the state at the boundaries (both are "no effect" before `delay` and after `delay + duration`) but the direction `progress` runs through the window: `0` (assembled) → `1` (dispersed) in `"out"`, the reverse in `"in"`. +- **`"hold"`** plays the same dispersal as `"out"` but **freezes** at full dispersal once `delay + duration` is reached, instead of returning to the whole node — it never reconverges. It is the only one of the three whose final state differs from a node without the effect. -Piège à ne pas reproduire ailleurs : ne pas confondre « en dehors de la fenêtre » avec `progress` proche de 0 ou 1. `active_shatter` (`paint_pass.rs`) renvoie `None` — pas `Some(0.0)` ou `Some(1.0)` — hors fenêtre ; c'est un branchement de code différent (`paint_node_visual` direct, sans aucune rasterisation ni découpe), pas la même fonction évaluée à une borne. +A trap not to reproduce elsewhere: do not confuse "outside the window" with `progress` near 0 or 1. `active_shatter` (`paint_pass.rs`) returns `None` — not `Some(0.0)` or `Some(1.0)` — outside the window; that is a different code branch (`paint_node_visual` directly, with no rasterisation and no clipping), not the same function evaluated at a boundary. -## Comment c'est peint +## The animation budget applies -Le sous-arbre du nœud est peint une seule fois dans une surface raster **dédiée**, à la taille de sa propre boîte (`box_layout.width × height`, coordonnées locales — même geste que `paint_inflated_material`/`silhouette_alpha_field` pour rasteriser puis relire des pixels). Cette capture désactive la hit-map (`PaintContext.hits: None`) : pendant que le nœud est fragmenté, ses enfants ne sont pas des cibles de clic cohérentes — seul le nœud lui-même reste cliquable, à son rectangle d'origine, exactement comme s'il n'était pas en train de se briser. +`shatter` counts toward [animation-completion-budget.md](animation-completion-budget.md) like any entrance: `start_at + delay + duration ≤ scene_duration`. It is **not** an exempt exit preset — even in `mode: "out"` or `"hold"`, where the effect reads like an exit. A shatter meant to land on the cut must therefore fall exactly on the end of the scene rather than overrun it: `delay + duration == scene_duration`. Otherwise the validator raises the usual budget error. -La partition de Voronoi vient d'un semis de points sur une grille approximative (`√pieces` colonnes), chacun perturbé par un hash déterministe de `(seed, index)` — pas un point uniformément aléatoire, pour éviter les esquilles dégénérées d'un Poisson pur. Chaque cellule est calculée par découpe successive du rectangle englobant contre le plan médiateur de chaque autre point (Sutherland-Hodgman, `O(pieces²)` — négligeable jusqu'à 64 pièces). Direction, magnitude du trajet, signe/magnitude du spin et valeur de profondeur par éclat sont tous des hashs de `(seed, index, salt)` distincts — deux rendus du même fichier au même instant sont donc octet pour octet identiques. +## Trap: `origin` is a fraction, not pixels -Pour chaque éclat, l'ordre des opérations canvas compte : **translation/rotation/échelle d'abord, découpe (`clip_path`) ensuite**, dans ce sens précis. Si la découpe est posée avant la transformation, le masque reste à sa position d'origine pendant que l'image sous-jacente se déplace dessous — l'éclat ne bouge jamais visuellement, seul son contenu glisse sous un trou fixe. C'est un bug qui a été observé et corrigé pendant l'implémentation ; un test dédié (`shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not`) l'aurait détecté en le repassant au rouge. +Unlike `TransformOrigin` (CSS, `LengthPercentage`), `shatter.origin` is a pair of floats `0..1` relative to the node's box — `{ "x": 0.5, "y": 0.5 }` is the centre, `{ "x": 0.0, "y": 0.0 }` the top-left corner. Passing pixels is not a schema error (the field accepts any float) but an origin point outside the box, so every shard leaves in nearly the same direction instead of radiating. -## Le budget d'animation s'applique +## How it is painted -`shatter` entre dans le calcul de [animation-completion-budget.md](animation-completion-budget.md) comme n'importe quelle entrée : `start_at + delay + duration ≤ scene_duration`. Ce n'est **pas** un preset de sortie exempté — même en `mode: "out"` ou `"hold"`, où l'effet se lit pourtant comme une sortie. Un éclatement destiné à finir sur la coupe doit donc tomber pile à la fin de la scène, pas la déborder : `delay + duration == scene_duration`. Sinon le validateur émet l'erreur de budget habituelle. +The node's subtree is painted once into a **dedicated** raster surface the size of its own box (`box_layout.width × height`, local coordinates — the same move `paint_inflated_material`/`silhouette_alpha_field` make to rasterise and read pixels back). That capture disables the hit map (`PaintContext.hits: None`): while the node is fragmented, its children are not coherent click targets — only the node itself stays clickable, at its original rectangle, exactly as if it were not breaking. -## Piège : `origin` est une fraction, pas des px +The Voronoi partition comes from seed points on an approximate grid (`√pieces` columns), each perturbed by a deterministic hash of `(seed, index)` — not a uniformly random point, which would produce degenerate slivers. Each cell is computed by successively clipping the bounding rectangle against the perpendicular bisector of every other point (Sutherland-Hodgman, `O(pieces²)` — negligible up to 64 pieces). Direction, travel magnitude, spin sign and magnitude, and per-shard depth are all hashes of distinct `(seed, index, salt)` triples — so two renders of the same file at the same instant are byte-identical. -Contrairement à `TransformOrigin` (CSS, `LengthPercentage`), `shatter.origin` est une paire de flottants `0..1` relative à la boîte du nœud — `{ "x": 0.5, "y": 0.5 }` est le centre, `{ "x": 0.0, "y": 0.0 }` le coin haut-gauche. Donner des pixels ici ne produit pas d'erreur de schéma (le champ accepte n'importe quel flottant) mais un point d'origine hors de la boîte, donc une dispersion qui tire tous les éclats dans une direction quasi uniforme au lieu de rayonner. +For each shard the order of canvas operations matters: **translate/rotate/scale first, clip (`clip_path`) second**, in that exact order. Clip before transform and the mask stays at its original position while the image underneath slides: the shard never visually moves, only its content shifts inside a static hole. That bug was observed and fixed during implementation; a dedicated test (`shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not`) turns red on the revert. -## Cas dégénérés +## Degenerate cases -- `pieces: 0` ou `1` est traité comme `1` (borné en interne) : toute la boîte forme un seul éclat, qui se contente de translater/tourner/rétrécir comme un bloc — pas d'erreur, juste un « shatter » dégénéré en simple sortie. -- `spread: 0` immobilise les éclats sur place : seuls le spin, la profondeur et le fondu restent visibles, une variante « dislocation sans envol ». -- `duration: 0` (ou négative) désactive l'effet à chaque frame, comme `chromatic_aberration`. -- `fade: false` laisse les éclats à pleine opacité même totalement dispersés — utile avec `mode: "hold"` pour une composition éclatée qui doit rester lisible. +- `pieces: 0` or `1` is treated as `1` (clamped internally): the whole box is one shard, which simply translates, rotates and scales as a block — no error, just a "shatter" degenerated into a plain exit. +- `spread: 0` pins the shards in place: only spin, depth and fade remain visible, a "dislocation without flight" variant. +- `duration: 0` (or negative) disables the effect on every frame, like `chromatic_aberration`. +- `fade: false` leaves shards at full opacity even when fully dispersed — useful with `mode: "hold"` for a shattered composition that has to stay legible.