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.