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                // Perpendicular offset for curvature
113                let perp_x = -dy / len * self.curvature * len * 0.3;
114                let perp_y = dx / len * self.curvature * len * 0.3;
115                path.quad_to((mid_x + perp_x, mid_y + perp_y), (x2, y2));
116            }
117            RoutingMode::Elbow => {
118                path.move_to((x1, y1));
119                // Route: horizontal first, then vertical
120                let mid_x = (x1 + x2) / 2.0;
121                path.line_to((mid_x, y1));
122                path.line_to((mid_x, y2));
123                path.line_to((x2, y2));
124            }
125        }
126
127        path.detach()
128    }
129
130    fn draw_arrowhead(
131        canvas: &Canvas,
132        path: &Path,
133        at_end: bool,
134        size: f32,
135        paint: &skia_safe::Paint,
136    ) {
137        let mut measure = PathMeasure::new(path, false, None);
138        let total_len = measure.length();
139        if total_len < 1.0 {
140            return;
141        }
142
143        let (pos, tangent) = if at_end {
144            match measure.pos_tan(total_len - 0.1) {
145                Some((p, t)) => (p, t),
146                None => return,
147            }
148        } else {
149            match measure.pos_tan(0.1) {
150                Some((p, t)) => (p, Point::new(-t.x, -t.y)),
151                None => return,
152            }
153        };
154
155        let angle = tangent.y.atan2(tangent.x);
156        let half_angle = std::f32::consts::PI / 6.0;
157
158        let mut arrow_path = PathBuilder::new();
159        arrow_path.move_to(pos);
160        arrow_path.line_to((
161            pos.x - size * (angle - half_angle).cos(),
162            pos.y - size * (angle - half_angle).sin(),
163        ));
164        arrow_path.move_to(pos);
165        arrow_path.line_to((
166            pos.x - size * (angle + half_angle).cos(),
167            pos.y - size * (angle + half_angle).sin(),
168        ));
169
170        let mut arrow_paint = paint.clone();
171        arrow_paint.set_path_effect(None);
172        arrow_paint.set_stroke_cap(skia_safe::PaintCap::Round);
173        canvas.draw_path(&arrow_path.detach(), &arrow_paint);
174    }
175
176    fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) {
177        let path = self.build_path();
178
179        let mut paint = paint_from_hex(&self.color);
180        paint.set_style(PaintStyle::Stroke);
181        paint.set_stroke_width(self.width);
182        paint.set_anti_alias(true);
183        paint.set_stroke_cap(skia_safe::PaintCap::Round);
184        paint.set_stroke_join(skia_safe::PaintJoin::Round);
185
186        if let Some(ref intervals) = self.dashed {
187            if intervals.len() >= 2 {
188                if let Some(dash) = skia_safe::PathEffect::dash(intervals, 0.0) {
189                    paint.set_path_effect(dash);
190                }
191            }
192        }
193
194        if props.draw_progress >= 0.0 && props.draw_progress < 1.0 {
195            let mut measure = PathMeasure::new(&path, false, None);
196            let total_len = measure.length();
197            if total_len > 0.0 {
198                let draw_len = total_len * props.draw_progress.clamp(0.0, 1.0);
199                let intervals = [draw_len, total_len - draw_len + 0.01];
200                if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) {
201                    paint.set_path_effect(dash);
202                }
203            }
204        }
205
206        canvas.draw_path(&path, &paint);
207
208        let show_arrows = props.draw_progress < 0.0 || props.draw_progress >= 0.95;
209        if show_arrows {
210            if self.arrow_end {
211                Self::draw_arrowhead(canvas, &path, true, self.arrow_size, &paint);
212            }
213            if self.arrow_start {
214                Self::draw_arrowhead(canvas, &path, false, self.arrow_size, &paint);
215            }
216        }
217    }
218}
219
220impl Painter for Connector {
221    fn paint_content(
222        &self,
223        canvas: &Canvas,
224        _layout: &BoxLayout,
225        props: &AnimatedProperties,
226        _ctx: &PaintCtx,
227    ) {
228        self.paint(canvas, props);
229    }
230}