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)]
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 #[serde(default)]
23 pub cp: Option<ControlPoint>,
24 #[serde(default)]
26 pub cp1: Option<ControlPoint>,
27 #[serde(default)]
29 pub cp2: Option<ControlPoint>,
30 #[serde(default)]
34 pub curve: Option<f32>,
35 #[serde(default = "default_arrow_width")]
37 pub width: f32,
38 #[serde(default = "default_arrow_color")]
40 pub color: String,
41 #[serde(default = "default_true")]
43 pub arrow_end: bool,
44 #[serde(default)]
46 pub arrow_start: bool,
47 #[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}