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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct Connector {
16 pub from: ConnectorPoint,
18 pub to: ConnectorPoint,
20 #[serde(default)]
22 pub routing: RoutingMode,
23 #[serde(default = "default_curvature")]
25 pub curvature: f32,
26 #[serde(default = "default_connector_width")]
28 pub width: f32,
29 #[serde(default = "default_connector_color")]
31 pub color: String,
32 #[serde(default = "default_true")]
34 pub arrow_end: bool,
35 #[serde(default)]
37 pub arrow_start: bool,
38 #[serde(default = "default_arrow_size")]
40 pub arrow_size: f32,
41 #[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 #[default]
65 Straight,
66 Curved,
68 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}