From a736fd9ed68a07a9ae61618bb36a3d107b191bbf Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 10:00:33 +0200 Subject: [PATCH 1/2] feat(animation): shatter effect breaks a node's render into flying Voronoi shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AnimationEffect::Shatter (schema/video.rs) and its paint_pass implementation, closing #378. The node's own subtree is rasterised once into a dedicated offscreen surface sized to its box (same "capture pixels, read them back" move as paint_inflated_material), then cut into a deterministic Voronoi partition seeded by `seed` (jittered grid seed points, Sutherland-Hodgman half-plane clipping against every other seed — no external geometry crate). Each cell gets a seeded direction/travel/ spin/depth and is drawn back onto the real canvas with its own transform and clip, in that order: transform first, clip second, or the mask stays put while the pixels slide under it — a real bug caught by shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not during development (confirmed red before the fix, green after). paint_node's post-transform body is extracted into paint_node_visual so both the plain path and the shattered offscreen capture share the exact same painting code; the capture pass disables hit registration (children aren't sane click targets mid-shatter) without touching PaintContext's shape. mode: "out"/"in" hard-short-circuit to "no effect at all" outside [delay, delay+duration) — same guarantee chromatic_aberration and zoom_blur already give, verified by reverting shatter_progress's cutoff and watching the pixel-identity tests fail. mode: "hold" is the deliberate exception: it freezes at full dispersion and never reconverges. entrance_budget (validate_schema.rs) gets the new arm the exhaustive match required; shift_delay (schema/video.rs) does too. crates/rustmotion/skills/rules/shatter.md documents the vocabulary, the mode/zero-at-ends contract, and the transform-before-clip pitfall for future readers. --- crates/rustmotion-core/src/engine/animator.rs | 90 ++++ .../rustmotion-core/src/engine/paint_pass.rs | 508 +++++++++++++++++- crates/rustmotion-core/src/schema/video.rs | 142 +++++ crates/rustmotion/skills/rules/shatter.md | 68 +++ .../src/cli/commands/validate_schema.rs | 2 + 5 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 crates/rustmotion/skills/rules/shatter.md diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 9ddc10c..51f4f07 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -309,6 +309,96 @@ mod chromatic_aberration_shift_tests { } } +pub fn shatter_progress(cfg: &crate::schema::ShatterConfig, time: f64) -> Option { + use crate::schema::ShatterMode; + + if cfg.duration <= 0.0 { + return None; + } + let elapsed = time - cfg.delay; + if elapsed < 0.0 { + return None; + } + match cfg.mode { + ShatterMode::Hold => Some((elapsed / cfg.duration).min(1.0) as f32), + ShatterMode::Out => { + if elapsed >= cfg.duration { + None + } else { + Some((elapsed / cfg.duration) as f32) + } + } + ShatterMode::In => { + if elapsed >= cfg.duration { + None + } else { + Some((1.0 - elapsed / cfg.duration) as f32) + } + } + } +} + +#[cfg(test)] +mod shatter_progress_tests { + use super::*; + use crate::schema::{ShatterConfig, ShatterMode}; + + fn cfg(mode: ShatterMode) -> ShatterConfig { + ShatterConfig { + delay: 1.0, + duration: 0.5, + mode, + pieces: 12, + seed: 3, + origin: Default::default(), + spread: 1.0, + spin: 90.0, + depth: 0.4, + fade: true, + } + } + + #[test] + fn out_is_none_before_delay_and_at_or_after_the_end() { + let c = cfg(ShatterMode::Out); + assert_eq!(shatter_progress(&c, 0.0), None); + assert_eq!(shatter_progress(&c, 0.999), None); + assert_eq!(shatter_progress(&c, 1.5), None); + assert_eq!(shatter_progress(&c, 10.0), None); + } + + #[test] + fn out_climbs_from_zero_to_just_under_one_across_the_window() { + let c = cfg(ShatterMode::Out); + assert_eq!(shatter_progress(&c, 1.0), Some(0.0)); + let mid = shatter_progress(&c, 1.25).unwrap(); + assert!(mid > 0.0 && mid < 1.0, "got {mid}"); + } + + #[test] + fn in_is_the_mirror_of_out() { + let c = cfg(ShatterMode::In); + assert_eq!(shatter_progress(&c, 0.0), None); + assert_eq!(shatter_progress(&c, 1.5), None); + assert_eq!(shatter_progress(&c, 1.0), Some(1.0)); + let mid = shatter_progress(&c, 1.25).unwrap(); + assert!(mid > 0.0 && mid < 1.0, "got {mid}"); + } + + #[test] + fn hold_freezes_at_one_past_the_window_instead_of_going_back_to_none() { + let c = cfg(ShatterMode::Hold); + assert_eq!(shatter_progress(&c, 0.0), None); + assert_eq!(shatter_progress(&c, 1.0), Some(0.0)); + assert_eq!(shatter_progress(&c, 1.5), Some(1.0)); + assert_eq!( + shatter_progress(&c, 100.0), + Some(1.0), + "hold must never converge back to None, unlike out/in" + ); + } +} + fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { let t_curve = find_bezier_t_for_x(t, x1, x2); bezier_component(t_curve, y1, y2) diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 5686c4a..f713177 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -403,6 +403,28 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } } + match active_shatter(&node.css, ctx.frame.time) { + Some((cfg, progress)) => { + paint_shattered_node( + canvas, node, box_layout, length_ctx, ctx, tree_depth, cfg, progress, + ); + } + None => { + paint_node_visual(canvas, node, box_layout, length_ctx, ctx, tree_depth); + } + } + + canvas.restore(); +} + +fn paint_node_visual( + canvas: &Canvas, + node: &BoxNode, + box_layout: &BoxLayout, + length_ctx: LengthContext, + ctx: &PaintContext, + tree_depth: usize, +) { let overflow = node.css.overflow.unwrap_or(Overflow::Visible); let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0); @@ -606,7 +628,272 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u if opened_opacity_layer { canvas.restore(); } - canvas.restore(); +} + +const MAX_SHATTER_PIECES: u32 = 64; + +fn active_shatter(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShatterConfig, f32)> { + let cfg = css.animation.iter().find_map(|e| match e { + crate::schema::AnimationEffect::Shatter(c) => Some(c), + _ => None, + })?; + crate::engine::animator::shatter_progress(cfg, time).map(|progress| (cfg, progress)) +} + +fn shatter_hash(seed: u32, index: u32, salt: u32) -> f32 { + let mut h = (index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (seed as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9) + ^ ((salt as u64) << 32).wrapping_mul(0x94D0_49BB_1331_11EB); + h ^= h >> 30; + h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); + h ^= h >> 27; + h = h.wrapping_mul(0x94D0_49BB_1331_11EB); + h ^= h >> 31; + ((h >> 11) as f64 / (1u64 << 53) as f64) as f32 +} + +fn shatter_seed_points(width: f32, height: f32, pieces: u32, seed: u32) -> Vec<(f32, f32)> { + let cols = (pieces as f32).sqrt().ceil().max(1.0) as u32; + let rows = pieces.div_ceil(cols).max(1); + let cell_w = width / cols as f32; + let cell_h = height / rows as f32; + (0..pieces) + .map(|i| { + let col = i % cols; + let row = i / cols; + let jitter_x = (shatter_hash(seed, i, 1) - 0.5) * cell_w * 0.7; + let jitter_y = (shatter_hash(seed, i, 2) - 0.5) * cell_h * 0.7; + let x = ((col as f32 + 0.5) * cell_w + jitter_x).clamp(0.0, width); + let y = ((row as f32 + 0.5) * cell_h + jitter_y).clamp(0.0, height); + (x, y) + }) + .collect() +} + +fn shatter_polygon_side(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { + (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0) +} + +fn shatter_segment_intersection( + p0: (f32, f32), + p1: (f32, f32), + a: (f32, f32), + b: (f32, f32), +) -> (f32, f32) { + let (x1, y1) = p0; + let (x2, y2) = p1; + let (x3, y3) = a; + let (x4, y4) = b; + let denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if denom.abs() < 1e-6 { + return p1; + } + let t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + (x1 + t * (x2 - x1), y1 + t * (y2 - y1)) +} + +fn shatter_clip_half_plane(poly: Vec<(f32, f32)>, a: (f32, f32), b: (f32, f32)) -> Vec<(f32, f32)> { + if poly.is_empty() { + return poly; + } + let n = poly.len(); + let mut out = Vec::with_capacity(n + 1); + for idx in 0..n { + let cur = poly[idx]; + let prev = poly[(idx + n - 1) % n]; + let cur_in = shatter_polygon_side(cur, a, b) >= 0.0; + let prev_in = shatter_polygon_side(prev, a, b) >= 0.0; + if cur_in != prev_in { + out.push(shatter_segment_intersection(prev, cur, a, b)); + } + if cur_in { + out.push(cur); + } + } + out +} + +fn shatter_clip_by_bisector( + poly: Vec<(f32, f32)>, + keep: (f32, f32), + other: (f32, f32), + reach: f32, +) -> Vec<(f32, f32)> { + let mid = ((keep.0 + other.0) * 0.5, (keep.1 + other.1) * 0.5); + let dir = (other.0 - keep.0, other.1 - keep.1); + let len = (dir.0 * dir.0 + dir.1 * dir.1).sqrt().max(1e-6); + let perp = (-dir.1 / len, dir.0 / len); + let mut a = (mid.0 + perp.0 * reach, mid.1 + perp.1 * reach); + let mut b = (mid.0 - perp.0 * reach, mid.1 - perp.1 * reach); + if shatter_polygon_side(keep, a, b) < 0.0 { + std::mem::swap(&mut a, &mut b); + } + shatter_clip_half_plane(poly, a, b) +} + +fn shatter_cells(width: f32, height: f32, pieces: u32, seed: u32) -> Vec> { + if width <= 0.0 || height <= 0.0 { + return Vec::new(); + } + let pieces = pieces.clamp(1, MAX_SHATTER_PIECES); + let points = shatter_seed_points(width, height, pieces, seed); + let reach = (width + height) * 4.0 + 1000.0; + (0..points.len()) + .map(|i| { + let mut poly = vec![(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]; + for (j, &other) in points.iter().enumerate() { + if j == i || poly.is_empty() { + continue; + } + poly = shatter_clip_by_bisector(poly, points[i], other, reach); + } + poly + }) + .collect() +} + +fn shatter_polygon_centroid(points: &[(f32, f32)]) -> (f32, f32) { + let n = points.len(); + let mut area = 0.0_f32; + let mut cx = 0.0_f32; + let mut cy = 0.0_f32; + for i in 0..n { + let (x0, y0) = points[i]; + let (x1, y1) = points[(i + 1) % n]; + let cross = x0 * y1 - x1 * y0; + area += cross; + cx += (x0 + x1) * cross; + cy += (y0 + y1) * cross; + } + area *= 0.5; + if area.abs() < 1e-6 { + let sum = points + .iter() + .fold((0.0, 0.0), |acc, p| (acc.0 + p.0, acc.1 + p.1)); + return (sum.0 / n as f32, sum.1 / n as f32); + } + (cx / (6.0 * area), cy / (6.0 * area)) +} + +fn paint_shattered_node( + canvas: &Canvas, + node: &BoxNode, + box_layout: &BoxLayout, + length_ctx: LengthContext, + ctx: &PaintContext, + tree_depth: usize, + cfg: &crate::schema::ShatterConfig, + progress: f32, +) { + let width = box_layout.width.max(1.0).ceil() as i32; + let height = box_layout.height.max(1.0).ceil() as i32; + let info = skia_safe::ImageInfo::new( + (width, height), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let Some(mut surface) = skia_safe::surfaces::raster(&info, None, None) else { + paint_node_visual(canvas, node, box_layout, length_ctx, ctx, tree_depth); + return; + }; + let offscreen = surface.canvas(); + offscreen.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); + offscreen.translate((-box_layout.x, -box_layout.y)); + let capture_ctx = PaintContext { + layout: ctx.layout, + frame: ctx.frame, + dispatcher: ctx.dispatcher, + viewport_size: ctx.viewport_size, + hits: None, + }; + paint_node_visual( + offscreen, + node, + box_layout, + length_ctx, + &capture_ctx, + tree_depth, + ); + let image = surface.image_snapshot(); + + let pieces = cfg.pieces.clamp(1, MAX_SHATTER_PIECES); + let cells = shatter_cells(box_layout.width, box_layout.height, pieces, cfg.seed); + let origin_x = cfg.origin.x.clamp(0.0, 1.0) * box_layout.width; + let origin_y = cfg.origin.y.clamp(0.0, 1.0) * box_layout.height; + let diagonal = (box_layout.width.powi(2) + box_layout.height.powi(2)).sqrt(); + + for (i, cell) in cells.iter().enumerate() { + if cell.len() < 3 { + continue; + } + let index = i as u32; + let centroid = shatter_polygon_centroid(cell); + let (mut dx, mut dy) = (centroid.0 - origin_x, centroid.1 - origin_y); + let dist = (dx * dx + dy * dy).sqrt(); + if dist < 0.001 { + let angle = shatter_hash(cfg.seed, index, 7) * std::f32::consts::TAU; + dx = angle.cos(); + dy = angle.sin(); + } else { + dx /= dist; + dy /= dist; + } + + let travel_jitter = 0.6 + shatter_hash(cfg.seed, index, 3) * 0.8; + let travel = cfg.spread.max(0.0) * diagonal * 0.5 * travel_jitter * progress; + + let spin_sign = if shatter_hash(cfg.seed, index, 5) < 0.5 { + -1.0 + } else { + 1.0 + }; + let spin_jitter = 0.5 + shatter_hash(cfg.seed, index, 6) * 0.5; + let rotation = cfg.spin * spin_sign * spin_jitter * progress; + + let depth_value = (shatter_hash(cfg.seed, index, 4) * 2.0 - 1.0) * cfg.depth; + let scale = (1.0 + depth_value * progress).max(0.05); + + let alpha = if cfg.fade { + ((1.0 - progress).clamp(0.0, 1.0) * 255.0).round() as u8 + } else { + 255 + }; + if alpha == 0 { + continue; + } + + let mut clip_builder = PathBuilder::new(); + for (idx, &(px, py)) in cell.iter().enumerate() { + let p = (box_layout.x + px, box_layout.y + py); + if idx == 0 { + clip_builder.move_to(p); + } else { + clip_builder.line_to(p); + } + } + clip_builder.close(); + let clip_path = clip_builder.detach(); + + let anchor = (box_layout.x + centroid.0, box_layout.y + centroid.1); + + canvas.save(); + canvas.translate((anchor.0 + dx * travel, anchor.1 + dy * travel)); + if rotation.abs() > 0.001 { + canvas.rotate(rotation, None); + } + if (scale - 1.0).abs() > 0.001 { + canvas.scale((scale, scale)); + } + canvas.translate((-anchor.0, -anchor.1)); + canvas.clip_path(&clip_path, ClipOp::Intersect, true); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_alpha(alpha); + canvas.draw_image(&image, (box_layout.x, box_layout.y), Some(&paint)); + canvas.restore(); + } } fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> { @@ -3593,7 +3880,10 @@ mod paint_order_tests { use crate::css::units::{Length, LengthPercentage as CLP}; use crate::engine::box_tree::{BoxKind, BoxNode}; use crate::engine::layout_pass::run_layout; - use crate::schema::{AnimationEffect, ChromaticAberrationConfig, EasingType}; + use crate::schema::{ + AnimationEffect, ChromaticAberrationConfig, EasingType, ShatterConfig, ShatterMode, + ShatterOrigin, + }; fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { @@ -4854,6 +5144,220 @@ mod paint_order_tests { ); } + fn shatter_cfg(mode: ShatterMode) -> ShatterConfig { + ShatterConfig { + delay: 0.2, + duration: 0.6, + mode, + pieces: 16, + seed: 42, + origin: ShatterOrigin::default(), + spread: 1.0, + spin: 90.0, + depth: 0.4, + fade: true, + } + } + + fn any_ink_in_band(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> bool { + for y in y0..y1 { + for x in x0..x1 { + let i = ((y * w + x) * 4) as usize; + if buf[i] > 20 || buf[i + 1] > 20 || buf[i + 2] > 20 { + return true; + } + } + } + false + } + + #[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![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + + let cfg = shatter_cfg(ShatterMode::Out); + let mut before = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])], + ); + let before_delay = render_pixels_at(&mut before, 400, 400, 0.0); + assert_eq!( + baseline, before_delay, + "before delay, mode: out must contribute nothing at all — pixel-identical to a \ + node with no shatter effect in its animation list" + ); + + let mut after = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg)])], + ); + let after_duration = render_pixels_at(&mut after, 400, 400, 5.0); + assert_eq!( + baseline, after_duration, + "mode: out must short-circuit back to the plain node once delay + duration has \ + elapsed — no shards left hanging" + ); + } + + #[test] + fn shatter_in_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![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + + let cfg = shatter_cfg(ShatterMode::In); + let mut before = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])], + ); + let before_delay = render_pixels_at(&mut before, 400, 400, 0.0); + assert_eq!( + baseline, before_delay, + "mode: in must also render as the plain node before delay — the mirror still \ + short-circuits outside its own window" + ); + + let mut after = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg)])], + ); + let after_duration = render_pixels_at(&mut after, 400, 400, 5.0); + assert_eq!( + baseline, after_duration, + "mode: in converges back to the plain node by delay + duration" + ); + } + + #[test] + fn shatter_hold_freezes_past_the_window_instead_of_reverting() { + 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 = shatter_cfg(ShatterMode::Hold); + let mut held = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg)])], + ); + let long_after = render_pixels_at(&mut held, 400, 400, 5.0); + assert_ne!( + baseline, long_after, + "mode: hold must never converge back to the plain node, unlike out/in — the \ + shards stay frozen in suspension" + ); + } + + #[test] + fn shatter_two_renders_of_the_same_instant_are_byte_identical() { + let cfg = shatter_cfg(ShatterMode::Out); + let mut root_a = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])], + ); + let a = render_pixels_at(&mut root_a, 400, 400, 0.5); + + let mut root_b = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg)])], + ); + let b = render_pixels_at(&mut root_b, 400, 400, 0.5); + + assert_eq!( + a, b, + "seed + instant must fully determine every shard — two renders of the same frame \ + must be byte-identical" + ); + } + + #[test] + fn shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not() { + let band = (221u32, 100u32, 320u32, 220u32); + + let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let intact = render_pixels_at(&mut plain, 400, 400, 0.0); + assert!( + !any_ink_in_band(&intact, 400, band.0, band.1, band.2, band.3), + "sanity: an intact node must not paint outside its own box" + ); + + let cfg = shatter_cfg(ShatterMode::Out); + let mut shattered = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg)])], + ); + let mid_flight = render_pixels_at(&mut shattered, 400, 400, 0.5); + assert!( + any_ink_in_band(&mid_flight, 400, band.0, band.1, band.2, band.3), + "a shattered node mid-flight must have ink outside its own box — a shard has \ + physically left the node's rect" + ); + } + + #[test] + #[ignore] + fn shatter_per_frame_cost_vs_the_plain_node() { + let frames = 200; + let cfg = shatter_cfg(ShatterMode::Out); + + let time_plain = || { + let start = std::time::Instant::now(); + for i in 0..frames { + let mut root = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let _ = render_pixels_at(&mut root, 400, 400, i as f64 / 30.0); + } + start.elapsed() + }; + let time_shatter = |cfg: &ShatterConfig| { + let start = std::time::Instant::now(); + for i in 0..frames { + let mut root = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])], + ); + let _ = render_pixels_at(&mut root, 400, 400, i as f64 / 30.0); + } + start.elapsed() + }; + + time_plain(); + time_shatter(&cfg); + + let plain_elapsed = time_plain().min(time_plain()); + let shatter_elapsed = time_shatter(&cfg).min(time_shatter(&cfg)); + + println!( + "plain: {:?}/frame, shatter (16 pieces, 120x120 box): {:?}/frame, ratio {:.1}x", + plain_elapsed / frames, + shatter_elapsed / frames, + shatter_elapsed.as_secs_f64() / plain_elapsed.as_secs_f64().max(1e-9) + ); + + let heavy_cfg = ShatterConfig { pieces: 64, ..cfg }; + let heavy_elapsed = time_shatter(&heavy_cfg).min(time_shatter(&heavy_cfg)); + println!( + "shatter (64 pieces, 120x120 box): {:?}/frame, ratio {:.1}x", + heavy_elapsed / frames, + heavy_elapsed.as_secs_f64() / plain_elapsed.as_secs_f64().max(1e-9) + ); + } + #[test] fn clip_path_morph_interpolates_a_circles_radius_between_two_keyframes() { let morph_at = |progress: f32| ClipPath::Morph { diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 3ffe31c..105c990 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -107,6 +107,10 @@ pub enum AnimationEffect { /// red/cyan fringes that converge back to zero separation by the end. /// See [`ChromaticAberrationConfig`]'s doc comment. ChromaticAberration(ChromaticAberrationConfig), + /// Cuts the node's own rendered pixels into a deterministic Voronoi + /// partition and flies the pieces apart (or together). See + /// [`ShatterConfig`]'s doc comment. + Shatter(ShatterConfig), } impl AnimationEffect { @@ -128,6 +132,7 @@ impl AnimationEffect { MotionPath(c) => c.delay += by, Shimmer(c) => c.delay += by, ChromaticAberration(c) => c.delay += by, + Shatter(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } @@ -956,6 +961,143 @@ where } } +/// Configuration for the `shatter` animation effect: the node's own rendered +/// subtree — background, border, content and children, exactly as it would +/// have painted — is rasterised once, cut into `pieces` convex cells by a +/// deterministic Voronoi partition seeded by `seed`, and each cell is flown +/// away from `origin`, spun and faded independently. +/// +/// `seed` and the sampled instant fully determine every shard's cell, +/// direction, spin and depth — two renders of the same file at the same time +/// are byte-identical. +/// +/// `mode` decides which end of the timeline is the intact node and which is +/// the fully dispersed one, and what happens once `delay + duration` has +/// elapsed: +/// - `"out"` (default): assembled at `delay`, fully dispersed at +/// `delay + duration`. Outside `[delay, delay + duration)` the effect +/// contributes nothing at all — the node renders byte-identically to one +/// with no `shatter` effect in its `animation` list, the same hard +/// short-circuit `chromatic_aberration` and `zoom_blur` use rather than a +/// fade that only gets close to 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. +/// - `"hold"` plays the same dispersal as `"out"` but freezes at the fully +/// dispersed state once `delay + duration` is reached instead of +/// short-circuiting back to the plain node — it legitimately never +/// converges. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ShatterConfig { + /// Delay before the shards start moving (seconds). + #[serde(default)] + pub delay: f64, + /// How long the dispersal (or, in `"in"` mode, the assembly) takes + /// (seconds). + #[serde(default = "default_shatter_duration")] + pub duration: f64, + /// Which end of the timeline is intact and what happens after + /// `delay + duration`. See the type-level doc comment. + #[serde(default)] + pub mode: ShatterMode, + /// Number of Voronoi shards (default 24, clamped 1..=64). + #[serde(default = "default_shatter_pieces")] + pub pieces: u32, + /// Seed for the deterministic Voronoi partition and every shard's + /// direction/spin/depth jitter. + #[serde(default)] + pub seed: u32, + /// Point shards fly away from (or, in `"in"` mode, converge toward), as + /// a fraction of the node's own box (`{ "x": 0.5, "y": 0.5 }` is the + /// centre, the default). + #[serde(default)] + pub origin: ShatterOrigin, + /// Radial travel multiplier at full dispersal, relative to the node's + /// own diagonal (default 1.0). `0` pins shards in place — only spin, + /// depth and fade remain visible. + #[serde(default = "default_shatter_spread")] + pub spread: f32, + /// Maximum rotation in degrees a shard reaches at full dispersal + /// (default 90); each shard's own sign and magnitude are jittered from + /// `seed`. + #[serde(default = "default_shatter_spin")] + pub spin: f32, + /// Per-shard scale modulation at full dispersal, the same "0.0 = none" + /// semantics as `OrbitConfig::depth` (default 0.4): each shard's own + /// signed depth is jittered from `seed`, so some shards appear to come + /// toward the camera (scale > 1) while others recede (scale < 1). + #[serde(default = "default_shatter_depth")] + pub depth: f32, + /// Fade each shard's opacity to zero as it reaches full dispersal + /// (default true). With `mode: "in"` this is a mirror: shards start + /// transparent and reach full opacity as they assemble. + #[serde(default = "default_true")] + pub fade: bool, +} + +fn default_shatter_duration() -> f64 { + 0.6 +} +fn default_shatter_pieces() -> u32 { + 24 +} +fn default_shatter_spread() -> f32 { + 1.0 +} +fn default_shatter_spin() -> f32 { + 90.0 +} +fn default_shatter_depth() -> f32 { + 0.4 +} +fn default_true() -> bool { + true +} + +/// Which end of `shatter`'s timeline is the intact node. See +/// [`ShatterConfig`]'s doc comment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ShatterMode { + /// Assembled at `delay`, dispersed at `delay + duration`, then + /// short-circuits back to the plain node. + #[default] + Out, + /// Dispersed at `delay`, assembled at `delay + duration`, then + /// short-circuits back to the plain node (the same "no effect" state + /// its own end converges to). + In, + /// Same dispersal as `"out"`, but freezes at full dispersal instead of + /// returning to the plain node. + Hold, +} + +/// Fractional point within `shatter`'s own box — `{ "x": 0.0, "y": 0.0 }` is +/// the top-left corner, `{ "x": 1.0, "y": 1.0 }` the bottom-right. See +/// [`ShatterConfig::origin`]. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ShatterOrigin { + #[serde(default = "default_shatter_origin_component")] + pub x: f32, + #[serde(default = "default_shatter_origin_component")] + pub y: f32, +} + +impl Default for ShatterOrigin { + fn default() -> Self { + Self { + x: default_shatter_origin_component(), + y: default_shatter_origin_component(), + } + } +} + +fn default_shatter_origin_component() -> f32 { + 0.5 +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ShapeType { diff --git a/crates/rustmotion/skills/rules/shatter.md b/crates/rustmotion/skills/rules/shatter.md new file mode 100644 index 0000000..4fa2b6b --- /dev/null +++ b/crates/rustmotion/skills/rules/shatter.md @@ -0,0 +1,68 @@ +# Rule: `shatter` — fragments de Voronoi qui s'envolent (ou s'assemblent) + +`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. + +## La forme + +```json +{ + "type": "div", + "style": { + "width": 400, "height": 300, + "background": "#1B1F3B", + "animation": [{ + "name": "shatter", + "delay": 1.2, + "duration": 0.7, + "mode": "out", + "pieces": 24, + "seed": 7, + "origin": { "x": 0.5, "y": 0.5 }, + "spread": 1.0, + "spin": 90, + "depth": 0.4, + "fade": true + }] + } +} +``` + +| Champ | Rôle | Défaut | +|---|---|---| +| `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` | + +## `mode` décide quelle extrémité est le nœud intact + +- **`"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. + +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. + +## Comment c'est peint + +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. + +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. + +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. + +## Piège : `origin` est une fraction, pas des px + +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. + +## Cas dégénérés + +- `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. diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index 34995e0..d697071 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -666,6 +666,8 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { AnimationEffect::ChromaticAberration(c) => Some((c.delay, c.duration)), + AnimationEffect::Shatter(c) => Some((c.delay, c.duration)), + AnimationEffect::Glow(_) | AnimationEffect::Wiggle(_) | AnimationEffect::Orbit(_) From 7b1b6267d1dc09b1bd7dd10ba02e46abbc2902e7 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 10:13:42 +0200 Subject: [PATCH 2/2] docs(shatter): index the rule and state that the budget applies The implementation left the rule file unindexed. It also left out the one thing an author gets wrong first: shatter reads like an exit preset but is not one of the exempted names, so the completion budget applies to it in full -- a shatter meant to land on the cut must satisfy delay + duration == scene_duration, not overrun it. --- crates/rustmotion/skills/SKILL.md | 1 + crates/rustmotion/skills/rules/hyperframes-mapping.md | 1 + crates/rustmotion/skills/rules/shatter.md | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index bde0f23..73eb928 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -236,6 +236,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis - [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/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/layout-surface.md](rules/layout-surface.md) - Project a flat grid onto a cylinder or sphere with one shared vanishing point — and why children still paint in declaration order diff --git a/crates/rustmotion/skills/rules/hyperframes-mapping.md b/crates/rustmotion/skills/rules/hyperframes-mapping.md index bbd28cb..7a6c7cb 100644 --- a/crates/rustmotion/skills/rules/hyperframes-mapping.md +++ b/crates/rustmotion/skills/rules/hyperframes-mapping.md @@ -22,6 +22,7 @@ If you're asked for an effect from the Hyperframes catalogue (or an effect descr | Dynamic Grid | `animated-background` preset `grid_lines` | | 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) | ## Two naming traps diff --git a/crates/rustmotion/skills/rules/shatter.md b/crates/rustmotion/skills/rules/shatter.md index 4fa2b6b..9ceefa8 100644 --- a/crates/rustmotion/skills/rules/shatter.md +++ b/crates/rustmotion/skills/rules/shatter.md @@ -56,6 +56,10 @@ La partition de Voronoi vient d'un semis de points sur une grille approximative 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. +## Le budget d'animation s'applique + +`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. + ## Piège : `origin` est une fraction, pas des px 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.