Skip to main content

rustmotion_components/
line.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::paint_from_hex;
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12/// A line component that draws a line from (x1, y1) to (x2, y2).
13#[derive(Debug, Serialize, Deserialize, JsonSchema)]
14pub struct Line {
15    #[serde(default)]
16    pub x1: f32,
17    #[serde(default)]
18    pub y1: f32,
19    pub x2: f32,
20    pub y2: f32,
21    #[serde(default = "default_line_width")]
22    pub width: f32,
23    #[serde(default = "default_line_color")]
24    pub color: String,
25    #[serde(default)]
26    pub dashed: Option<Vec<f32>>,
27    #[serde(flatten)]
28    pub timing: TimingConfig,
29    #[serde(default)]
30    pub style: CssStyle,
31    #[serde(default)]
32    pub timeline: Vec<TimelineStep>,
33    #[serde(default)]
34    pub stagger: Option<f32>,
35}
36
37fn default_line_width() -> f32 {
38    2.0
39}
40
41fn default_line_color() -> String {
42    "#FFFFFF".to_string()
43}
44
45rustmotion_core::impl_traits!(Line {
46    Animatable => animation,
47    Timed => timing,
48    Styled => style,
49});
50
51impl Line {
52    fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) {
53        let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0;
54        let trim_start = props.draw_start.clamp(0.0, 1.0);
55        let trimming = props.draw_start > 0.0;
56        let trim_end = if drawing {
57            props.draw_progress.clamp(0.0, 1.0)
58        } else {
59            1.0
60        };
61        if trim_end <= trim_start && (drawing || trimming) {
62            return;
63        }
64
65        let mut paint = paint_from_hex(&self.color);
66        paint.set_style(PaintStyle::Stroke);
67        paint.set_stroke_width(self.width);
68        paint.set_anti_alias(true);
69        paint.set_stroke_cap(skia_safe::PaintCap::Round);
70
71        if let Some(ref intervals) = self.dashed {
72            if intervals.len() >= 2 {
73                if let Some(dash) = skia_safe::PathEffect::dash(intervals, 0.0) {
74                    paint.set_path_effect(dash);
75                }
76            }
77        }
78
79        if drawing || trimming {
80            let mut builder = skia_safe::PathBuilder::new();
81            builder.move_to((self.x1, self.y1));
82            builder.line_to((self.x2, self.y2));
83            let trimmed = rustmotion_core::engine::renderer::trim_path_between(
84                &builder.detach(),
85                trim_start,
86                trim_end,
87            );
88            canvas.draw_path(&trimmed, &paint);
89            return;
90        }
91
92        canvas.draw_line((self.x1, self.y1), (self.x2, self.y2), &paint);
93    }
94}
95
96impl Painter for Line {
97    fn paint_content(
98        &self,
99        canvas: &Canvas,
100        _layout: &BoxLayout,
101        props: &AnimatedProperties,
102        _ctx: &PaintCtx,
103    ) {
104        self.paint(canvas, props);
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use rustmotion_core::engine::layout_pass::Insets;
112
113    const W: i32 = 100;
114    const H: i32 = 100;
115
116    fn test_layout() -> BoxLayout {
117        BoxLayout {
118            x: 0.0,
119            y: 0.0,
120            width: W as f32,
121            height: H as f32,
122            border: Insets::default(),
123            padding: Insets::default(),
124        }
125    }
126
127    fn test_ctx() -> PaintCtx {
128        PaintCtx {
129            time: 0.0,
130            scenario_time: 0.0,
131            scene_duration: 1.0,
132            frame_index: 0,
133            fps: 30,
134            video_width: 1920,
135            video_height: 1080,
136            stagger_offset: 0.0,
137        }
138    }
139
140    fn drawable_line() -> Line {
141        Line {
142            x1: 10.0,
143            y1: 50.0,
144            x2: 90.0,
145            y2: 50.0,
146            width: 12.0,
147            color: "#000000".to_string(),
148            dashed: None,
149            timing: Default::default(),
150            style: Default::default(),
151            timeline: Vec::new(),
152            stagger: None,
153        }
154    }
155
156    fn lit_pixel_count(line: &Line, draw_progress: f32) -> usize {
157        let layout = test_layout();
158        let props = AnimatedProperties {
159            draw_progress,
160            ..Default::default()
161        };
162        let ctx = test_ctx();
163
164        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
165        {
166            let canvas = surface.canvas();
167            line.paint_content(canvas, &layout, &props, &ctx);
168        }
169
170        let snapshot = surface.image_snapshot();
171        let info = skia_safe::ImageInfo::new(
172            (W, H),
173            skia_safe::ColorType::RGBA8888,
174            skia_safe::AlphaType::Unpremul,
175            None,
176        );
177        let mut buf = vec![0u8; (W * H * 4) as usize];
178        let ok = snapshot.read_pixels(
179            &info,
180            &mut buf,
181            (W * 4) as usize,
182            skia_safe::IPoint::new(0, 0),
183            skia_safe::image::CachingHint::Disallow,
184        );
185        assert!(ok, "pixel read should succeed");
186
187        (0..(W * H) as usize)
188            .filter(|&i| buf[i * 4 + 3] > 10)
189            .count()
190    }
191
192    #[test]
193    fn draw_progress_zero_paints_nothing() {
194        let line = drawable_line();
195        let lit = lit_pixel_count(&line, 0.0);
196        assert_eq!(
197            lit, 0,
198            "at draw_progress=0 the line must not paint a zero-length dash as a dot, got {lit} lit pixels"
199        );
200    }
201
202    #[test]
203    fn draw_progress_partial_paints_a_partial_stroke() {
204        let line = drawable_line();
205        let lit = lit_pixel_count(&line, 0.5);
206        assert!(
207            lit > 0,
208            "at draw_progress=0.5 the line must paint a partial stroke, got {lit} lit pixels"
209        );
210    }
211}
212#[cfg(test)]
213mod draw_start_tests {
214    use super::*;
215
216    fn line_at(draw_start: f32, draw_progress: f32) -> Vec<u8> {
217        let line = Line {
218            x1: 10.0,
219            y1: 50.0,
220            x2: 90.0,
221            y2: 50.0,
222            color: "#FFFFFF".into(),
223            width: 8.0,
224            dashed: None,
225            timing: Default::default(),
226            style: Default::default(),
227            timeline: Vec::new(),
228            stagger: None,
229        };
230        let mut surface = skia_safe::surfaces::raster_n32_premul((100, 100)).unwrap();
231        let props = AnimatedProperties {
232            draw_start,
233            draw_progress,
234            ..Default::default()
235        };
236        line.paint(surface.canvas(), &props);
237
238        let info = skia_safe::ImageInfo::new(
239            (100, 100),
240            skia_safe::ColorType::RGBA8888,
241            skia_safe::AlphaType::Unpremul,
242            None,
243        );
244        let mut buf = vec![0u8; 100 * 100 * 4];
245        surface.read_pixels(&info, &mut buf, 100 * 4, (0, 0));
246        buf
247    }
248
249    fn lit_in_columns(buf: &[u8], x0: usize, x1: usize) -> usize {
250        (0..100)
251            .flat_map(|y| (x0..x1).map(move |x| (x, y)))
252            .filter(|&(x, y)| buf[(y * 100 + x) * 4 + 3] > 40)
253            .count()
254    }
255
256    #[test]
257    fn draw_start_leaves_the_first_half_empty() {
258        let buf = line_at(0.5, -1.0);
259        assert_eq!(
260            lit_in_columns(&buf, 0, 44),
261            0,
262            "draw_start 0.5 must erase the first half of the stroke. The probe stops at 44 \
263             rather than 50 because the round cap extends half the 8px stroke back past the \
264             cut, which is the cap doing its job and not the trim failing"
265        );
266        assert!(
267            lit_in_columns(&buf, 52, 100) > 100,
268            "and leave the second half painted"
269        );
270    }
271
272    #[test]
273    fn draw_start_and_draw_progress_bound_a_window() {
274        let buf = line_at(0.3, 0.7);
275        assert_eq!(lit_in_columns(&buf, 0, 30), 0, "before the window: empty");
276        assert_eq!(lit_in_columns(&buf, 78, 100), 0, "after the window: empty");
277        assert!(
278            lit_in_columns(&buf, 40, 60) > 80,
279            "inside the window: painted"
280        );
281    }
282
283    #[test]
284    fn no_draw_start_renders_exactly_as_before() {
285        assert_eq!(
286            line_at(-1.0, -1.0),
287            line_at(0.0, -1.0),
288            "draw_start absent and draw_start 0 must both paint the whole line, byte for byte"
289        );
290    }
291}