tessera_ui_basic_components/
image_vector.rs

1//! Vector image component built on top of SVG parsing and tessellation.
2//!
3//! This module mirrors the ergonomics of [`crate::image`], but keeps the content
4//! in vector form so it can scale cleanly at any size. SVG data is parsed with
5//! [`usvg`] and tessellated into GPU-friendly triangles using lyon.
6//! The resulting [`ImageVectorData`] can be cached and reused across frames.
7
8use std::{fs, path::Path as StdPath, sync::Arc};
9
10use lyon_geom::point;
11use lyon_path::Path as LyonPath;
12use lyon_tessellation::{
13    BuffersBuilder, FillOptions, FillRule as LyonFillRule, FillTessellator, FillVertex,
14    LineCap as LyonLineCap, LineJoin as LyonLineJoin, StrokeOptions, StrokeTessellator,
15    StrokeVertex, VertexBuffers,
16};
17use tessera_ui::Color;
18use thiserror::Error;
19use usvg::{
20    BlendMode, FillRule, Group, LineCap as SvgLineCap, LineJoin as SvgLineJoin, Node, Paint,
21    PaintOrder, Path, Stroke, Tree, tiny_skia_path::PathSegment,
22};
23
24use crate::pipelines::image_vector::command::ImageVectorVertex;
25
26pub use crate::pipelines::image_vector::command::{ImageVectorData, VectorTintMode as TintMode};
27
28/// Source for loading SVG vector data.
29#[derive(Clone, Debug)]
30pub enum ImageVectorSource {
31    /// Load from a filesystem path.
32    Path(String),
33    /// Load from in-memory bytes.
34    Bytes(Arc<[u8]>),
35}
36
37/// Errors that can occur while decoding or tessellating vector images.
38#[derive(Debug, Error)]
39pub enum ImageVectorLoadError {
40    /// Failed to read a file from disk.
41    #[error("failed to read SVG from {path}: {source}")]
42    Io {
43        /// Failing path.
44        path: String,
45        /// Underlying IO error.
46        #[source]
47        source: std::io::Error,
48    },
49    /// SVG parsing failed.
50    #[error("failed to parse SVG: {0}")]
51    Parse(#[from] usvg::Error),
52    /// The SVG viewport dimensions are invalid.
53    #[error("SVG viewport must have finite, positive size")]
54    InvalidViewport,
55    /// Encountered an SVG feature that isn't supported yet.
56    #[error("unsupported SVG feature: {0}")]
57    UnsupportedFeature(String),
58    /// Failed to apply the absolute transform for a path.
59    #[error("failed to apply SVG transforms")]
60    TransformFailed,
61    /// Tessellation of the path geometry failed.
62    #[error("tessellation error: {0}")]
63    Tessellation(#[from] lyon_tessellation::TessellationError),
64    /// No renderable geometry was produced.
65    #[error("SVG produced no renderable paths")]
66    EmptyGeometry,
67}
68
69/// Load [`ImageVectorData`] from the provided source.
70pub fn load_image_vector_from_source(
71    source: &ImageVectorSource,
72) -> Result<ImageVectorData, ImageVectorLoadError> {
73    let (bytes, resources_dir) = read_source_bytes(source)?;
74
75    let options = usvg::Options {
76        resources_dir,
77        ..Default::default()
78    };
79    let tree = Tree::from_data(&bytes, &options)?;
80
81    build_vector_data(&tree)
82}
83
84fn read_source_bytes(
85    source: &ImageVectorSource,
86) -> Result<(Vec<u8>, Option<std::path::PathBuf>), ImageVectorLoadError> {
87    match source {
88        ImageVectorSource::Path(path) => {
89            let bytes = fs::read(path).map_err(|source| ImageVectorLoadError::Io {
90                path: path.clone(),
91                source,
92            })?;
93            let dir = StdPath::new(path).parent().map(|p| p.to_path_buf());
94            Ok((bytes, dir))
95        }
96        ImageVectorSource::Bytes(bytes) => Ok((bytes.as_ref().to_vec(), None)),
97    }
98}
99
100fn build_vector_data(tree: &Tree) -> Result<ImageVectorData, ImageVectorLoadError> {
101    let size = tree.size();
102    let viewport_width = size.width();
103    let viewport_height = size.height();
104
105    if !viewport_width.is_finite()
106        || !viewport_height.is_finite()
107        || viewport_width <= 0.0
108        || viewport_height <= 0.0
109    {
110        return Err(ImageVectorLoadError::InvalidViewport);
111    }
112
113    let mut collector = VectorGeometryCollector::new(viewport_width, viewport_height);
114    visit_group(tree.root(), 1.0, &mut collector)?;
115
116    collector.finish()
117}
118
119fn visit_group(
120    group: &Group,
121    inherited_opacity: f32,
122    collector: &mut VectorGeometryCollector,
123) -> Result<(), ImageVectorLoadError> {
124    if group.clip_path().is_some() || group.mask().is_some() || !group.filters().is_empty() {
125        return Err(ImageVectorLoadError::UnsupportedFeature(
126            "clip paths, masks, and filters are not supported".to_string(),
127        ));
128    }
129
130    if group.blend_mode() != BlendMode::Normal {
131        return Err(ImageVectorLoadError::UnsupportedFeature(
132            "non-normal blend modes".to_string(),
133        ));
134    }
135
136    let accumulated_opacity = inherited_opacity * group.opacity().get();
137
138    for node in group.children() {
139        match node {
140            Node::Group(child) => visit_group(child, accumulated_opacity, collector)?,
141            Node::Path(path) => collector.process_path(path, accumulated_opacity)?,
142            Node::Image(_) | Node::Text(_) => {
143                return Err(ImageVectorLoadError::UnsupportedFeature(
144                    "non-path nodes in SVG are not supported".to_string(),
145                ));
146            }
147        }
148    }
149
150    Ok(())
151}
152
153struct VectorGeometryCollector {
154    viewport_width: f32,
155    viewport_height: f32,
156    buffers: VertexBuffers<ImageVectorVertex, u32>,
157}
158
159impl VectorGeometryCollector {
160    fn new(viewport_width: f32, viewport_height: f32) -> Self {
161        Self {
162            viewport_width,
163            viewport_height,
164            buffers: VertexBuffers::new(),
165        }
166    }
167
168    fn process_path(
169        &mut self,
170        path: &Path,
171        inherited_opacity: f32,
172    ) -> Result<(), ImageVectorLoadError> {
173        if !path.is_visible() {
174            return Ok(());
175        }
176
177        if path.rendering_mode() != usvg::ShapeRendering::default() {
178            return Err(ImageVectorLoadError::UnsupportedFeature(
179                "shape-rendering modes are not supported".to_string(),
180            ));
181        }
182
183        let lyon_path = convert_to_lyon_path(path)?;
184
185        match path.paint_order() {
186            PaintOrder::FillAndStroke => {
187                if let Some(fill) = path.fill() {
188                    self.tessellate_fill(&lyon_path, fill, inherited_opacity)?;
189                }
190                if let Some(stroke) = path.stroke() {
191                    self.tessellate_stroke(&lyon_path, stroke, inherited_opacity)?;
192                }
193            }
194            PaintOrder::StrokeAndFill => {
195                if let Some(stroke) = path.stroke() {
196                    self.tessellate_stroke(&lyon_path, stroke, inherited_opacity)?;
197                }
198                if let Some(fill) = path.fill() {
199                    self.tessellate_fill(&lyon_path, fill, inherited_opacity)?;
200                }
201            }
202        }
203
204        Ok(())
205    }
206
207    fn tessellate_fill(
208        &mut self,
209        path: &LyonPath,
210        fill: &usvg::Fill,
211        inherited_opacity: f32,
212    ) -> Result<(), ImageVectorLoadError> {
213        let color = color_from_paint(fill.paint(), fill.opacity().get(), inherited_opacity)?;
214        let fill_rule = match fill.rule() {
215            FillRule::EvenOdd => LyonFillRule::EvenOdd,
216            FillRule::NonZero => LyonFillRule::NonZero,
217        };
218
219        let options = FillOptions::default().with_fill_rule(fill_rule);
220        let viewport = [self.viewport_width, self.viewport_height];
221
222        FillTessellator::new().tessellate_path(
223            path,
224            &options,
225            &mut BuffersBuilder::new(&mut self.buffers, |vertex: FillVertex| {
226                ImageVectorVertex::new(vertex.position().to_array(), color, viewport)
227            }),
228        )?;
229
230        Ok(())
231    }
232
233    fn tessellate_stroke(
234        &mut self,
235        path: &LyonPath,
236        stroke: &Stroke,
237        inherited_opacity: f32,
238    ) -> Result<(), ImageVectorLoadError> {
239        if stroke.dasharray().is_some() {
240            return Err(ImageVectorLoadError::UnsupportedFeature(
241                "stroke dash arrays".to_string(),
242            ));
243        }
244
245        let color = color_from_paint(stroke.paint(), stroke.opacity().get(), inherited_opacity)?;
246
247        let mut options = StrokeOptions::default()
248            .with_line_width(stroke.width().get())
249            .with_line_cap(map_line_cap(stroke.linecap()))
250            .with_line_join(map_line_join(stroke.linejoin()));
251
252        options.miter_limit = stroke.miterlimit().get();
253
254        let viewport = [self.viewport_width, self.viewport_height];
255
256        StrokeTessellator::new().tessellate_path(
257            path,
258            &options,
259            &mut BuffersBuilder::new(&mut self.buffers, |vertex: StrokeVertex| {
260                ImageVectorVertex::new(vertex.position().to_array(), color, viewport)
261            }),
262        )?;
263
264        Ok(())
265    }
266
267    fn finish(self) -> Result<ImageVectorData, ImageVectorLoadError> {
268        if self.buffers.vertices.is_empty() || self.buffers.indices.is_empty() {
269            return Err(ImageVectorLoadError::EmptyGeometry);
270        }
271
272        Ok(ImageVectorData::new(
273            self.viewport_width,
274            self.viewport_height,
275            Arc::new(self.buffers.vertices),
276            Arc::new(self.buffers.indices),
277        ))
278    }
279}
280
281fn color_from_paint(
282    paint: &Paint,
283    paint_opacity: f32,
284    inherited_opacity: f32,
285) -> Result<Color, ImageVectorLoadError> {
286    let opacity = (paint_opacity * inherited_opacity).clamp(0.0, 1.0);
287    match paint {
288        Paint::Color(color) => Ok(Color::new(
289            f32::from(color.red) / 255.0,
290            f32::from(color.green) / 255.0,
291            f32::from(color.blue) / 255.0,
292            opacity,
293        )),
294        _ => Err(ImageVectorLoadError::UnsupportedFeature(
295            "only solid color fills and strokes are supported".to_string(),
296        )),
297    }
298}
299
300fn convert_to_lyon_path(path: &Path) -> Result<LyonPath, ImageVectorLoadError> {
301    let transformed = path
302        .data()
303        .clone()
304        .transform(path.abs_transform())
305        .ok_or(ImageVectorLoadError::TransformFailed)?;
306
307    let mut builder = LyonPath::builder().with_svg();
308    for segment in transformed.segments() {
309        match segment {
310            PathSegment::MoveTo(p0) => {
311                builder.move_to(point(p0.x, p0.y));
312            }
313            PathSegment::LineTo(p0) => {
314                builder.line_to(point(p0.x, p0.y));
315            }
316            PathSegment::QuadTo(p0, p1) => {
317                builder.quadratic_bezier_to(point(p0.x, p0.y), point(p1.x, p1.y));
318            }
319            PathSegment::CubicTo(p0, p1, p2) => {
320                builder.cubic_bezier_to(point(p0.x, p0.y), point(p1.x, p1.y), point(p2.x, p2.y));
321            }
322            PathSegment::Close => {
323                builder.close();
324            }
325        }
326    }
327
328    Ok(builder.build())
329}
330
331fn map_line_cap(cap: SvgLineCap) -> LyonLineCap {
332    match cap {
333        SvgLineCap::Butt => LyonLineCap::Butt,
334        SvgLineCap::Round => LyonLineCap::Round,
335        SvgLineCap::Square => LyonLineCap::Square,
336    }
337}
338
339fn map_line_join(join: SvgLineJoin) -> LyonLineJoin {
340    match join {
341        SvgLineJoin::Miter | SvgLineJoin::MiterClip => LyonLineJoin::Miter,
342        SvgLineJoin::Round => LyonLineJoin::Round,
343        SvgLineJoin::Bevel => LyonLineJoin::Bevel,
344    }
345}
346
347impl ImageVectorVertex {
348    fn new(position: [f32; 2], color: Color, viewport: [f32; 2]) -> Self {
349        ImageVectorVertex {
350            position: [position[0] / viewport[0], position[1] / viewport[1]],
351            color,
352        }
353    }
354}