Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 99 additions & 10 deletions crates/rustmotion-components/src/line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down Expand Up @@ -200,3 +209,83 @@ mod tests {
);
}
}
#[cfg(test)]
mod draw_start_tests {
use super::*;

fn line_at(draw_start: f32, draw_progress: f32) -> Vec<u8> {
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"
);
}
}
44 changes: 26 additions & 18 deletions crates/rustmotion-components/src/svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DrawSegment> = Vec::new();
collect_paths(group, draw_stroke_width, &mut segments);
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
);
Expand Down
83 changes: 83 additions & 0 deletions crates/rustmotion-components/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
);
}
}
15 changes: 15 additions & 0 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/rustmotion-core/src/schema/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,8 @@ const KNOWN_MOTION_PROPERTIES: &[&str] = &[
"motion_progress",
"color",
"clip_path_progress",
"letter_spacing",
"draw_start",
];

fn validate_motion_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading