Skip to main content

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