Skip to main content

fastanim_core/
svg.rs

1//! SVG still export, straight from a [`SceneState`] (no GPU).
2
3use std::fmt::Write;
4
5use crate::color::Color;
6use crate::geom::VPath;
7use crate::mobject::SceneState;
8use crate::{FRAME_HEIGHT, FRAME_WIDTH};
9
10/// Renders one frame as a 16:9 SVG document over a `background` fill.
11pub fn to_svg(state: &SceneState, background: Color) -> String {
12    let (w, h) = (FRAME_WIDTH, FRAME_HEIGHT);
13    let mut out = String::new();
14    let _ = writeln!(
15        out,
16        r#"<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="{} {} {} {}">"#,
17        num(-w / 2.0),
18        num(-h / 2.0),
19        num(w),
20        num(h)
21    );
22    let _ = writeln!(
23        out,
24        r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
25        num(-w / 2.0),
26        num(-h / 2.0),
27        num(w),
28        num(h),
29        background.to_hex()
30    );
31    // Scene units are y-up; SVG is y-down.
32    out.push_str("<g transform=\"scale(1,-1)\">\n");
33    let mut order: Vec<_> = state.iter().collect();
34    order.sort_by_key(|(id, m)| (m.z_index, **id));
35    for (_, m) in order {
36        let d = path_data(&m.path.trim(m.draw_range.clone()));
37        if m.opacity <= 0.0 || d.is_empty() {
38            continue;
39        }
40        let _ = writeln!(
41            out,
42            r#"<path d="{d}" fill="{}" fill-opacity="{}" stroke="{}" stroke-opacity="{}" stroke-width="{}" stroke-linejoin="round" stroke-linecap="round" opacity="{}"/>"#,
43            m.fill.to_hex(),
44            num(m.fill.a.into()),
45            m.stroke.color.to_hex(),
46            num(m.stroke.color.a.into()),
47            num(m.stroke.width),
48            num(m.opacity.into()),
49        );
50    }
51    out.push_str("</g>\n</svg>\n");
52    out
53}
54
55/// SVG path data (`M`, `C`, `Z`) for `p`, in scene units; also what canvas `Path2D` takes.
56pub fn path_data(p: &VPath) -> String {
57    let mut d = String::new();
58    for sp in &p.subpaths {
59        let Some(first) = sp.segments.first() else {
60            continue;
61        };
62        let _ = write!(d, "M{} {}", num(first.p0.x), num(first.p0.y));
63        for s in &sp.segments {
64            let _ = write!(
65                d,
66                "C{} {} {} {} {} {}",
67                num(s.p1.x),
68                num(s.p1.y),
69                num(s.p2.x),
70                num(s.p2.y),
71                num(s.p3.x),
72                num(s.p3.y)
73            );
74        }
75        if sp.closed {
76            d.push('Z');
77        }
78    }
79    d
80}
81
82/// Four decimals, no trailing zeros, no `-0`: stable, diffable golden files.
83fn num(x: f64) -> String {
84    let s = format!("{x:.4}");
85    let s = s.trim_end_matches('0').trim_end_matches('.');
86    if s == "-0" { "0".into() } else { s.into() }
87}