Skip to main content

rustmotion_components/
shape.rs

1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::{Canvas, Paint, PaintStyle, Point};
5
6use rustmotion_core::css::{CssStyle, FrameClock};
7use rustmotion_core::engine::animator::{ease, AnimatedProperties};
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10    build_shape_path, color4f_from_hex, draw_shape_path, draw_text_with_fallback, emoji_typeface,
11    interpolate_path_data, measure_text_with_fallback, paint_from_hex, subpixel_font,
12    trim_path_between, typeface_with_fallback, wrap_text_with_tracking,
13};
14use rustmotion_core::expr::{Computed, Expr, Scope};
15use rustmotion_core::schema::{
16    EasingType, Fill, FontWeight, GradientType, LineCap, LineJoin, ShapeText, ShapeType, Stroke,
17    TextAlign, TimelineStep,
18};
19use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
20
21#[derive(Debug, Serialize, Deserialize, JsonSchema)]
22pub struct Shape {
23    pub shape: ShapeType,
24    #[serde(default)]
25    pub text: Option<ShapeText>,
26    #[serde(flatten)]
27    pub timing: TimingConfig,
28    #[serde(default)]
29    pub style: CssStyle,
30    #[serde(default)]
31    pub timeline: Vec<TimelineStep>,
32    #[serde(default)]
33    pub stagger: Option<f32>,
34    #[serde(default)]
35    pub fill: Option<Fill>,
36    #[serde(default)]
37    pub stroke: Option<Stroke>,
38    /// Where the visible stroke starts, as a fraction `0..1` of the path's own
39    /// length — the counterpart to `draw_progress`, which only ever moves the
40    /// end. The visible segment is the path between `draw_start` and
41    /// `draw_progress`; a literal number or an `= expr` evaluated against
42    /// `t`/`t_abs`/`duration`/`width`/`height`/`fps`, the same scope
43    /// `dash_offset` reads. Has no effect unless `draw_progress` is also
44    /// animating (or has already finished, in which case the segment simply
45    /// erases from its tail) — matching `hold` on `iris`, this is a no-op by
46    /// itself. Applied only to `stroke`; ignored for `fill`.
47    #[serde(default)]
48    pub draw_start: Option<Computed<f32>>,
49    /// Keyframes on the shape's own path data (`d`), interpolated point by
50    /// point when every keyframe shares the same command structure. A
51    /// mismatched pair is reported on stderr and holds the earlier
52    /// keyframe's shape rather than snapping — see
53    /// `rustmotion_core::engine::renderer::interpolate_path_data`. Overrides
54    /// `shape` entirely (for both `fill` and `stroke`) while present.
55    #[serde(default)]
56    pub path_morph: Option<PathMorph>,
57}
58
59/// A path's `d` animated over time by re-drawing the interpolated outline
60/// between two authored keyframes, rather than a single frozen shape.
61#[derive(Debug, Serialize, Deserialize, JsonSchema)]
62pub struct PathMorph {
63    /// At least one keyframe; a single keyframe holds that shape statically.
64    pub keyframes: Vec<PathMorphKeyframe>,
65    /// Applied within each segment between two consecutive keyframes.
66    #[serde(default)]
67    pub easing: EasingType,
68    /// Loops back to the first keyframe once `time` passes the last one,
69    /// instead of holding the last shape forever.
70    #[serde(default)]
71    pub repeat: bool,
72    /// Only with `repeat`: alternates direction each cycle instead of
73    /// snapping back to the first keyframe.
74    #[serde(default)]
75    pub yoyo: bool,
76}
77
78/// One stop in a `path_morph`.
79#[derive(Debug, Serialize, Deserialize, JsonSchema)]
80pub struct PathMorphKeyframe {
81    /// Seconds from scene start, like every other keyframe `time` in this
82    /// schema — not a `0..1` fraction.
83    pub time: f64,
84    /// SVG path data, same grammar as `shape: { "type": "path", "data": … }`.
85    pub value: String,
86}
87
88rustmotion_core::impl_traits!(Shape {
89    Animatable => animation,
90    Timed => timing,
91    Styled => style,
92});
93
94fn frame_clock(ctx: &PaintCtx) -> FrameClock {
95    FrameClock {
96        t: ctx.time,
97        t_abs: ctx.scenario_time,
98        duration: ctx.scene_duration,
99        width: ctx.video_width as f64,
100        height: ctx.video_height as f64,
101        fps: ctx.fps as f64,
102    }
103}
104
105fn skia_line_cap(cap: LineCap) -> skia_safe::PaintCap {
106    match cap {
107        LineCap::Butt => skia_safe::PaintCap::Butt,
108        LineCap::Round => skia_safe::PaintCap::Round,
109        LineCap::Square => skia_safe::PaintCap::Square,
110    }
111}
112
113fn skia_line_join(join: LineJoin) -> skia_safe::PaintJoin {
114    match join {
115        LineJoin::Miter => skia_safe::PaintJoin::Miter,
116        LineJoin::Round => skia_safe::PaintJoin::Round,
117        LineJoin::Bevel => skia_safe::PaintJoin::Bevel,
118    }
119}
120
121fn resolve_dash_offset(offset: &Option<Computed<f32>>, scope: &dyn Scope) -> f32 {
122    match offset {
123        None => 0.0,
124        Some(Computed::Literal(v)) => *v,
125        Some(Computed::Expr(src)) => Expr::parse(src)
126            .and_then(|e| e.eval(scope))
127            .map(|v| v as f32)
128            .unwrap_or(0.0),
129    }
130}
131
132fn resolve_draw_start(value: &Option<Computed<f32>>, scope: &dyn Scope) -> f32 {
133    let raw = match value {
134        None => 0.0,
135        Some(Computed::Literal(v)) => *v,
136        Some(Computed::Expr(src)) => Expr::parse(src)
137            .and_then(|e| e.eval(scope))
138            .map(|v| v as f32)
139            .unwrap_or(0.0),
140    };
141    raw.clamp(0.0, 1.0)
142}
143
144fn resolve_path_morph(morph: &PathMorph, time: f64) -> Option<skia_safe::Path> {
145    let keyframes = &morph.keyframes;
146    let first = keyframes.first()?;
147    if keyframes.len() == 1 {
148        return skia_safe::Path::from_svg(&first.value);
149    }
150    let last = keyframes.last()?;
151    let first_time = first.time;
152    let last_time = last.time;
153    let span = (last_time - first_time).max(1e-9);
154
155    let sample_time = if morph.repeat && time > first_time {
156        let elapsed = time - first_time;
157        let cycle = elapsed.rem_euclid(span);
158        let forward = elapsed.div_euclid(span) as i64 % 2 == 0;
159        if morph.yoyo && !forward {
160            last_time - cycle
161        } else {
162            first_time + cycle
163        }
164    } else {
165        time.clamp(first_time, last_time)
166    };
167
168    let mut lower = first;
169    let mut upper = last;
170    for pair in keyframes.windows(2) {
171        if sample_time >= pair[0].time && sample_time <= pair[1].time {
172            lower = &pair[0];
173            upper = &pair[1];
174            break;
175        }
176    }
177
178    if lower.value == upper.value {
179        return skia_safe::Path::from_svg(&lower.value);
180    }
181    let segment_span = (upper.time - lower.time).max(1e-9);
182    let local_t = ((sample_time - lower.time) / segment_span).clamp(0.0, 1.0);
183    let eased = ease(local_t, &morph.easing) as f32;
184    interpolate_path_data(&lower.value, &upper.value, eased)
185}
186
187fn resolve_template(s: &str, scope: &dyn Scope) -> std::result::Result<Option<String>, ()> {
188    if !s.contains("${") {
189        return Ok(None);
190    }
191    let mut out = String::with_capacity(s.len());
192    let mut rest = s;
193    loop {
194        match rest.find("${") {
195            None => {
196                out.push_str(rest);
197                break;
198            }
199            Some(start) => {
200                out.push_str(&rest[..start]);
201                let after = &rest[start + 2..];
202                let end = after.find('}').ok_or(())?;
203                let marker = &after[..end];
204                let (src, format_spec) = match marker.rsplit_once('|') {
205                    Some((src, spec)) => (src, Some(spec)),
206                    None => (marker, None),
207                };
208                let value = Expr::parse(src)
209                    .map_err(|_| ())?
210                    .eval(scope)
211                    .map_err(|_| ())?;
212                match format_spec {
213                    None => out.push_str(&format!("{value:.4}")),
214                    Some("02x") => {
215                        let byte = value.round().clamp(0.0, 255.0) as i64;
216                        out.push_str(&format!("{byte:02x}"));
217                    }
218                    Some(_) => return Err(()),
219                }
220                rest = &after[end + 1..];
221            }
222        }
223    }
224    Ok(Some(out))
225}
226
227impl Painter for Shape {
228    fn paint_content(
229        &self,
230        canvas: &Canvas,
231        layout: &BoxLayout,
232        props: &AnimatedProperties,
233        ctx: &PaintCtx,
234    ) {
235        let w = layout.width;
236        let h = layout.height;
237        let corner_radius = self.style.border_radius_px();
238
239        let clock = frame_clock(ctx);
240        let mut resolved_path = None;
241        if let ShapeType::Path { data } = &self.shape {
242            match resolve_template(data, &clock) {
243                Ok(Some(resolved)) => resolved_path = Some(ShapeType::Path { data: resolved }),
244                Ok(None) => {}
245                Err(()) => return,
246            }
247        }
248        let shape = resolved_path.as_ref().unwrap_or(&self.shape);
249        let morphed_path = self
250            .path_morph
251            .as_ref()
252            .and_then(|morph| resolve_path_morph(morph, clock.t));
253
254        if let Some(fill) = &self.fill {
255            let paint: Option<Paint> = match fill {
256                Fill::Solid(color) => match resolve_template(color, &clock) {
257                    Ok(resolved) => Some(paint_from_hex(resolved.as_deref().unwrap_or(color))),
258                    Err(()) => None,
259                },
260                Fill::Gradient(gradient) => Some({
261                    let colors: Vec<skia_safe::Color4f> = gradient
262                        .colors
263                        .iter()
264                        .map(|c| color4f_from_hex(c))
265                        .collect();
266                    let stops: Option<Vec<f32>> = gradient
267                        .stops
268                        .as_ref()
269                        .filter(|s| s.len() == colors.len())
270                        .cloned();
271                    let mut paint = Paint::default();
272                    paint.set_anti_alias(true);
273
274                    let shader = match gradient.gradient_type {
275                        GradientType::Linear => {
276                            let angle = gradient.angle.unwrap_or(0.0);
277                            let rad = angle.to_radians();
278                            let cx = w / 2.0;
279                            let cy = h / 2.0;
280                            let dx = (w / 2.0) * rad.cos();
281                            let dy = (h / 2.0) * rad.sin();
282                            let start = Point::new(cx - dx, cy - dy);
283                            let end = Point::new(cx + dx, cy + dy);
284                            let gradient_colors = skia_safe::gradient::Colors::new(
285                                &colors,
286                                stops.as_deref(),
287                                skia_safe::TileMode::Clamp,
288                                Some(skia_safe::ColorSpace::new_srgb()),
289                            );
290                            let g = skia_safe::gradient::Gradient::new(
291                                gradient_colors,
292                                skia_safe::gradient::Interpolation::default(),
293                            );
294                            skia_safe::gradient::shaders::linear_gradient((start, end), &g, None)
295                        }
296                        GradientType::Radial => {
297                            let center = Point::new(w / 2.0, h / 2.0);
298                            let radius = w.max(h) / 2.0;
299                            let gradient_colors = skia_safe::gradient::Colors::new(
300                                &colors,
301                                stops.as_deref(),
302                                skia_safe::TileMode::Clamp,
303                                Some(skia_safe::ColorSpace::new_srgb()),
304                            );
305                            let g = skia_safe::gradient::Gradient::new(
306                                gradient_colors,
307                                skia_safe::gradient::Interpolation::default(),
308                            );
309                            skia_safe::gradient::shaders::radial_gradient(
310                                (center, radius),
311                                &g,
312                                None,
313                            )
314                        }
315                    };
316                    if let Some(shader) = shader {
317                        paint.set_shader(shader);
318                        paint.set_dither(true);
319                    }
320                    paint
321                }),
322            };
323            if let Some(mut paint) = paint {
324                paint.set_style(PaintStyle::Fill);
325                match &morphed_path {
326                    Some(path) => {
327                        canvas.draw_path(path, &paint);
328                    }
329                    None => draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint),
330                };
331            }
332        }
333
334        if let Some(stroke) = &self.stroke {
335            let mut paint = paint_from_hex(&stroke.color);
336            paint.set_style(PaintStyle::Stroke);
337            let stroke_w = if props.stroke_width >= 0.0 {
338                props.stroke_width
339            } else {
340                stroke.width
341            };
342            paint.set_stroke_width(stroke_w);
343            paint.set_stroke_cap(skia_line_cap(stroke.line_cap));
344            paint.set_stroke_join(skia_line_join(stroke.line_join));
345
346            let expr_draw_start = resolve_draw_start(&self.draw_start, &clock);
347            let draw_start = if props.draw_start >= 0.0 {
348                props.draw_start.clamp(0.0, 1.0)
349            } else {
350                expr_draw_start
351            };
352            let draw_offset = props.draw_offset;
353            let mut trimmed_path = None;
354
355            if let Some(intervals) = stroke.dashed.as_ref().filter(|v| v.len() >= 2) {
356                let phase = resolve_dash_offset(&stroke.dash_offset, &clock);
357                if let Some(dash) = skia_safe::PathEffect::dash(intervals, phase) {
358                    paint.set_path_effect(dash);
359                }
360            } else {
361                let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0;
362                let trimming = draw_start > 0.0 || draw_offset.abs() > 0.0005;
363                if drawing || trimming {
364                    let end = if props.draw_progress >= 0.0 {
365                        props.draw_progress.clamp(0.0, 1.0)
366                    } else {
367                        1.0
368                    };
369                    let trim_start = (draw_start + draw_offset).clamp(0.0, 1.0);
370                    let trim_end = (end + draw_offset).clamp(0.0, 1.0);
371                    if trim_end <= trim_start {
372                        trimmed_path = Some(skia_safe::PathBuilder::new().detach());
373                    } else {
374                        let base = morphed_path
375                            .clone()
376                            .or_else(|| build_shape_path(shape, 0.0, 0.0, w, h, corner_radius));
377                        if let Some(path) = base {
378                            trimmed_path = Some(trim_path_between(&path, trim_start, trim_end));
379                        }
380                    }
381                }
382            }
383
384            match trimmed_path.as_ref().or(morphed_path.as_ref()) {
385                Some(path) => {
386                    canvas.draw_path(path, &paint);
387                }
388                None => draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint),
389            };
390        }
391
392        if let Some(text) = &self.text {
393            let _ = render_shape_text(canvas, text, 0.0, 0.0, w, h);
394        }
395    }
396}
397
398fn render_shape_text(
399    canvas: &Canvas,
400    text: &ShapeText,
401    shape_x: f32,
402    shape_y: f32,
403    shape_w: f32,
404    shape_h: f32,
405) -> Result<()> {
406    use rustmotion_core::schema::VerticalAlign;
407
408    let pad = text.padding.unwrap_or(0.0);
409    let area_x = shape_x + pad;
410    let area_y = shape_y + pad;
411    let area_w = shape_w - 2.0 * pad;
412    let area_h = shape_h - 2.0 * pad;
413
414    let font_style = match text.font_weight {
415        FontWeight::Bold => skia_safe::FontStyle::bold(),
416        FontWeight::Normal => skia_safe::FontStyle::normal(),
417        FontWeight::Weight(w) => skia_safe::FontStyle::new(
418            skia_safe::font_style::Weight::from(w as i32),
419            skia_safe::font_style::Width::NORMAL,
420            skia_safe::font_style::Slant::Upright,
421        ),
422    };
423
424    let typeface = typeface_with_fallback(&text.font_family, font_style)?;
425
426    let font = subpixel_font(typeface, text.font_size);
427    let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, text.font_size));
428    let (_strike_width, metrics) = font.metrics();
429    let ascent = -metrics.ascent;
430    let line_height = match text.line_height {
431        Some(v) if v <= 10.0 => text.font_size * v,
432        Some(v) => v,
433        None => text.font_size * 1.3,
434    };
435    let letter_spacing = text.letter_spacing.unwrap_or(0.0);
436
437    let lines = wrap_text_with_tracking(
438        &text.content,
439        &font,
440        &emoji_font,
441        Some(area_w),
442        letter_spacing,
443    );
444    let descent = metrics.descent;
445    let total_h = if lines.len() > 1 {
446        (lines.len() - 1) as f32 * line_height + ascent + descent
447    } else {
448        ascent + descent
449    };
450
451    let y_start = match text.vertical_align {
452        VerticalAlign::Top => area_y + ascent,
453        VerticalAlign::Middle => area_y + (area_h - total_h) / 2.0 + ascent,
454        VerticalAlign::Bottom => area_y + area_h - total_h + ascent,
455    };
456
457    let mut paint = paint_from_hex(&text.color);
458    paint.set_alpha_f(1.0);
459
460    for (i, line) in lines.iter().enumerate() {
461        if line.is_empty() {
462            continue;
463        }
464
465        let line_width = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
466
467        let x = match text.align {
468            TextAlign::Left => area_x,
469            TextAlign::Center => area_x + (area_w - line_width) / 2.0,
470            TextAlign::Right => area_x + area_w - line_width,
471        };
472        let y = y_start + i as f32 * line_height;
473        draw_text_with_fallback(
474            canvas,
475            line,
476            &font,
477            &emoji_font,
478            letter_spacing,
479            x,
480            y,
481            &paint,
482        );
483    }
484
485    Ok(())
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use rustmotion_core::traits::TimingConfig;
492    use skia_safe::{surfaces, AlphaType, ColorType, ImageInfo};
493
494    const W: i32 = 200;
495    const H: i32 = 20;
496
497    fn ctx_at(time: f64) -> PaintCtx {
498        PaintCtx {
499            time,
500            scenario_time: time,
501            scene_duration: 1.0,
502            frame_index: 0,
503            fps: 30,
504            video_width: W as u32,
505            video_height: H as u32,
506            stagger_offset: 0.0,
507        }
508    }
509
510    fn layout() -> BoxLayout {
511        BoxLayout {
512            width: W as f32,
513            height: H as f32,
514            ..Default::default()
515        }
516    }
517
518    fn straight_line(color: &str) -> Shape {
519        Shape {
520            shape: ShapeType::Path {
521                data: format!("M0 {} L{} {}", H / 2, W, H / 2),
522            },
523            text: None,
524            timing: TimingConfig::default(),
525            style: CssStyle::default(),
526            timeline: vec![],
527            stagger: None,
528            fill: None,
529            stroke: Some(Stroke {
530                color: color.to_string(),
531                width: 4.0,
532                dashed: None,
533                dash_offset: None,
534                line_cap: LineCap::Butt,
535                line_join: LineJoin::Miter,
536            }),
537            draw_start: None,
538            path_morph: None,
539        }
540    }
541
542    fn render_at(shape: &Shape, props: &AnimatedProperties, time: f64) -> Vec<u8> {
543        let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
544        let mut surface = surfaces::raster(&info, None, None).expect("raster surface");
545        surface.canvas().clear(skia_safe::Color::BLACK);
546        shape.paint_content(surface.canvas(), &layout(), props, &ctx_at(time));
547        let row_bytes = W as usize * 4;
548        let mut pixels = vec![0u8; row_bytes * H as usize];
549        surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
550        pixels
551    }
552
553    fn render(shape: &Shape, props: &AnimatedProperties) -> Vec<u8> {
554        render_at(shape, props, 0.0)
555    }
556
557    fn pixel(buf: &[u8], x: i32, y: i32) -> [u8; 4] {
558        let base = ((y * W + x) * 4) as usize;
559        [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]]
560    }
561
562    fn is_red(px: [u8; 4]) -> bool {
563        px[0] > 200 && px[1] < 40 && px[2] < 40
564    }
565
566    #[test]
567    fn draw_start_leaves_no_ink_before_it() {
568        let mut shape = straight_line("#FF0000");
569        shape.draw_start = Some(Computed::Literal(0.5));
570        let props = AnimatedProperties {
571            draw_progress: 1.0,
572            ..AnimatedProperties::default()
573        };
574        let out = render(&shape, &props);
575
576        for x in [5, 40, 90] {
577            assert!(
578                !is_red(pixel(&out, x, H / 2)),
579                "x={x} is before draw_start=0.5 on a 200px line and must have no ink"
580            );
581        }
582        for x in [110, 150, 195] {
583            assert!(
584                is_red(pixel(&out, x, H / 2)),
585                "x={x} is after draw_start=0.5 and before draw_progress=1.0, so it must be lit"
586            );
587        }
588    }
589
590    #[test]
591    fn draw_start_absent_matches_the_pre_existing_draw_progress_behaviour() {
592        let with_zero = {
593            let mut shape = straight_line("#FF0000");
594            shape.draw_start = Some(Computed::Literal(0.0));
595            let props = AnimatedProperties {
596                draw_progress: 0.5,
597                ..AnimatedProperties::default()
598            };
599            render(&shape, &props)
600        };
601        let without_field = {
602            let shape = straight_line("#FF0000");
603            let props = AnimatedProperties {
604                draw_progress: 0.5,
605                ..AnimatedProperties::default()
606            };
607            render(&shape, &props)
608        };
609        assert_eq!(
610            with_zero, without_field,
611            "an explicit draw_start of 0.0 must render identically to draw_start absent"
612        );
613    }
614
615    #[test]
616    fn draw_start_without_an_active_draw_progress_erases_from_the_tail() {
617        let mut shape = straight_line("#FF0000");
618        shape.draw_start = Some(Computed::Literal(0.5));
619        let props = AnimatedProperties::default();
620        let out = render(&shape, &props);
621
622        assert!(
623            !is_red(pixel(&out, 20, H / 2)),
624            "with draw_progress never animated, draw_start still trims the head of the stroke"
625        );
626        assert!(
627            is_red(pixel(&out, 180, H / 2)),
628            "the tail (up to the implicit end of 1.0) must remain lit"
629        );
630    }
631
632    #[test]
633    fn a_keyframe_driven_draw_start_is_honored_even_with_no_static_draw_start_field() {
634        let shape = straight_line("#FF0000");
635        let props = AnimatedProperties {
636            draw_start: 0.5,
637            draw_progress: 1.0,
638            ..AnimatedProperties::default()
639        };
640        let out = render(&shape, &props);
641
642        for x in [5, 40, 90] {
643            assert!(
644                !is_red(pixel(&out, x, H / 2)),
645                "x={x} is before a keyframe-driven draw_start=0.5 and must have no ink — \
646                 keyframes on draw_start must not be a no-op on shape"
647            );
648        }
649        for x in [110, 150, 195] {
650            assert!(
651                is_red(pixel(&out, x, H / 2)),
652                "x={x} is after the keyframe-driven draw_start=0.5 and must be lit"
653            );
654        }
655    }
656
657    #[test]
658    fn draw_offset_marches_the_drawn_window_along_the_path() {
659        let shape = straight_line("#FF0000");
660        let no_offset = render(
661            &shape,
662            &AnimatedProperties {
663                draw_progress: 0.3,
664                ..AnimatedProperties::default()
665            },
666        );
667        let offset = render(
668            &shape,
669            &AnimatedProperties {
670                draw_progress: 0.3,
671                draw_offset: 0.5,
672                ..AnimatedProperties::default()
673            },
674        );
675
676        assert!(
677            is_red(pixel(&no_offset, 20, H / 2)),
678            "with no offset the window starts at the path's own beginning"
679        );
680        assert!(
681            !is_red(pixel(&offset, 20, H / 2)),
682            "draw_offset=0.5 must march the window forward, leaving the path's start empty"
683        );
684        assert!(
685            is_red(pixel(&offset, 140, H / 2)),
686            "and paint further along the path instead"
687        );
688    }
689
690    #[test]
691    fn path_morph_interpolates_between_same_structure_paths() {
692        let mut shape = straight_line("#FF0000");
693        shape.stroke = None;
694        shape.fill = Some(Fill::Solid("#FF0000".to_string()));
695        shape.path_morph = Some(PathMorph {
696            keyframes: vec![
697                PathMorphKeyframe {
698                    time: 0.0,
699                    value: "M0 0 L10 0 L10 10 L0 10 Z".to_string(),
700                },
701                PathMorphKeyframe {
702                    time: 1.0,
703                    value: format!("M0 0 L{W} 0 L{W} {H} L0 {H} Z"),
704                },
705            ],
706            easing: EasingType::Linear,
707            repeat: false,
708            yoyo: false,
709        });
710
711        let out = render_at(&shape, &AnimatedProperties::default(), 0.5);
712
713        assert!(
714            is_red(pixel(&out, 5, H / 2)),
715            "the near corner must already be inside the halfway-grown rectangle"
716        );
717        assert!(
718            !is_red(pixel(&out, W - 5, H / 2)),
719            "the far corner must not be covered yet at t=0.5 of the morph"
720        );
721    }
722
723    #[test]
724    fn path_morph_with_a_structure_mismatch_does_not_panic_and_holds_a_shape() {
725        let mut shape = straight_line("#FF0000");
726        shape.stroke = None;
727        shape.fill = Some(Fill::Solid("#FF0000".to_string()));
728        shape.path_morph = Some(PathMorph {
729            keyframes: vec![
730                PathMorphKeyframe {
731                    time: 0.0,
732                    value: "M0 0 L10 0 L10 10 Z".to_string(),
733                },
734                PathMorphKeyframe {
735                    time: 1.0,
736                    value: "M0 0 L10 0 L10 10 L5 15 L0 10 Z".to_string(),
737                },
738            ],
739            easing: EasingType::Linear,
740            repeat: false,
741            yoyo: false,
742        });
743        let _ = render_at(&shape, &AnimatedProperties::default(), 0.5);
744    }
745}