From 573fa45a82b4928213f3b21e74e5221998777aab Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 10:27:38 +0200 Subject: [PATCH] feat(animation): burst throws a ring of strokes off a node's box edge Second half of #379. Today a pill that pops needs N hand-placed `line` nodes at computed angles, each with its own draw_in timing, to get the splash of ink that sells the pop. burst is that ring as one effect. Each stroke is a head and a tail that each cross the same track once: the head runs out over the first half of the window (ease_out), the tail follows over the second (ease_in). The stroke therefore has zero length at both ends of the window by construction, not because a fade got small. That geometric zero matters beyond tidiness. burst_progress does short-circuit outside [delay, delay + duration), but that test cannot be exact for every duration: delay + duration - delay != duration in floating point as soon as duration is not representable (0.4, for instance), so a frame landing exactly on the end can still fall one ULP inside the window. It paints nothing anyway, because tail == head there. Both protections exist and they cover different cases -- a_frame_landing_one_ulp_inside_the_window_still_paints_nothing pins it. gap keeps every stroke off the box: the track starts at the box edge along each ray, measured against the node's own half-extents, plus gap. A wide pill therefore pushes its horizontal strokes further out than its vertical ones, which is what makes the ring follow the shape. Angles, per-stroke length and per-stroke phase all come from (seed, index) through the same splitmix64-style hash shatter uses, so two renders of the same instant are byte-identical -- injecting SystemTime into it fails the determinism test immediately. The strokes are painted over the node, after its own render and inside its transform, so they follow a pop_in or a rotation and take no layout space. Budget-checked like shatter and chromatic_aberration. --- crates/rustmotion-core/src/engine/animator.rs | 108 +++++++++ .../rustmotion-core/src/engine/paint_pass.rs | 207 +++++++++++++++++- crates/rustmotion-core/src/schema/video.rs | 77 +++++++ crates/rustmotion/skills/SKILL.md | 1 + crates/rustmotion/skills/rules/burst.md | 63 ++++++ .../skills/rules/hyperframes-mapping.md | 1 + .../src/cli/commands/validate_schema.rs | 2 + 7 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 crates/rustmotion/skills/rules/burst.md diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 51f4f07..73c7614 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -338,6 +338,25 @@ pub fn shatter_progress(cfg: &crate::schema::ShatterConfig, time: f64) -> Option } } +pub fn burst_progress(cfg: &crate::schema::BurstConfig, time: f64) -> Option { + if cfg.duration <= 0.0 { + return None; + } + let elapsed = time - cfg.delay; + if elapsed < 0.0 || elapsed >= cfg.duration { + return None; + } + Some((elapsed / cfg.duration) as f32) +} + +pub fn burst_stroke_span(progress: f32, phase: f32) -> (f32, f32) { + let phase = phase.clamp(0.0, 0.9); + let local = ((progress - phase) / (1.0 - phase)).clamp(0.0, 1.0); + let head = ease_out_cubic((local * 2.0).min(1.0) as f64) as f32; + let tail = ease_in_cubic((local * 2.0 - 1.0).clamp(0.0, 1.0) as f64) as f32; + (tail, head) +} + #[cfg(test)] mod shatter_progress_tests { use super::*; @@ -3247,3 +3266,92 @@ mod repeat_cycle_tests { ); } } + +#[cfg(test)] +mod burst_progress_tests { + use super::*; + use crate::schema::BurstConfig; + + fn cfg() -> BurstConfig { + BurstConfig { + delay: 1.0, + duration: 0.5, + count: 8, + length: 40.0, + gap: 12.0, + width: 4.0, + color: "#FFB020".to_string(), + seed: 3, + jitter: 0.2, + } + } + + #[test] + fn nothing_before_the_delay_and_nothing_at_or_after_the_end() { + let c = cfg(); + assert_eq!(burst_progress(&c, c.delay - 0.01), None); + assert_eq!(burst_progress(&c, c.delay + c.duration), None); + assert_eq!(burst_progress(&c, 9.0), None); + assert!(burst_progress(&c, c.delay).is_some()); + assert!(burst_progress(&c, c.delay + c.duration * 0.99).is_some()); + } + + #[test] + fn a_frame_landing_one_ulp_inside_the_window_still_paints_nothing() { + let mut c = cfg(); + c.duration = 0.4; + let at_end = c.delay + c.duration; + let progress = burst_progress(&c, at_end) + .expect("0.4 is not exactly representable: delay + duration - delay < duration"); + let (tail, head) = burst_stroke_span(progress, 0.0); + assert_eq!( + tail, head, + "the window test cannot be exact for every duration, so the guarantee is \ + geometric: a stroke at progress 1.0 has zero length and paints nothing" + ); + } + + #[test] + fn a_zero_or_negative_duration_disables_the_effect_entirely() { + let mut c = cfg(); + c.duration = 0.0; + assert_eq!(burst_progress(&c, 1.0), None); + c.duration = -1.0; + assert_eq!(burst_progress(&c, 1.0), None); + } + + #[test] + fn the_stroke_has_zero_length_at_both_ends_of_its_own_window() { + let (tail, head) = burst_stroke_span(0.0, 0.0); + assert_eq!(tail, head, "at the start the head has not left the tail"); + let (tail, head) = burst_stroke_span(1.0, 0.0); + assert_eq!(tail, head, "at the end the tail has caught the head"); + } + + #[test] + fn the_head_reaches_the_far_end_halfway_through_while_the_tail_waits() { + let (tail, head) = burst_stroke_span(0.5, 0.0); + assert!((head - 1.0).abs() < 1e-6, "head at the far end, got {head}"); + assert_eq!(tail, 0.0, "tail has not started"); + } + + #[test] + fn the_head_never_overtakes_the_far_end_nor_the_tail_the_head() { + for step in 0..=100 { + let p = step as f32 / 100.0; + for phase in [0.0_f32, 0.15, 0.4] { + let (tail, head) = burst_stroke_span(p, phase); + assert!((0.0..=1.0).contains(&head), "head {head} at p={p}"); + assert!(tail <= head + 1e-6, "tail {tail} past head {head} at p={p}"); + } + } + } + + #[test] + fn a_phase_delays_the_stroke_without_letting_it_outlive_the_window() { + let (tail, head) = burst_stroke_span(0.3, 0.4); + assert_eq!(tail, head, "a phased stroke has not started at p=0.3"); + let (tail, head) = burst_stroke_span(1.0, 0.4); + assert_eq!(tail, head, "a phased stroke still ends with the window"); + } +} diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index f713177..74c4496 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -414,6 +414,10 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } } + if let Some((cfg, progress)) = active_burst(&node.css, ctx.frame.time) { + paint_burst(canvas, box_layout, cfg, progress); + } + canvas.restore(); } @@ -896,6 +900,83 @@ fn paint_shattered_node( } } +const MAX_BURST_COUNT: u32 = 64; + +fn active_burst(css: &CssStyle, time: f64) -> Option<(&crate::schema::BurstConfig, f32)> { + let cfg = css.animation.iter().find_map(|e| match e { + crate::schema::AnimationEffect::Burst(c) => Some(c), + _ => None, + })?; + crate::engine::animator::burst_progress(cfg, time).map(|progress| (cfg, progress)) +} + +fn burst_track_start(half_width: f32, half_height: f32, dir: (f32, f32)) -> f32 { + let to_vertical = if dir.0.abs() < 1e-6 { + f32::INFINITY + } else { + half_width / dir.0.abs() + }; + let to_horizontal = if dir.1.abs() < 1e-6 { + f32::INFINITY + } else { + half_height / dir.1.abs() + }; + let reach = to_vertical.min(to_horizontal); + if reach.is_finite() { + reach + } else { + half_width.max(half_height) + } +} + +fn paint_burst( + canvas: &Canvas, + box_layout: &BoxLayout, + cfg: &crate::schema::BurstConfig, + progress: f32, +) { + let count = cfg.count.clamp(1, MAX_BURST_COUNT); + let length = cfg.length.max(0.0); + let width = cfg.width.max(0.0); + if length <= 0.0 || width <= 0.0 { + return; + } + let jitter = cfg.jitter.clamp(0.0, 1.0); + let centre = ( + box_layout.x + box_layout.width * 0.5, + box_layout.y + box_layout.height * 0.5, + ); + let half_width = box_layout.width * 0.5; + let half_height = box_layout.height * 0.5; + let spacing = std::f32::consts::TAU / count as f32; + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(width); + paint.set_stroke_cap(skia_safe::PaintCap::Round); + paint.set_color(parse_color_string(&cfg.color).unwrap_or_else(|| unresolved_color(&cfg.color))); + + for i in 0..count { + let angle = i as f32 * spacing + (shatter_hash(cfg.seed, i, 11) - 0.5) * jitter * spacing; + let dir = (angle.cos(), angle.sin()); + let stroke_length = length * (1.0 + (shatter_hash(cfg.seed, i, 12) - 0.5) * jitter); + let phase = shatter_hash(cfg.seed, i, 13) * jitter * 0.5; + let (tail, head) = crate::engine::animator::burst_stroke_span(progress, phase); + if (head - tail) * stroke_length < 0.5 { + continue; + } + let base = burst_track_start(half_width, half_height, dir) + cfg.gap.max(0.0); + let near = base + tail * stroke_length; + let far = base + head * stroke_length; + canvas.draw_line( + (centre.0 + dir.0 * near, centre.1 + dir.1 * near), + (centre.0 + dir.0 * far, centre.1 + dir.1 * far), + &paint, + ); + } +} + fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> { let cfg = css.animation.iter().find_map(|e| match e { crate::schema::AnimationEffect::Shimmer(c) => Some(c), @@ -3881,8 +3962,8 @@ mod paint_order_tests { use crate::engine::box_tree::{BoxKind, BoxNode}; use crate::engine::layout_pass::run_layout; use crate::schema::{ - AnimationEffect, ChromaticAberrationConfig, EasingType, ShatterConfig, ShatterMode, - ShatterOrigin, + AnimationEffect, BurstConfig, ChromaticAberrationConfig, EasingType, ShatterConfig, + ShatterMode, ShatterOrigin, }; fn test_frame(w: u32, h: u32) -> PaintFrame { @@ -5171,6 +5252,128 @@ mod paint_order_tests { false } + fn burst_cfg() -> BurstConfig { + BurstConfig { + delay: 0.2, + duration: 0.6, + count: 4, + length: 60.0, + gap: 14.0, + width: 6.0, + color: "#ff0000".to_string(), + seed: 5, + jitter: 0.0, + } + } + + fn burst_node(cfg: BurstConfig) -> BoxNode { + white_square(vec![AnimationEffect::Burst(cfg)]) + } + + #[test] + fn burst_is_pixel_identical_to_no_effect_before_delay_and_at_or_after_the_end() { + let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + + let cfg = burst_cfg(); + let mut before = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]); + assert_eq!( + baseline, + render_pixels_at(&mut before, 400, 400, 0.0), + "before delay a burst must contribute nothing at all — pixel-identical to a node \ + with no burst in its animation list" + ); + + let mut at_end = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]); + assert_eq!( + baseline, + render_pixels_at(&mut at_end, 400, 400, 0.8), + "at delay + duration the window is already closed — no stroke may survive the last \ + frame and bleed into the next scene" + ); + + let mut after = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]); + assert_eq!( + baseline, + render_pixels_at(&mut after, 400, 400, 5.0), + "long after the window the node is plain again" + ); + } + + #[test] + fn burst_paints_ink_outside_the_nodes_own_box_at_full_extension() { + let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + assert!( + !any_ink_in_band(&baseline, 400, 226, 155, 290, 166), + "the band to the right of the square must be empty without a burst" + ); + + let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]); + let mid = render_pixels_at(&mut bursting, 400, 400, 0.5); + assert!( + any_ink_in_band(&mid, 400, 226, 155, 290, 166), + "at the halfway point the head is at the far end of its track — a stroke must be \ + painted well outside the node's own box" + ); + } + + #[test] + fn burst_leaves_the_node_itself_untouched() { + let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]); + let mid = render_pixels_at(&mut bursting, 400, 400, 0.5); + + for y in 100..220 { + for x in 100..220 { + let i = ((y * 400 + x) * 4) as usize; + assert_eq!( + (baseline[i], baseline[i + 1], baseline[i + 2]), + (mid[i], mid[i + 1], mid[i + 2]), + "gap keeps every stroke off the box — pixel ({x}, {y}) inside the node \ + changed" + ); + } + } + } + + #[test] + fn burst_strokes_sit_on_the_rays_count_asks_for_and_nowhere_else() { + let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]); + let mid = render_pixels_at(&mut bursting, 400, 400, 0.5); + + assert!( + any_ink_in_band(&mid, 400, 240, 156, 260, 165), + "count: 4 with no jitter puts a stroke straight to the right of the centre" + ); + assert!( + any_ink_in_band(&mid, 400, 156, 240, 165, 260), + "count: 4 with no jitter puts a stroke straight below the centre" + ); + assert!( + !any_ink_in_band(&mid, 400, 230, 230, 272, 272), + "count: 4 leaves the diagonals empty — the ring is not a halo" + ); + } + + #[test] + fn burst_two_renders_of_the_same_instant_are_byte_identical() { + let cfg = BurstConfig { + jitter: 0.9, + count: 24, + ..burst_cfg() + }; + let mut first = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]); + let mut second = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]); + assert_eq!( + render_pixels_at(&mut first, 400, 400, 0.45), + render_pixels_at(&mut second, 400, 400, 0.45), + "every stroke's angle, length and phase comes from (seed, index) alone — the same \ + instant must render byte-identically for the parallel renderer" + ); + } + #[test] fn shatter_out_is_pixel_identical_to_no_effect_before_delay_and_after_duration() { let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 105c990..19766f8 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -111,6 +111,9 @@ pub enum AnimationEffect { /// partition and flies the pieces apart (or together). See /// [`ShatterConfig`]'s doc comment. Shatter(ShatterConfig), + /// Throws a ring of short strokes outward from just off the node's own + /// box edge, then retracts them. See [`BurstConfig`]'s doc comment. + Burst(BurstConfig), } impl AnimationEffect { @@ -133,6 +136,7 @@ impl AnimationEffect { Shimmer(c) => c.delay += by, ChromaticAberration(c) => c.delay += by, Shatter(c) => c.delay += by, + Burst(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } @@ -1098,6 +1102,79 @@ fn default_shatter_origin_component() -> f32 { 0.5 } +/// Configuration for the `burst` animation effect: a ring of short strokes +/// that shoot outward from just off the node's own box edge and then retract, +/// the splash of ink a pill or a badge throws when it pops in. +/// +/// Nothing about the node itself is touched — the strokes are painted over it, +/// outside its box, and take no layout space. Outside +/// `[delay, delay + duration)` the effect contributes nothing at all. +/// +/// The stroke is drawn by a head and a tail that each cross the stroke's track +/// once: the head runs out over the first half of the window, the tail follows +/// over the second. Both ends of the window therefore have a zero-length +/// stroke — the burst is invisible at `delay` and at `delay + duration` +/// without ever being a fade that merely gets small. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BurstConfig { + /// Delay before the strokes start shooting out (seconds). + #[serde(default)] + pub delay: f64, + /// Full out-and-back duration (seconds). The head reaches the far end of + /// its track at the halfway point. + #[serde(default = "default_burst_duration")] + pub duration: f64, + /// Number of strokes in the ring (default 8, clamped 1..=64). + #[serde(default = "default_burst_count")] + pub count: u32, + /// Length of each stroke's track, in px, measured outward from `gap` + /// (default 40). + #[serde(default = "default_burst_length")] + pub length: f32, + /// Distance in px between the node's box edge and the near end of every + /// stroke's track (default 12). The box is never overdrawn. + #[serde(default = "default_burst_gap")] + pub gap: f32, + /// Stroke width in px (default 4). + #[serde(default = "default_burst_width")] + pub width: f32, + /// Stroke colour (hex string, default "#FFB020"). + #[serde(default = "default_burst_color")] + pub color: String, + /// Seed for the per-stroke angle, length and phase jitter. + #[serde(default)] + pub seed: u32, + /// How far each stroke may stray from its even share of the ring, as a + /// fraction of the spacing between two strokes (default 0.2). `0` gives a + /// perfectly regular ring; `1` lets a stroke reach its neighbour's slot. + /// It also scales the per-stroke length and phase jitter. + #[serde(default = "default_burst_jitter")] + pub jitter: f32, +} + +fn default_burst_duration() -> f64 { + 0.4 +} +fn default_burst_count() -> u32 { + 8 +} +fn default_burst_length() -> f32 { + 40.0 +} +fn default_burst_gap() -> f32 { + 12.0 +} +fn default_burst_width() -> f32 { + 4.0 +} +fn default_burst_color() -> String { + "#FFB020".to_string() +} +fn default_burst_jitter() -> f32 { + 0.2 +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ShapeType { diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 35c415e..3eb80ee 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -237,6 +237,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/zoom-blur-transition.md](rules/zoom-blur-transition.md) - The radial "tunnel" cut: `zoom_blur`'s `strength`/`origin`, why it had to be a transition and not an effect, and the pivot-coincident-edge trap - [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/shatter.md](rules/shatter.md) - `shatter`: the node's own render broken into deterministic Voronoi shards that fly apart — the three modes, the fraction-not-pixels `origin`, and why outside its window it is a different code path, not a progress of zero +- [rules/burst.md](rules/burst.md) - `burst`: the ring of strokes a badge throws when it pops — the head-out/tail-follows stroke, why its zero-at-both-ends is geometric and not only a window test, and why `gap` never lets it touch the box - [rules/inflated-material.md](rules/inflated-material.md) - `material: "inflated"`: shading derived from the clipped silhouette, so each branch of a star gets its own relief — and why `bevel` must stay small relative to the shape - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry - [rules/emitter-lifecycle.md](rules/emitter-lifecycle.md) - `emitter`: a particle field whose lifecycle is closed-form, so `still --time` and a full render agree — and why there is no particle-count field diff --git a/crates/rustmotion/skills/rules/burst.md b/crates/rustmotion/skills/rules/burst.md new file mode 100644 index 0000000..29f0d75 --- /dev/null +++ b/crates/rustmotion/skills/rules/burst.md @@ -0,0 +1,63 @@ +# Rule: `burst` — l'éclaboussure de traits autour d'un élément qui apparaît + +`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 ». + +```json +{ + "type": "badge", + "text": "Livré", + "style": { + "animation": [ + { "name": "pop_in", "delay": 0.2, "duration": 0.45 }, + { "name": "burst", "delay": 0.28, "duration": 0.4, "count": 10, + "length": 34, "gap": 10, "width": 4, "color": "#FFB020", + "seed": 3, "jitter": 0.25 } + ] + } +} +``` + +| Champ | Rôle | Défaut | +|---|---|---| +| `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` | + +## La tête part, la queue rattrape + +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. + +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. + +## Rien de ce qui appartient au nœud n'est touché + +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 : + +- 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. + +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. + +## Le budget d'animation s'applique + +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é. + +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`. + +## Cas dégénérés + +- `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`. + +## Ce n'est pas `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`. diff --git a/crates/rustmotion/skills/rules/hyperframes-mapping.md b/crates/rustmotion/skills/rules/hyperframes-mapping.md index 7a6c7cb..953e5b1 100644 --- a/crates/rustmotion/skills/rules/hyperframes-mapping.md +++ b/crates/rustmotion/skills/rules/hyperframes-mapping.md @@ -23,6 +23,7 @@ If you're asked for an effect from the Hyperframes catalogue (or an effect descr | Page Slide | `transition: { "type": "slide" }` | | Chromatic Aberration Wipe | `transition: { "type": "chromatic_wipe" }` | | Card Explosion / Glass Break | `style.animation: [{ "name": "shatter" }]` — see [shatter.md](shatter.md) | +| Sparkle / Pop Burst | `style.animation: [{ "name": "burst" }]` — see [burst.md](burst.md) | ## Two naming traps diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index d697071..2f68daf 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -668,6 +668,8 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { AnimationEffect::Shatter(c) => Some((c.delay, c.duration)), + AnimationEffect::Burst(c) => Some((c.delay, c.duration)), + AnimationEffect::Glow(_) | AnimationEffect::Wiggle(_) | AnimationEffect::Orbit(_)