Skip to main content

ranim_items/vitem/
svg.rs

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