1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle};
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 Line {
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 = "default_line_width")]
22 pub width: f32,
23 #[serde(default = "default_line_color")]
24 pub color: String,
25 #[serde(default)]
26 pub dashed: Option<Vec<f32>>,
27 #[serde(flatten)]
28 pub timing: TimingConfig,
29 #[serde(default)]
30 pub style: CssStyle,
31 #[serde(default)]
32 pub timeline: Vec<TimelineStep>,
33 #[serde(default)]
34 pub stagger: Option<f32>,
35}
36
37fn default_line_width() -> f32 {
38 2.0
39}
40
41fn default_line_color() -> String {
42 "#FFFFFF".to_string()
43}
44
45rustmotion_core::impl_traits!(Line {
46 Animatable => animation,
47 Timed => timing,
48 Styled => style,
49});
50
51impl Line {
52 fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) {
53 let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0;
54 let trim_start = props.draw_start.clamp(0.0, 1.0);
55 let trimming = props.draw_start > 0.0;
56 let trim_end = if drawing {
57 props.draw_progress.clamp(0.0, 1.0)
58 } else {
59 1.0
60 };
61 if trim_end <= trim_start && (drawing || trimming) {
62 return;
63 }
64
65 let mut paint = paint_from_hex(&self.color);
66 paint.set_style(PaintStyle::Stroke);
67 paint.set_stroke_width(self.width);
68 paint.set_anti_alias(true);
69 paint.set_stroke_cap(skia_safe::PaintCap::Round);
70
71 if let Some(ref intervals) = self.dashed {
72 if intervals.len() >= 2 {
73 if let Some(dash) = skia_safe::PathEffect::dash(intervals, 0.0) {
74 paint.set_path_effect(dash);
75 }
76 }
77 }
78
79 if drawing || trimming {
80 let mut builder = skia_safe::PathBuilder::new();
81 builder.move_to((self.x1, self.y1));
82 builder.line_to((self.x2, self.y2));
83 let trimmed = rustmotion_core::engine::renderer::trim_path_between(
84 &builder.detach(),
85 trim_start,
86 trim_end,
87 );
88 canvas.draw_path(&trimmed, &paint);
89 return;
90 }
91
92 canvas.draw_line((self.x1, self.y1), (self.x2, self.y2), &paint);
93 }
94}
95
96impl Painter for Line {
97 fn paint_content(
98 &self,
99 canvas: &Canvas,
100 _layout: &BoxLayout,
101 props: &AnimatedProperties,
102 _ctx: &PaintCtx,
103 ) {
104 self.paint(canvas, props);
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use rustmotion_core::engine::layout_pass::Insets;
112
113 const W: i32 = 100;
114 const H: i32 = 100;
115
116 fn test_layout() -> BoxLayout {
117 BoxLayout {
118 x: 0.0,
119 y: 0.0,
120 width: W as f32,
121 height: H as f32,
122 border: Insets::default(),
123 padding: Insets::default(),
124 }
125 }
126
127 fn test_ctx() -> PaintCtx {
128 PaintCtx {
129 time: 0.0,
130 scenario_time: 0.0,
131 scene_duration: 1.0,
132 frame_index: 0,
133 fps: 30,
134 video_width: 1920,
135 video_height: 1080,
136 stagger_offset: 0.0,
137 }
138 }
139
140 fn drawable_line() -> Line {
141 Line {
142 x1: 10.0,
143 y1: 50.0,
144 x2: 90.0,
145 y2: 50.0,
146 width: 12.0,
147 color: "#000000".to_string(),
148 dashed: None,
149 timing: Default::default(),
150 style: Default::default(),
151 timeline: Vec::new(),
152 stagger: None,
153 }
154 }
155
156 fn lit_pixel_count(line: &Line, draw_progress: f32) -> usize {
157 let layout = test_layout();
158 let props = AnimatedProperties {
159 draw_progress,
160 ..Default::default()
161 };
162 let ctx = test_ctx();
163
164 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
165 {
166 let canvas = surface.canvas();
167 line.paint_content(canvas, &layout, &props, &ctx);
168 }
169
170 let snapshot = surface.image_snapshot();
171 let info = skia_safe::ImageInfo::new(
172 (W, H),
173 skia_safe::ColorType::RGBA8888,
174 skia_safe::AlphaType::Unpremul,
175 None,
176 );
177 let mut buf = vec![0u8; (W * H * 4) as usize];
178 let ok = snapshot.read_pixels(
179 &info,
180 &mut buf,
181 (W * 4) as usize,
182 skia_safe::IPoint::new(0, 0),
183 skia_safe::image::CachingHint::Disallow,
184 );
185 assert!(ok, "pixel read should succeed");
186
187 (0..(W * H) as usize)
188 .filter(|&i| buf[i * 4 + 3] > 10)
189 .count()
190 }
191
192 #[test]
193 fn draw_progress_zero_paints_nothing() {
194 let line = drawable_line();
195 let lit = lit_pixel_count(&line, 0.0);
196 assert_eq!(
197 lit, 0,
198 "at draw_progress=0 the line must not paint a zero-length dash as a dot, got {lit} lit pixels"
199 );
200 }
201
202 #[test]
203 fn draw_progress_partial_paints_a_partial_stroke() {
204 let line = drawable_line();
205 let lit = lit_pixel_count(&line, 0.5);
206 assert!(
207 lit > 0,
208 "at draw_progress=0.5 the line must paint a partial stroke, got {lit} lit pixels"
209 );
210 }
211}
212#[cfg(test)]
213mod draw_start_tests {
214 use super::*;
215
216 fn line_at(draw_start: f32, draw_progress: f32) -> Vec<u8> {
217 let line = Line {
218 x1: 10.0,
219 y1: 50.0,
220 x2: 90.0,
221 y2: 50.0,
222 color: "#FFFFFF".into(),
223 width: 8.0,
224 dashed: None,
225 timing: Default::default(),
226 style: Default::default(),
227 timeline: Vec::new(),
228 stagger: None,
229 };
230 let mut surface = skia_safe::surfaces::raster_n32_premul((100, 100)).unwrap();
231 let props = AnimatedProperties {
232 draw_start,
233 draw_progress,
234 ..Default::default()
235 };
236 line.paint(surface.canvas(), &props);
237
238 let info = skia_safe::ImageInfo::new(
239 (100, 100),
240 skia_safe::ColorType::RGBA8888,
241 skia_safe::AlphaType::Unpremul,
242 None,
243 );
244 let mut buf = vec![0u8; 100 * 100 * 4];
245 surface.read_pixels(&info, &mut buf, 100 * 4, (0, 0));
246 buf
247 }
248
249 fn lit_in_columns(buf: &[u8], x0: usize, x1: usize) -> usize {
250 (0..100)
251 .flat_map(|y| (x0..x1).map(move |x| (x, y)))
252 .filter(|&(x, y)| buf[(y * 100 + x) * 4 + 3] > 40)
253 .count()
254 }
255
256 #[test]
257 fn draw_start_leaves_the_first_half_empty() {
258 let buf = line_at(0.5, -1.0);
259 assert_eq!(
260 lit_in_columns(&buf, 0, 44),
261 0,
262 "draw_start 0.5 must erase the first half of the stroke. The probe stops at 44 \
263 rather than 50 because the round cap extends half the 8px stroke back past the \
264 cut, which is the cap doing its job and not the trim failing"
265 );
266 assert!(
267 lit_in_columns(&buf, 52, 100) > 100,
268 "and leave the second half painted"
269 );
270 }
271
272 #[test]
273 fn draw_start_and_draw_progress_bound_a_window() {
274 let buf = line_at(0.3, 0.7);
275 assert_eq!(lit_in_columns(&buf, 0, 30), 0, "before the window: empty");
276 assert_eq!(lit_in_columns(&buf, 78, 100), 0, "after the window: empty");
277 assert!(
278 lit_in_columns(&buf, 40, 60) > 80,
279 "inside the window: painted"
280 );
281 }
282
283 #[test]
284 fn no_draw_start_renders_exactly_as_before() {
285 assert_eq!(
286 line_at(-1.0, -1.0),
287 line_at(0.0, -1.0),
288 "draw_start absent and draw_start 0 must both paint the whole line, byte for byte"
289 );
290 }
291}