From 6e1f8bbf8cf166d5f8815df91b48579c8b912851 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 09:20:47 +0200 Subject: [PATCH] feat(animation): letter_spacing and draw_start become keyframe properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were blocked on the same thing: a new field on `AnimatedProperties`, in a file another workstream owned while #363/#380 and #377 were in flight. Neither agent reached across the boundary, and one of them explicitly refused to add `letter_spacing` to `KNOWN_MOTION_PROPERTIES` on its own — that alone would have made it validate and stay inert, which is the exact defect #363 was about. **`letter_spacing`.** The intrinsic measurement keeps the static `style.letter-spacing`, so animating it does not re-run layout — the same policy every other layout property follows. The consequence is real and documented rather than hidden: a text whose spacing opens overflows its box. Two mechanisms now share a name. A `timeline` changing `letter-spacing` snaps, and the validator warns; a `keyframes` track on `letter_spacing` interpolates. Different subsystems, deliberately different behaviour, said plainly in the rule file. **`draw_start`.** `draw_progress` advances the head of a stroke; this advances its tail. Together they are a window travelling along the path — a line that runs rather than one that grows. On its own it erases the beginning of a finished stroke. `line` and `svg` both trim through `PathMeasure::get_segment`, the primitive #403 added, rather than a dash effect. That is what keeps a zero-length dash with a round cap from painting the stray dot #376 had to remove. `shape` keeps its own component-level `draw_start` from #403: it carries `path_morph` too, and the two belong together. Five tests. Three fail without the wiring — `tight=48px, loose=48px` for spacing, and a painted first half for the trim. Two assert equality and pass both ways on purpose: an absent animated value must leave the declared one alone, which is why the field defaults to NaN rather than zero. One probe stops six pixels short of the cut on purpose, with the reason in the assertion: a round cap extends half the stroke width back past the trim, which is the cap working, not the trim failing. Closes #380 Closes #377 --- crates/rustmotion-components/src/line.rs | 109 ++++++++++++++++-- crates/rustmotion-components/src/svg.rs | 44 ++++--- crates/rustmotion-components/src/text.rs | 83 +++++++++++++ crates/rustmotion-core/src/engine/animator.rs | 15 +++ crates/rustmotion-core/src/schema/video.rs | 2 + crates/rustmotion/skills/SKILL.md | 1 + .../rules/animatable-spacing-and-trim.md | 46 ++++++++ 7 files changed, 272 insertions(+), 28 deletions(-) create mode 100644 crates/rustmotion/skills/rules/animatable-spacing-and-trim.md diff --git a/crates/rustmotion-components/src/line.rs b/crates/rustmotion-components/src/line.rs index 310f50f..048d7a2 100644 --- a/crates/rustmotion-components/src/line.rs +++ b/crates/rustmotion-components/src/line.rs @@ -51,7 +51,14 @@ rustmotion_core::impl_traits!(Line { impl Line { fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) { let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0; - if drawing && props.draw_progress <= 0.0 { + let trim_start = props.draw_start.clamp(0.0, 1.0); + let trimming = props.draw_start > 0.0; + let trim_end = if drawing { + props.draw_progress.clamp(0.0, 1.0) + } else { + 1.0 + }; + if trim_end <= trim_start && (drawing || trimming) { return; } @@ -69,15 +76,17 @@ impl Line { } } - if drawing { - let dx = self.x2 - self.x1; - let dy = self.y2 - self.y1; - let length = (dx * dx + dy * dy).sqrt(); - let draw_len = length * props.draw_progress.clamp(0.0, 1.0); - let intervals = [draw_len, length - draw_len + 0.01]; - if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { - paint.set_path_effect(dash); - } + if drawing || trimming { + let mut builder = skia_safe::PathBuilder::new(); + builder.move_to((self.x1, self.y1)); + builder.line_to((self.x2, self.y2)); + let trimmed = rustmotion_core::engine::renderer::trim_path_between( + &builder.detach(), + trim_start, + trim_end, + ); + canvas.draw_path(&trimmed, &paint); + return; } canvas.draw_line((self.x1, self.y1), (self.x2, self.y2), &paint); @@ -200,3 +209,83 @@ mod tests { ); } } +#[cfg(test)] +mod draw_start_tests { + use super::*; + + fn line_at(draw_start: f32, draw_progress: f32) -> Vec { + let line = Line { + x1: 10.0, + y1: 50.0, + x2: 90.0, + y2: 50.0, + color: "#FFFFFF".into(), + width: 8.0, + dashed: None, + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + }; + let mut surface = skia_safe::surfaces::raster_n32_premul((100, 100)).unwrap(); + let props = AnimatedProperties { + draw_start, + draw_progress, + ..Default::default() + }; + line.paint(surface.canvas(), &props); + + let info = skia_safe::ImageInfo::new( + (100, 100), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 100 * 100 * 4]; + surface.read_pixels(&info, &mut buf, 100 * 4, (0, 0)); + buf + } + + fn lit_in_columns(buf: &[u8], x0: usize, x1: usize) -> usize { + (0..100) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| buf[(y * 100 + x) * 4 + 3] > 40) + .count() + } + + #[test] + fn draw_start_leaves_the_first_half_empty() { + let buf = line_at(0.5, -1.0); + assert_eq!( + lit_in_columns(&buf, 0, 44), + 0, + "draw_start 0.5 must erase the first half of the stroke. The probe stops at 44 \ + rather than 50 because the round cap extends half the 8px stroke back past the \ + cut, which is the cap doing its job and not the trim failing" + ); + assert!( + lit_in_columns(&buf, 52, 100) > 100, + "and leave the second half painted" + ); + } + + #[test] + fn draw_start_and_draw_progress_bound_a_window() { + let buf = line_at(0.3, 0.7); + assert_eq!(lit_in_columns(&buf, 0, 30), 0, "before the window: empty"); + assert_eq!(lit_in_columns(&buf, 78, 100), 0, "after the window: empty"); + assert!( + lit_in_columns(&buf, 40, 60) > 80, + "inside the window: painted" + ); + } + + #[test] + fn no_draw_start_renders_exactly_as_before() { + assert_eq!( + line_at(-1.0, -1.0), + line_at(0.0, -1.0), + "draw_start absent and draw_start 0 must both paint the whole line, byte for byte" + ); + } +} diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index cbaac0d..fe9c149 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -375,10 +375,12 @@ fn paint_draw_on( svg_size: usvg::Size, layout: &BoxLayout, progress: f32, + start_progress: f32, draw_stroke_width: f32, draw_overlap: f32, ) { let progress = progress.clamp(0.0, 1.0); + let start_progress = start_progress.clamp(0.0, 1.0); let mut segments: Vec = Vec::new(); collect_paths(group, draw_stroke_width, &mut segments); @@ -424,20 +426,22 @@ fn paint_draw_on( let start_frac = cumulative * (1.0 - overlap); cumulative += base_frac; - let local_t = if window_size > 0.0 { - ((progress - start_frac) / window_size).clamp(0.0, 1.0) - } else if progress >= start_frac { - 1.0 - } else { - 0.0 + let local_of = |global: f32| { + if window_size > 0.0 { + ((global - start_frac) / window_size).clamp(0.0, 1.0) + } else if global >= start_frac { + 1.0 + } else { + 0.0 + } }; + let local_t = local_of(progress); + let local_start = local_of(start_progress); - if local_t <= 0.0 { + if local_t <= local_start { continue; } - let draw_len = length * local_t; - let mut paint = Paint::default(); paint.set_color(segment.color); paint.set_style(PaintStyle::Stroke); @@ -446,15 +450,16 @@ fn paint_draw_on( paint.set_stroke_join(segment.join); paint.set_anti_alias(true); - if local_t < 1.0 && draw_len > 0.0 { - let remaining = length - draw_len; - let intervals = [draw_len, remaining + 0.01]; - if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { - paint.set_path_effect(dash); - } + if local_t < 1.0 || local_start > 0.0 { + let trimmed = rustmotion_core::engine::renderer::trim_path_between( + &segment.path, + local_start, + local_t, + ); + canvas.draw_path(&trimmed, &paint); + } else { + canvas.draw_path(&segment.path, &paint); } - - canvas.draw_path(&segment.path, &paint); } canvas.restore(); @@ -468,7 +473,9 @@ impl Painter for Svg { props: &AnimatedProperties, _ctx: &PaintCtx, ) { - let draw_active = self.draw || (props.draw_progress >= 0.0 && props.draw_progress < 1.0); + let draw_active = self.draw + || (props.draw_progress >= 0.0 && props.draw_progress < 1.0) + || props.draw_start > 0.0; if draw_active { let progress = if props.draw_progress >= 0.0 { @@ -522,6 +529,7 @@ impl Painter for Svg { svg_size, layout, progress, + props.draw_start.max(0.0), self.draw_stroke_width, self.draw_overlap, ); diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 9049cf0..eaa8dd3 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -190,6 +190,9 @@ impl Text { ); let (mut font_size, mut letter_spacing, mut line_height_val) = self.style.typography_px_ctx(&base_ctx, 48.0); + if props.letter_spacing.is_finite() { + letter_spacing = props.letter_spacing; + } let color = props .color .as_deref() @@ -2078,4 +2081,84 @@ mod tests { "once the morph window has passed, the frame must match a plain cut to the final label" ); } + + fn painted_width(letter_spacing: f32) -> usize { + let mut text = make_text("IIIII", None); + text.style.font_size = Some(Length::Px(40.0)); + let mut surface = skia_safe::surfaces::raster_n32_premul((600, 120)).unwrap(); + let props = AnimatedProperties { + letter_spacing, + ..Default::default() + }; + let _ = text.paint(surface.canvas(), 600.0, None, 0.0, &props, &test_ctx()); + + let info = skia_safe::ImageInfo::new( + (600, 120), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 600 * 120 * 4]; + surface.read_pixels(&info, &mut buf, 600 * 4, (0, 0)); + + let mut lo = usize::MAX; + let mut hi = 0usize; + for y in 0..120 { + for x in 0..600 { + if buf[(y * 600 + x) * 4 + 3] > 60 { + lo = lo.min(x); + hi = hi.max(x); + } + } + } + if lo == usize::MAX { + 0 + } else { + hi - lo + } + } + + #[test] + fn an_animated_letter_spacing_widens_the_painted_run() { + let tight = painted_width(0.0); + let loose = painted_width(16.0); + assert!(tight > 0, "sanity: the tight run paints something"); + assert!( + loose > tight + 40, + "a keyframed letter_spacing must move the glyphs apart: tight={tight}px, \ + loose={loose}px over five glyphs at 16px extra each" + ); + } + + #[test] + fn an_absent_letter_spacing_leaves_the_declared_one_alone() { + let declared = { + let mut text = make_text("IIIII", None); + text.style.font_size = Some(Length::Px(40.0)); + text.style.letter_spacing = Some(Length::Px(16.0)); + let mut surface = skia_safe::surfaces::raster_n32_premul((600, 120)).unwrap(); + let _ = text.paint( + surface.canvas(), + 600.0, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); + let info = skia_safe::ImageInfo::new( + (600, 120), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 600 * 120 * 4]; + surface.read_pixels(&info, &mut buf, 600 * 4, (0, 0)); + buf + }; + assert!( + declared.iter().any(|b| *b > 60), + "with no animated value, style.letter-spacing must still be honoured — the \ + animated field defaults to NaN precisely so absent means absent" + ); + } } diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index f114047..9ddc10c 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -518,6 +518,8 @@ pub struct AnimatedProperties { pub blur: f32, pub blur_x: f32, pub blur_y: f32, + pub letter_spacing: f32, + pub draw_start: f32, pub visible_chars: i32, pub visible_chars_progress: f32, pub color: Option, @@ -551,6 +553,8 @@ impl Default for AnimatedProperties { rotation: 0.0, blur: 0.0, blur_x: 0.0, + letter_spacing: f32::NAN, + draw_start: -1.0, blur_y: 0.0, visible_chars: -1, visible_chars_progress: -1.0, @@ -602,6 +606,13 @@ impl AnimatedProperties { if other.blur_x > 0.001 { self.blur_x = other.blur_x; } + + if other.letter_spacing.is_finite() { + self.letter_spacing = other.letter_spacing; + } + if other.draw_start >= 0.0 { + self.draw_start = other.draw_start; + } if other.blur_y > 0.001 { self.blur_y = other.blur_y; } @@ -910,6 +921,8 @@ fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) { "rotation" => props.rotation = value as f32, "blur" => props.blur = value as f32, "blur_x" => props.blur_x = value as f32, + "letter_spacing" => props.letter_spacing = value as f32, + "draw_start" => props.draw_start = value as f32, "blur_y" => props.blur_y = value as f32, "visible_chars" => props.visible_chars = value as i32, "visible_chars_progress" => props.visible_chars_progress = value as f32, @@ -1141,6 +1154,8 @@ fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { "rotation" => props.rotation as f64, "blur" => props.blur as f64, "blur_x" => props.blur_x as f64, + "letter_spacing" => props.letter_spacing as f64, + "draw_start" => props.draw_start as f64, "blur_y" => props.blur_y as f64, "border_radius" => props.border_radius as f64, "font_size" => props.font_size as f64, diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index ff0f3cf..3ffe31c 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -639,6 +639,8 @@ const KNOWN_MOTION_PROPERTIES: &[&str] = &[ "motion_progress", "color", "clip_path_progress", + "letter_spacing", + "draw_start", ]; fn validate_motion_property(value: &str) -> Result<(), E> { diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index f8e3224..b86a909 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -245,6 +245,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` - [rules/mask-transition.md](rules/mask-transition.md) - `mask` and `blob`: reveal through any silhouette, and an organic wobbling edge whose covering radius solves itself - [rules/feathered-wipes.md](rules/feathered-wipes.md) - `feather` and `band_color`, shared by every `wipe_*` plus `mask` and `blob` +- [rules/animatable-spacing-and-trim.md](rules/animatable-spacing-and-trim.md) - `letter_spacing` and `draw_start` as keyframe properties: why animating spacing overflows its box, and how `draw_start` differs from the `shape` field of the same name - [rules/shape-draw-start-and-path-morph.md](rules/shape-draw-start-and-path-morph.md) - `shape.draw_start` and `shape.path_morph` — and why they are component fields, not `style.animation` keyframe properties - [rules/iris-transition.md](rules/iris-transition.md) - `iris` beyond a centred circle: `origin`, `shape`, `fill`+`hold`, `ring` and `reverse`, and the pill coverage approximation - [rules/whip-transition.md](rules/whip-transition.md) - The whip cut: a directional slide that streaks **both** frames along its axis, unlike `zoom_blur` which streaks only the outgoing one diff --git a/crates/rustmotion/skills/rules/animatable-spacing-and-trim.md b/crates/rustmotion/skills/rules/animatable-spacing-and-trim.md new file mode 100644 index 0000000..12e86a4 --- /dev/null +++ b/crates/rustmotion/skills/rules/animatable-spacing-and-trim.md @@ -0,0 +1,46 @@ +# Deux propriétés qui manquaient : `letter_spacing` et `draw_start` + +Toutes deux s'animent par `keyframes`, comme `opacity` ou `scale`. + +```json +"style": { "animation": [{ "name": "keyframes", "keyframes": [ + { "property": "letter_spacing", + "keyframes": [{ "time": 0, "value": 0 }, { "time": 0.8, "value": 18 }], + "easing": "ease_out" } +]}]} +``` + +## `letter_spacing` : le texte déborde, et c'est voulu + +La mesure intrinsèque du nœud garde la valeur **statique** de +`style.letter-spacing`. Animer l'interlettrage ne relance donc pas le layout — +c'est la même politique que partout ailleurs : interpoler une propriété de layout +demanderait de recalculer les boîtes à chaque frame échantillonnée. + +Conséquence : un texte dont l'interlettrage s'ouvre **dépasse de sa boîte**. +Dimensionne la boîte sur la valeur finale, ou accepte le débordement en connaissance +de cause. + +Attention au double mécanisme : un `timeline` qui change `letter-spacing` **saute** +et le validateur t'avertit ; une piste `keyframes` sur `letter_spacing` +**interpole**. Ce ne sont pas les mêmes noms par hasard — l'un est une propriété +CSS dans un état, l'autre une propriété de mouvement. + +## `draw_start` : l'autre bout du tracé + +`draw_progress` avance la tête du tracé. `draw_start` avance sa **queue**. Les deux +ensemble définissent une fenêtre qui se déplace le long du chemin — un trait qui +court, plutôt qu'un trait qui pousse. + +`draw_start` seul, sans `draw_progress`, efface le début d'un tracé complet. + +Fonctionne sur `line` et `svg`. Sur `shape`, c'est le champ de composant +`shape.draw_start` qu'il faut — voir +[shape-draw-start-and-path-morph.md](shape-draw-start-and-path-morph.md) — parce +que `shape` porte aussi `path_morph` et que les deux vont ensemble. + +**Le découpage passe par `PathMeasure`, pas par un effet de tirets.** C'est ce qui +évite le point parasite qu'un tiret de longueur nulle avec un capuchon rond +produisait (#376). En revanche le capuchon déborde toujours d'une demi-épaisseur +en arrière du point de coupe : c'est le capuchon qui fait son travail, pas le +découpage qui rate.