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
42 changes: 41 additions & 1 deletion crates/rustmotion-components/src/svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ pub struct Svg {
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
/// Force draw-on mode even when draw_progress is 1.0 (static draw trace view, no animation needed).
/// Take the draw-on path even outside an animation's own window. It needs a driver:
/// a `draw_in`/`stroke_reveal` preset or keyframes on `draw_progress`. On its own it
/// leaves `draw_progress` at rest, which paints the finished mark — `validate` rejects
/// that rather than let the flag look as though it did something.
#[serde(default)]
pub draw: bool,
/// Stroke width used when tracing fill-only paths (no stroke in the SVG).
Expand Down Expand Up @@ -1049,4 +1052,41 @@ mod tests {
must trigger the stderr warning"
);
}
#[test]
fn draw_with_nothing_driving_progress_is_pixel_identical_to_no_draw() {
fn render(draw: bool) -> Vec<u8> {
let mut svg = filled_square_svg();
svg.draw = draw;
let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).unwrap();
svg.paint_content(
surface.canvas(),
&test_layout(),
&AnimatedProperties::default(),
&test_ctx(),
);
let snapshot = surface.image_snapshot();
let info = skia_safe::ImageInfo::new(
(W, H),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; (W * H * 4) as usize];
snapshot.read_pixels(
&info,
&mut buf,
(W * 4) as usize,
skia_safe::IPoint::new(0, 0),
skia_safe::image::CachingHint::Disallow,
);
buf
}

assert_eq!(
render(true),
render(false),
"with draw_progress at rest the draw branch short-circuits to the finished mark, \
so the flag changes nothing — which is why validate now refuses it"
);
}
}
28 changes: 25 additions & 3 deletions crates/rustmotion/skills/rules/draw-progress-stroke.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
# `draw_progress` : rien à 0, et un trait qui ne change pas d'apparence en finissant

`draw_progress` (via l'animation `keyframes` sur la propriété du même nom, ou
`draw: true` sur `svg`) révèle un trait progressivement. Deux pièges à
connaître sur `line` et `svg` (`reveal: "stroke"`, celui par défaut).
`draw_progress` révèle un trait progressivement. On le pilote par un preset
`draw_in`/`stroke_reveal`, ou par des `keyframes` sur la propriété du même nom.
Trois pièges à connaître sur `line` et `svg` (`reveal: "stroke"`, celui par
défaut).

## `svg` : `draw: true` n'est pas un pilote

`draw: true` force le **chemin de rendu** « draw-on ». Il ne fait pas avancer
`draw_progress`. Sans pilote, la propriété reste à sa valeur au repos, le
peintre prend la branche « fini » (`progress >= 1.0`, qui délègue simplement à
resvg) et la marque se rend **exactement comme avec `draw: false`** — vérifié
octet pour octet sur deux PNG.

Le validateur refuse donc `draw: true` sans pilote, plutôt que de laisser le
drapeau avoir l'air de faire quelque chose :

```
draw: true but nothing animates draw_progress — the mark renders finished,
pixel-identical to draw: false. Add a 'draw_in' or 'stroke_reveal' preset,
or keyframes on 'draw_progress'.
```

En pratique on n'a d'ailleurs pas besoin de `draw: true` : un preset `draw_in`
suffit à lui seul, puisque le peintre bascule dès que `draw_progress` est dans
`[0, 1[`.

## `line` : `draw_progress: 0` ne doit rien peindre

Expand Down
116 changes: 116 additions & 0 deletions crates/rustmotion/src/cli/commands/validate_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ fn validate_children(
errors.push(format!("{}.src: file not found '{}'", p, src));
}
}
if svg.draw && !drives_draw_progress(&svg.style) {
errors.push(format!(
"{}: draw: true but nothing animates draw_progress — the mark renders \
finished, pixel-identical to draw: false. Add a 'draw_in' or \
'stroke_reveal' preset, or keyframes on 'draw_progress'.",
p
));
}
}
Component::Icon(icon) => {
if let Some((prefix, name)) = icon.icon.split_once(':') {
Expand Down Expand Up @@ -238,6 +246,18 @@ fn validate_children(
}
}

fn drives_draw_progress(style: &CssStyle) -> bool {
style.animation.iter().any(|effect| match effect {
AnimationEffect::DrawIn(_) | AnimationEffect::StrokeReveal(_) => true,
AnimationEffect::Keyframes(k) => k
.keyframes
.iter()
.any(|anim| anim.property == "draw_progress" || anim.property == "draw_start"),
AnimationEffect::Wiggle(w) => w.property == "draw_progress" || w.property == "draw_start",
_ => false,
})
}

fn check_style_colors(style: &CssStyle, path: &str, errors: &mut Vec<String>) {
if let Some(c) = &style.color {
check_color(c, "color", path, errors);
Expand Down Expand Up @@ -1023,6 +1043,102 @@ mod style_warning_tests {
}
}

#[cfg(test)]
mod svg_draw_driver_tests {
use super::*;

const MARK: &str = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
<circle cx='50' cy='50' r='40' fill='#FF3366'/></svg>";

fn errors_for(style: serde_json::Value) -> Vec<String> {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "svg",
"data": MARK,
"draw": true,
"style": style
}))
.unwrap();
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
errors
}

#[test]
fn draw_with_no_driver_is_rejected_rather_than_rendering_the_finished_mark() {
let errors = errors_for(serde_json::json!({ "width": 200, "height": 200 }));
assert!(
errors.iter().any(|e| e.contains("draw_progress")),
"an undriven draw is pixel-identical to draw: false, so it must be named: {errors:?}"
);
}

#[test]
fn the_draw_in_preset_is_a_driver() {
let errors = errors_for(serde_json::json!({
"width": 200, "height": 200,
"animation": [{ "name": "draw_in", "delay": 0.1, "duration": 1.0 }]
}));
assert!(
errors.is_empty(),
"draw_in drives draw_progress: {errors:?}"
);
}

#[test]
fn the_stroke_reveal_preset_is_a_driver() {
let errors = errors_for(serde_json::json!({
"width": 200, "height": 200,
"animation": [{ "name": "stroke_reveal", "duration": 1.0 }]
}));
assert!(
errors.is_empty(),
"stroke_reveal drives draw_progress too: {errors:?}"
);
}

#[test]
fn keyframes_on_draw_progress_are_a_driver() {
let errors = errors_for(serde_json::json!({
"width": 200, "height": 200,
"animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [{
"property": "draw_progress",
"keyframes": [{ "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }]
}]}]
}));
assert!(
errors.is_empty(),
"hand-written keyframes count as a driver: {errors:?}"
);
}

#[test]
fn an_animation_on_some_other_property_is_not_a_driver() {
let errors = errors_for(serde_json::json!({
"width": 200, "height": 200,
"animation": [{ "name": "fade_in", "duration": 0.5 }]
}));
assert!(
errors.iter().any(|e| e.contains("draw_progress")),
"fade_in animates opacity, which leaves draw_progress at rest: {errors:?}"
);
}

#[test]
fn draw_false_never_asks_for_a_driver() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "svg",
"data": MARK,
"style": { "width": 200, "height": 200 }
}))
.unwrap();
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
assert!(errors.is_empty(), "a plain svg is not affected: {errors:?}");
}
}

#[cfg(test)]
mod color_validation_tests {
use super::*;
Expand Down
Loading