Skip to main content

ranim_items/vitem/
svg.rs

1#[cfg(feature = "render")]
2use ranim_render::extract::{Extract, ExtractContext};
3
4use color::{AlphaColor, Srgb, palette::css, rgb8, rgba};
5use glam::{DAffine2, DAffine3, DMat3, DVec3, dvec3};
6use ranim_core::anchor::Aabb;
7use ranim_core::traits::{ApplyTransform, FillColor, Opacity, StrokeColor, StrokeWidth};
8use ranim_core::traits::{PointsFunc, Rigid, TransformGroup};
9use ranim_core::transformed::Transformed;
10use ranim_core::utils::bezier::PathBuilder;
11use ranim_core::{color, components::width::Width, glam};
12use tracing::warn;
13
14use super::VItem;
15use crate::hierarchy::{Node, PlacedLeaves};
16
17// MARK: ### SvgItem ###
18/// An Svg Item.
19///
20/// Its inner is a [`Node`] tree of [`VItem`]s that mirrors the group
21/// structure of the source SVG: every `usvg` group becomes a group node
22/// carrying its **relative** transform (never baked into point data), and
23/// every path becomes a leaf holding its raw geometry. The root node's
24/// transform carries the whole placement, so repositioning an [`SvgItem`]
25/// is O(1) and never rewrites points.
26///
27/// Use [`Vec::<VItem>::from`] to flatten into baked items, and
28/// [`SvgItem::by_id`] to address subtrees by their SVG element id.
29#[derive(Clone, Debug)]
30pub struct SvgItem(Transformed<Node<VItem>, DAffine3>);
31
32impl From<SvgItem> for Vec<VItem> {
33    /// Flatten the hierarchy depth-first (painter's-algorithm order),
34    /// baking each leaf's accumulated world affine into a clone of its
35    /// [`VItem`].
36    fn from(value: SvgItem) -> Self {
37        value
38            .0
39            .leaves()
40            .map(|(world, leaf)| {
41                let mut vitem = leaf.clone();
42                vitem.apply_affine3(world);
43                vitem
44            })
45            .collect()
46    }
47}
48
49impl SvgItem {
50    /// Creates a new SvgItem from a SVG string
51    ///
52    /// The tree is centered on its own bounding box and flipped over the
53    /// x axis (SVG's y-down coordinates become y-up), matching the
54    /// composition of the old `move_to(ZERO)` + `rotate_on_x(PI)` pipeline
55    /// — but as an O(1) root pose instead of baked points.
56    pub fn new(svg: impl AsRef<str>) -> Self {
57        let tree =
58            usvg::Tree::from_str(svg.as_ref(), &usvg::Options::default()).expect("invalid svg");
59        let mut item = Self::from_tree(&tree);
60        let [min, max] = item.0.aabb();
61        let center = (min + max) * 0.5;
62        item.0.compose_outer(
63            Rigid::from_axis_angle(DVec3::X, std::f64::consts::PI)
64                .compose(&Rigid::from_translation(-center)),
65        );
66        item
67    }
68
69    /// Build the hierarchy from a `usvg` tree, preserving its structure.
70    ///
71    /// Unlike [`SvgItem::new`], no root normalization happens: coordinates
72    /// stay in the SVG's local (y-down) space. This keeps
73    /// [`vitems_from_tree`] behaving exactly like its pre-hierarchy
74    /// counterpart.
75    pub fn from_tree(tree: &usvg::Tree) -> Self {
76        Self(build_group_node(tree.root(), usvg::Transform::identity()))
77    }
78
79    /// The underlying hierarchy tree, including its root placement.
80    pub fn tree(&self) -> &Transformed<Node<VItem>, DAffine3> {
81        &self.0
82    }
83
84    /// The underlying hierarchy tree, mutably.
85    pub fn tree_mut(&mut self) -> &mut Transformed<Node<VItem>, DAffine3> {
86        &mut self.0
87    }
88
89    /// Consume the item, returning the underlying placed hierarchy tree.
90    pub fn into_tree(self) -> Transformed<Node<VItem>, DAffine3> {
91        self.0
92    }
93
94    /// The leaf payload of the first node (depth-first) whose id matches,
95    /// or — when the id sits on a group — the first leaf of that subtree.
96    ///
97    /// SVG ids may be set on groups, so such lookups resolve to the group's
98    /// first leaf; use [`SvgItem::tree`] for structural access.
99    pub fn by_id(&self, id: &str) -> Option<&VItem> {
100        let node = self.0.inner.by_id(id)?;
101        node.inner.item().or_else(|| node.inner.first_leaf())
102    }
103
104    /// Mutable variant of [`SvgItem::by_id`]. Mutation reaches only the
105    /// matched leaf's local (canonical) data; node transforms are
106    /// unaffected.
107    pub fn by_id_mut(&mut self, id: &str) -> Option<&mut VItem> {
108        let node = self.0.inner.by_id_mut(id)?;
109        if node.inner.item_mut().is_some() {
110            node.inner.item_mut()
111        } else {
112            node.inner.first_leaf_mut()
113        }
114    }
115}
116
117// MARK: Trait impls
118impl Aabb for SvgItem {
119    fn aabb(&self) -> [glam::DVec3; 2] {
120        self.0.aabb()
121    }
122}
123
124impl<G: Into<glam::DAffine3>> ApplyTransform<G> for SvgItem {
125    fn apply(&mut self, transform: G) -> &mut Self {
126        self.0.apply(transform.into());
127        self
128    }
129}
130
131impl FillColor for SvgItem {
132    fn fill_color(&self) -> AlphaColor<Srgb> {
133        self.0.fill_color()
134    }
135    fn set_fill_color(&mut self, color: AlphaColor<Srgb>) -> &mut Self {
136        self.0.set_fill_color(color);
137        self
138    }
139    fn set_fill_opacity(&mut self, opacity: f32) -> &mut Self {
140        self.0.set_fill_opacity(opacity);
141        self
142    }
143}
144
145impl StrokeColor for SvgItem {
146    fn stroke_color(&self) -> AlphaColor<Srgb> {
147        self.0.stroke_color()
148    }
149    fn set_stroke_color(&mut self, color: AlphaColor<Srgb>) -> &mut Self {
150        self.0.set_stroke_color(color);
151        self
152    }
153    fn set_stroke_opacity(&mut self, opacity: f32) -> &mut Self {
154        self.0.set_stroke_opacity(opacity);
155        self
156    }
157}
158
159impl Opacity for SvgItem {
160    fn set_opacity(&mut self, opacity: f32) -> &mut Self {
161        self.0.set_opacity(opacity);
162        self
163    }
164}
165
166impl StrokeWidth for SvgItem {
167    fn stroke_width(&self) -> f32 {
168        self.0.stroke_width()
169    }
170    fn apply_stroke_func(&mut self, f: impl for<'a> Fn(&'a mut [Width])) -> &mut Self {
171        self.0.apply_stroke_func(f);
172        self
173    }
174    fn set_stroke_width(&mut self, width: f32) -> &mut Self {
175        self.0.set_stroke_width(width);
176        self
177    }
178}
179
180// MARK: Conversions
181#[cfg(feature = "render")]
182impl Extract for SvgItem {
183    fn extract(&self, ctx: &mut ExtractContext<'_>) {
184        self.0.extract(ctx);
185    }
186}
187
188// MARK: misc
189fn parse_paint(paint: &usvg::Paint) -> AlphaColor<Srgb> {
190    match paint {
191        usvg::Paint::Color(color) => rgb8(color.red, color.green, color.blue),
192        _ => css::GREEN,
193    }
194}
195
196/// Build a group node from a `usvg` group and the absolute transform of
197/// its parent group.
198///
199/// The node's transform is the group's transform **relative** to its
200/// parent (`parent_abs^-1 * group_abs`), so path data is never rewritten
201/// and re-posing any level of the tree is a local operation.
202fn build_group_node(
203    group: &usvg::Group,
204    parent_abs: usvg::Transform,
205) -> Transformed<Node<VItem>, DAffine3> {
206    let abs = group.abs_transform();
207    let children: Vec<Transformed<Node<VItem>, DAffine3>> = group
208        .children()
209        .iter()
210        .filter_map(|node| match node {
211            usvg::Node::Group(group) => Some(build_group_node(group, abs)),
212            usvg::Node::Path(path) => build_path_leaf(path, abs),
213            // Image and Text nodes are not supported and are skipped.
214            usvg::Node::Image(_) | usvg::Node::Text(_) => None,
215        })
216        .collect();
217    let mut node = Node::group(children);
218    if !group.id().is_empty() {
219        node.id = Some(group.id().to_string());
220    }
221    Transformed::new(node, widen_to_daffine3(rel_transform(parent_abs, abs)))
222}
223
224/// Build a leaf node from a `usvg` path and the absolute transform of its
225/// parent group.
226///
227/// The [`VItem`] is built from the raw path segments exactly like the old
228/// flat walker did (same segment mapping, fill/stroke parsing, and empty
229/// path skipping) — but the path's placement goes onto the leaf node's
230/// transform instead of being baked into the points.
231fn build_path_leaf(
232    path: &usvg::Path,
233    parent_abs: usvg::Transform,
234) -> Option<Transformed<Node<VItem>, DAffine3>> {
235    let mut builder = PathBuilder::new();
236    for segment in path.data().segments() {
237        match segment {
238            usvg::tiny_skia_path::PathSegment::MoveTo(p) => {
239                builder.move_to(dvec3(p.x as f64, p.y as f64, 0.0))
240            }
241            usvg::tiny_skia_path::PathSegment::LineTo(p) => {
242                builder.line_to(dvec3(p.x as f64, p.y as f64, 0.0))
243            }
244            usvg::tiny_skia_path::PathSegment::QuadTo(p1, p2) => builder.quad_to(
245                dvec3(p1.x as f64, p1.y as f64, 0.0),
246                dvec3(p2.x as f64, p2.y as f64, 0.0),
247            ),
248            usvg::tiny_skia_path::PathSegment::CubicTo(p1, p2, p3) => builder.cubic_to(
249                dvec3(p1.x as f64, p1.y as f64, 0.0),
250                dvec3(p2.x as f64, p2.y as f64, 0.0),
251                dvec3(p3.x as f64, p3.y as f64, 0.0),
252            ),
253            usvg::tiny_skia_path::PathSegment::Close => builder.close_path(),
254        };
255    }
256    if builder.is_empty() {
257        warn!("empty path");
258        return None;
259    }
260
261    let mut vitem = VItem::from_vpoints(builder.vpoints().to_vec());
262    let fill_color = if let Some(fill) = path.fill() {
263        parse_paint(fill.paint()).with_alpha(fill.opacity().get())
264    } else {
265        rgba(0.0, 0.0, 0.0, 0.0)
266    };
267    vitem.set_fill_color(fill_color);
268    if let Some(stroke) = path.stroke() {
269        let color = parse_paint(stroke.paint()).with_alpha(stroke.opacity().get());
270        vitem.set_stroke_color(color);
271        vitem.set_stroke_width(stroke.width().get());
272    } else {
273        vitem.set_stroke_color(fill_color.with_alpha(0.0));
274        vitem.set_stroke_width(0.0);
275    }
276
277    let mut node = Node::leaf(vitem);
278    if !path.id().is_empty() {
279        node.id = Some(path.id().to_string());
280    }
281    Some(Transformed::new(
282        node,
283        widen_to_daffine3(rel_transform(parent_abs, path.abs_transform())),
284    ))
285}
286
287/// The transform of a node relative to its parent: `parent_abs^-1 * abs`.
288///
289/// A singular (non-invertible) parent — e.g. `scale(0)` — has no relative
290/// form; we warn and fall back to the identity, leaving the child's data
291/// in its raw coordinates.
292fn rel_transform(parent_abs: usvg::Transform, abs: usvg::Transform) -> DAffine2 {
293    let parent = usvg_transform_to_daffine2(parent_abs);
294    if parent.matrix2.determinant() == 0.0 {
295        warn!("singular svg transform cannot be inverted, using identity");
296        return DAffine2::IDENTITY;
297    }
298    parent.inverse() * usvg_transform_to_daffine2(abs)
299}
300
301/// Convert a `usvg` (tiny-skia) row-major transform into a [`DAffine2`].
302fn usvg_transform_to_daffine2(transform: usvg::Transform) -> DAffine2 {
303    DAffine2::from_cols_array(&[
304        transform.sx as f64,
305        transform.ky as f64,
306        transform.kx as f64,
307        transform.sy as f64,
308        transform.tx as f64,
309        transform.ty as f64,
310    ])
311}
312
313/// Widen a 2D affine into a 3D one, embedding the xy plane at z = 0.
314fn widen_to_daffine3(affine: DAffine2) -> DAffine3 {
315    DAffine3::from_mat3_translation(
316        DMat3::from_cols(
317            affine.matrix2.x_axis.extend(0.0),
318            affine.matrix2.y_axis.extend(0.0),
319            DVec3::Z,
320        ),
321        affine.translation.extend(0.0),
322    )
323}
324
325/// The first leaf (depth-first) under the first node whose id matches.
326/// Construct a `Vec<VItem` from `&str` of a SVG
327pub fn vitems_from_svg(svg: &str) -> Vec<VItem> {
328    let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).unwrap();
329    vitems_from_tree(&tree)
330}
331
332/// Construct a `Vec<VItem>` from `&usvg::Tree`
333pub fn vitems_from_tree(tree: &usvg::Tree) -> Vec<VItem> {
334    Vec::<VItem>::from(SvgItem::from_tree(tree))
335}
336
337#[cfg(test)]
338mod tests {
339    use std::f64::consts::PI;
340
341    use glam::dvec2;
342    use ranim_core::traits::ShiftTransform;
343
344    use super::*;
345
346    /// A source SVG with nested transformed groups and ids on groups and
347    /// paths. `width`/`height` match the viewBox, so usvg adds no root
348    /// scaling and the root group's absolute transform is the identity.
349    const NESTED_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
350        <g transform="translate(10) scale(2)">
351            <g id="inner" transform="rotate(45)">
352                <path id="leaf-a" d="M 10 10 L 20 10" fill="#ff0000" stroke="#00ff00" stroke-width="0.5"/>
353            </g>
354            <path id="leaf-b" d="M 0 0 L 5 0" fill="#0000ff"/>
355        </g>
356    </svg>"##;
357
358    fn parse(svg: &str) -> usvg::Tree {
359        usvg::Tree::from_str(svg, &usvg::Options::default()).unwrap()
360    }
361
362    /// `translate(10) scale(2)`: the scale applies to the points first.
363    fn outer_rel() -> DAffine2 {
364        DAffine2::from_translation(dvec2(10.0, 0.0)) * DAffine2::from_scale(dvec2(2.0, 2.0))
365    }
366
367    /// Affine equality with a tolerance suited to the f32 transforms usvg
368    /// stores.
369    fn assert_affine3_eq(actual: DAffine3, expected: DAffine3) {
370        assert!(actual.matrix3.abs_diff_eq(expected.matrix3, 1e-6));
371        assert!(actual.translation.abs_diff_eq(expected.translation, 1e-6));
372    }
373
374    #[test]
375    fn structure_ids_and_relative_transforms_match_the_source() {
376        let svg = SvgItem::from_tree(&parse(NESTED_SVG));
377        let root = svg.tree();
378        let outer = &root.inner.children()[0];
379        let inner = &outer.inner.children()[0];
380        assert_eq!(inner.inner.id.as_deref(), Some("inner"));
381        assert_affine3_eq(outer.transform, widen_to_daffine3(outer_rel()));
382        assert_affine3_eq(
383            inner.transform,
384            widen_to_daffine3(DAffine2::from_angle(PI / 4.0)),
385        );
386        let leaf = &inner.inner.children()[0];
387        assert_eq!(leaf.inner.id.as_deref(), Some("leaf-a"));
388        assert_eq!(
389            leaf.inner.item().unwrap().vpoints.0[0],
390            dvec3(10.0, 10.0, 0.0)
391        );
392        assert_eq!(
393            leaf.inner.item().unwrap().vpoints.0[2],
394            dvec3(20.0, 10.0, 0.0)
395        );
396    }
397
398    #[test]
399    fn flattened_world_points_match_hand_composed_affine() {
400        let svg = SvgItem::from_tree(&parse(NESTED_SVG));
401        let vitems = Vec::<VItem>::from(svg.clone());
402        assert_eq!(vitems.len(), 2);
403        for (item, id, affine) in [
404            (
405                &vitems[0],
406                "leaf-a",
407                outer_rel() * DAffine2::from_angle(PI / 4.0),
408            ),
409            (&vitems[1], "leaf-b", outer_rel()),
410        ] {
411            let raw = &svg.by_id(id).unwrap().vpoints.0;
412            assert_eq!(item.vpoints.len(), raw.len());
413            let affine = widen_to_daffine3(affine);
414            for (world, raw) in item.vpoints.iter().zip(raw) {
415                assert!(world.abs_diff_eq(affine.transform_point3(*raw), 1e-5));
416            }
417        }
418    }
419
420    #[test]
421    fn new_centers_the_aabb_and_flips_after_centering() {
422        // The unnormalized world, to derive the old pipeline's center.
423        let raw_items = Vec::<VItem>::from(SvgItem::from_tree(&parse(NESTED_SVG)));
424        let [min, max] = raw_items.aabb();
425        let center = (min + max) * 0.5;
426
427        let new_items = Vec::<VItem>::from(SvgItem::new(NESTED_SVG));
428
429        // old: move_to(ZERO) then rotate_on_x(PI) — i.e. flip(p - center).
430        let flip = DVec3::new(1.0, -1.0, -1.0);
431        for (new, raw) in new_items.iter().zip(raw_items.iter()) {
432            for (new_p, raw_p) in new.vpoints.0.iter().zip(raw.vpoints.0.iter()) {
433                let expected = (raw_p - center) * flip;
434                assert!(
435                    new_p.abs_diff_eq(expected, 1e-9),
436                    "new {new_p:?} vs expected {expected:?}"
437                );
438            }
439        }
440
441        // The extracted aabb is centered at the origin.
442        let [min, max] = new_items.aabb();
443        assert!(((min + max) * 0.5).abs_diff_eq(DVec3::ZERO, 1e-9));
444    }
445
446    #[test]
447    fn by_id_lookups_read_and_write_only_the_named_leaf() {
448        let mut svg = SvgItem::from_tree(&parse(NESTED_SVG));
449        assert!(svg.by_id("missing").is_none());
450
451        let sibling_before = svg.by_id("leaf-b").unwrap().vpoints.0.clone();
452        svg.by_id_mut("leaf-a").unwrap().shift(DVec3::X);
453        assert!(
454            svg.by_id("leaf-a").unwrap().vpoints.0[0].abs_diff_eq(dvec3(11.0, 10.0, 0.0), 1e-9)
455        );
456        assert_eq!(
457            svg.by_id("leaf-b").unwrap().vpoints.0,
458            sibling_before,
459            "the sibling leaf must be untouched"
460        );
461
462        // A group id resolves to its first leaf.
463        assert_eq!(
464            svg.by_id("inner").unwrap().vpoints.0[0],
465            dvec3(11.0, 10.0, 0.0)
466        );
467    }
468}
469
470#[cfg(all(test, feature = "typst"))]
471mod typst_tests {
472    use crate::vitem::typst::typst_svg;
473
474    use super::*;
475
476    #[test]
477    fn typst_svg_converts_to_vitems() {
478        let svg = typst_svg("R");
479        let vitems = vitems_from_svg(&svg);
480
481        assert!(!vitems.is_empty(), "typst output produced no vitems");
482        assert!(vitems.iter().all(|item| !item.vpoints.is_empty()));
483    }
484}