Skip to main content

rustmotion_components/
arrow.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, Path, PathBuilder, PathMeasure, Point};
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/// Curved arrow component with optional bezier control points and oriented arrowhead.
13#[derive(Debug, Serialize, Deserialize, JsonSchema)]
14pub struct Arrow {
15    #[serde(default)]
16    pub x1: f32,
17    #[serde(default)]
18    pub y1: f32,
19    pub x2: f32,
20    pub y2: f32,
21    /// Bezier control point (for quadratic curve). Mutually exclusive with cp1/cp2.
22    #[serde(default)]
23    pub cp: Option<ControlPoint>,
24    /// First bezier control point (for cubic curve).
25    #[serde(default)]
26    pub cp1: Option<ControlPoint>,
27    /// Second bezier control point (for cubic curve).
28    #[serde(default)]
29    pub cp2: Option<ControlPoint>,
30    /// Curvature intensity for auto-generated control point (-1.0 to 1.0).
31    /// Positive = curve upward, negative = curve downward.
32    /// Only used when no explicit cp/cp1/cp2 is provided.
33    #[serde(default)]
34    pub curve: Option<f32>,
35    /// Stroke width.
36    #[serde(default = "default_arrow_width")]
37    pub width: f32,
38    /// Stroke color.
39    #[serde(default = "default_arrow_color")]
40    pub color: String,
41    /// Show arrowhead at end (default: true).
42    #[serde(default = "default_true")]
43    pub arrow_end: bool,
44    /// Show arrowhead at start.
45    #[serde(default)]
46    pub arrow_start: bool,
47    /// Size of the arrowhead (default: 12.0).
48    #[serde(default = "default_arrow_size")]
49    pub arrow_size: f32,
50    #[serde(default)]
51    pub dashed: Option<Vec<f32>>,
52    #[serde(flatten)]
53    pub timing: TimingConfig,
54    #[serde(default)]
55    pub style: CssStyle,
56    #[serde(default)]
57    pub timeline: Vec<TimelineStep>,
58    #[serde(default)]
59    pub stagger: Option<f32>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct ControlPoint {
64    pub x: f32,
65    pub y: f32,
66}
67
68fn default_arrow_width() -> f32 {
69    3.0
70}
71fn default_arrow_color() -> String {
72    "#FFFFFF".to_string()
73}
74fn default_arrow_size() -> f32 {
75    12.0
76}
77fn default_true() -> bool {
78    true
79}
80
81rustmotion_core::impl_traits!(Arrow {
82    Animatable => animation,
83    Timed => timing,
84    Styled => style,
85});
86
87impl Arrow {
88    fn build_path(&self) -> Path {
89        let mut path = PathBuilder::new();
90        path.move_to((self.x1, self.y1));
91
92        if let (Some(cp1), Some(cp2)) = (&self.cp1, &self.cp2) {
93            path.cubic_to((cp1.x, cp1.y), (cp2.x, cp2.y), (self.x2, self.y2));
94        } else if let Some(cp) = &self.cp {
95            path.quad_to((cp.x, cp.y), (self.x2, self.y2));
96        } else if let Some(curve) = self.curve {
97            let mid_x = (self.x1 + self.x2) / 2.0;
98            let mid_y = (self.y1 + self.y2) / 2.0;
99            let dx = self.x2 - self.x1;
100            let dy = self.y2 - self.y1;
101            let len = (dx * dx + dy * dy).sqrt();
102            let perp_x = -dy / len * curve * len * 0.3;
103            let perp_y = dx / len * curve * len * 0.3;
104            path.quad_to((mid_x + perp_x, mid_y + perp_y), (self.x2, self.y2));
105        } else {
106            path.line_to((self.x2, self.y2));
107        }
108
109        path.detach()
110    }
111
112    fn draw_arrowhead(
113        canvas: &Canvas,
114        path: &Path,
115        at_end: bool,
116        size: f32,
117        paint: &skia_safe::Paint,
118    ) {
119        let mut measure = PathMeasure::new(path, false, None);
120        let total_len = measure.length();
121        if total_len < 1.0 {
122            return;
123        }
124
125        let (pos, tangent) = if at_end {
126            let dist = total_len - 0.1;
127            match measure.pos_tan(dist) {
128                Some((p, t)) => (p, t),
129                None => return,
130            }
131        } else {
132            let dist = 0.1;
133            match measure.pos_tan(dist) {
134                Some((p, t)) => (p, Point::new(-t.x, -t.y)),
135                None => return,
136            }
137        };
138
139        let angle = tangent.y.atan2(tangent.x);
140        let half_angle = std::f32::consts::PI / 6.0;
141
142        let mut arrow_path = PathBuilder::new();
143        arrow_path.move_to(pos);
144        arrow_path.line_to((
145            pos.x - size * (angle - half_angle).cos(),
146            pos.y - size * (angle - half_angle).sin(),
147        ));
148        arrow_path.move_to(pos);
149        arrow_path.line_to((
150            pos.x - size * (angle + half_angle).cos(),
151            pos.y - size * (angle + half_angle).sin(),
152        ));
153
154        let mut arrow_paint = paint.clone();
155        arrow_paint.set_path_effect(None);
156        arrow_paint.set_stroke_cap(skia_safe::PaintCap::Round);
157        canvas.draw_path(&arrow_path.detach(), &arrow_paint);
158    }
159
160    fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) {
161        let path = self.build_path();
162
163        let mut paint = paint_from_hex(&self.color);
164        paint.set_style(PaintStyle::Stroke);
165        paint.set_stroke_width(self.width);
166        paint.set_anti_alias(true);
167        paint.set_stroke_cap(skia_safe::PaintCap::Round);
168        paint.set_stroke_join(skia_safe::PaintJoin::Round);
169
170        if let Some(ref intervals) = self.dashed {
171            if intervals.len() >= 2 {
172                if let Some(dash) = skia_safe::PathEffect::dash(intervals, 0.0) {
173                    paint.set_path_effect(dash);
174                }
175            }
176        }
177
178        let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0;
179        let draw_start = props.draw_start.max(0.0);
180        let draw_offset = props.draw_offset;
181        let trimming = draw_start > 0.0 || draw_offset.abs() > 0.0005;
182        let trim_start = (draw_start + draw_offset).clamp(0.0, 1.0);
183        let trim_end = if drawing {
184            (props.draw_progress.clamp(0.0, 1.0) + draw_offset).clamp(0.0, 1.0)
185        } else {
186            (1.0 + draw_offset).clamp(0.0, 1.0)
187        };
188
189        if trim_end <= trim_start && (drawing || trimming) {
190            return;
191        }
192
193        if drawing || trimming {
194            let trimmed =
195                rustmotion_core::engine::renderer::trim_path_between(&path, trim_start, trim_end);
196            canvas.draw_path(&trimmed, &paint);
197        } else {
198            canvas.draw_path(&path, &paint);
199        }
200
201        let show_arrows = props.draw_progress < 0.0 || props.draw_progress >= 0.95;
202        if show_arrows {
203            if self.arrow_end {
204                Self::draw_arrowhead(canvas, &path, true, self.arrow_size, &paint);
205            }
206            if self.arrow_start {
207                Self::draw_arrowhead(canvas, &path, false, self.arrow_size, &paint);
208            }
209        }
210    }
211}
212
213impl Painter for Arrow {
214    fn paint_content(
215        &self,
216        canvas: &Canvas,
217        _layout: &BoxLayout,
218        props: &AnimatedProperties,
219        _ctx: &PaintCtx,
220    ) {
221        self.paint(canvas, props);
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use rustmotion_core::engine::layout_pass::Insets;
229
230    const W: i32 = 100;
231    const H: i32 = 100;
232
233    fn test_layout() -> BoxLayout {
234        BoxLayout {
235            x: 0.0,
236            y: 0.0,
237            width: W as f32,
238            height: H as f32,
239            border: Insets::default(),
240            padding: Insets::default(),
241        }
242    }
243
244    fn test_ctx() -> PaintCtx {
245        PaintCtx {
246            time: 0.0,
247            scenario_time: 0.0,
248            scene_duration: 1.0,
249            frame_index: 0,
250            fps: 30,
251            video_width: W as u32,
252            video_height: H as u32,
253            stagger_offset: 0.0,
254        }
255    }
256
257    fn straight_arrow() -> Arrow {
258        Arrow {
259            x1: 10.0,
260            y1: 50.0,
261            x2: 90.0,
262            y2: 50.0,
263            cp: None,
264            cp1: None,
265            cp2: None,
266            curve: None,
267            width: 12.0,
268            color: "#FFFFFF".to_string(),
269            arrow_end: true,
270            arrow_start: false,
271            arrow_size: default_arrow_size(),
272            dashed: None,
273            timing: Default::default(),
274            style: Default::default(),
275            timeline: Vec::new(),
276            stagger: None,
277        }
278    }
279
280    fn render_alpha(arrow: &Arrow, props: &AnimatedProperties) -> Vec<u8> {
281        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
282        arrow.paint_content(surface.canvas(), &test_layout(), props, &test_ctx());
283        let snapshot = surface.image_snapshot();
284        let info = skia_safe::ImageInfo::new(
285            (W, H),
286            skia_safe::ColorType::RGBA8888,
287            skia_safe::AlphaType::Unpremul,
288            None,
289        );
290        let mut buf = vec![0u8; (W * H * 4) as usize];
291        let ok = snapshot.read_pixels(
292            &info,
293            &mut buf,
294            (W * 4) as usize,
295            skia_safe::IPoint::new(0, 0),
296            skia_safe::image::CachingHint::Disallow,
297        );
298        assert!(ok, "pixel read should succeed");
299        buf
300    }
301
302    fn lit_pixel_count(buf: &[u8]) -> usize {
303        (0..(W * H) as usize)
304            .filter(|&i| buf[i * 4 + 3] > 10)
305            .count()
306    }
307
308    fn alpha_at(buf: &[u8], x: i32, y: i32) -> u8 {
309        buf[((y * W + x) * 4 + 3) as usize]
310    }
311
312    #[test]
313    fn draw_progress_zero_paints_nothing_not_even_a_dot() {
314        let arrow = straight_arrow();
315        let props = AnimatedProperties {
316            draw_progress: 0.0,
317            ..AnimatedProperties::default()
318        };
319        let lit = lit_pixel_count(&render_alpha(&arrow, &props));
320        assert_eq!(
321            lit, 0,
322            "at draw_progress=0 a round-capped zero-length dash must not paint a solid dot, \
323             got {lit} lit pixels"
324        );
325    }
326
327    #[test]
328    fn draw_progress_partial_paints_a_partial_stroke() {
329        let arrow = straight_arrow();
330        let props = AnimatedProperties {
331            draw_progress: 0.5,
332            ..AnimatedProperties::default()
333        };
334        let lit = lit_pixel_count(&render_alpha(&arrow, &props));
335        assert!(
336            lit > 0,
337            "at draw_progress=0.5 the arrow must paint a partial stroke, got {lit} lit pixels"
338        );
339    }
340
341    #[test]
342    fn draw_start_erases_the_head_of_an_already_finished_arrow() {
343        let arrow = straight_arrow();
344        let props = AnimatedProperties {
345            draw_start: 0.5,
346            ..AnimatedProperties::default()
347        };
348        let buf = render_alpha(&arrow, &props);
349        assert_eq!(
350            alpha_at(&buf, 20, 50),
351            0,
352            "before a keyframe-driven draw_start=0.5 on a finished arrow must be erased"
353        );
354        assert!(
355            alpha_at(&buf, 60, 50) > 40,
356            "after draw_start=0.5 must stay painted"
357        );
358    }
359
360    #[test]
361    fn draw_offset_marches_the_drawn_window_along_the_path() {
362        let arrow = straight_arrow();
363        let no_offset = render_alpha(
364            &arrow,
365            &AnimatedProperties {
366                draw_progress: 0.3,
367                ..AnimatedProperties::default()
368            },
369        );
370        let offset = render_alpha(
371            &arrow,
372            &AnimatedProperties {
373                draw_progress: 0.3,
374                draw_offset: 0.5,
375                ..AnimatedProperties::default()
376            },
377        );
378        assert!(
379            alpha_at(&no_offset, 20, 50) > 40,
380            "with no offset the window starts at the path's own beginning"
381        );
382        assert_eq!(
383            alpha_at(&offset, 20, 50),
384            0,
385            "draw_offset=0.5 must march the window forward, leaving the path's start empty"
386        );
387        assert!(
388            alpha_at(&offset, 70, 50) > 40,
389            "and paint further along the path instead"
390        );
391    }
392}