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 derive_builder::Builder;
11use lyon_geom::point;
12use lyon_path::Path as LyonPath;
13use lyon_tessellation::{
14    BuffersBuilder, FillOptions, FillRule as LyonFillRule, FillTessellator, FillVertex,
15    LineCap as LyonLineCap, LineJoin as LyonLineJoin, StrokeOptions, StrokeTessellator,
16    StrokeVertex, VertexBuffers,
17};
18use tessera_ui::{Color, ComputedData, Constraint, DimensionValue, Px, tessera};
19use thiserror::Error;
20use usvg::{
21    BlendMode, FillRule, Group, LineCap as SvgLineCap, LineJoin as SvgLineJoin, Node, Paint,
22    PaintOrder, Path, Stroke, Tree, tiny_skia_path::PathSegment,
23};
24
25use crate::pipelines::image_vector::{ImageVectorCommand, ImageVectorVertex};
26
27pub use crate::pipelines::image_vector::ImageVectorData;
28
29/// Source for loading SVG vector data.
30#[derive(Clone, Debug)]
31pub enum ImageVectorSource {
32    /// Load from a filesystem path.
33    Path(String),
34    /// Load from in-memory bytes.
35    Bytes(Arc<[u8]>),
36}
37
38/// Errors that can occur while decoding or tessellating vector images.
39#[derive(Debug, Error)]
40pub enum ImageVectorLoadError {
41    /// Failed to read a file from disk.
42    #[error("failed to read SVG from {path}: {source}")]
43    Io {
44        /// Failing path.
45        path: String,
46        /// Underlying IO error.
47        #[source]
48        source: std::io::Error,
49    },
50    /// SVG parsing failed.
51    #[error("failed to parse SVG: {0}")]
52    Parse(#[from] usvg::Error),
53    /// The SVG viewport dimensions are invalid.
54    #[error("SVG viewport must have finite, positive size")]
55    InvalidViewport,
56    /// Encountered an SVG feature that isn't supported yet.
57    #[error("unsupported SVG feature: {0}")]
58    UnsupportedFeature(String),
59    /// Failed to apply the absolute transform for a path.
60    #[error("failed to apply SVG transforms")]
61    TransformFailed,
62    /// Tessellation of the path geometry failed.
63    #[error("tessellation error: {0}")]
64    Tessellation(#[from] lyon_tessellation::TessellationError),
65    /// No renderable geometry was produced.
66    #[error("SVG produced no renderable paths")]
67    EmptyGeometry,
68}
69
70/// Load [`ImageVectorData`] from the provided source.
71pub fn load_image_vector_from_source(
72    source: &ImageVectorSource,
73) -> Result<ImageVectorData, ImageVectorLoadError> {
74    let (bytes, resources_dir) = read_source_bytes(source)?;
75
76    let mut options = usvg::Options::default();
77    options.resources_dir = resources_dir;
78    let tree = Tree::from_data(&bytes, &options)?;
79
80    build_vector_data(&tree)
81}
82
83/// Arguments for [`image_vector`].
84#[derive(Debug, Builder, Clone)]
85#[builder(pattern = "owned")]
86pub struct ImageVectorArgs {
87    /// Vector geometry to render.
88    #[builder(setter(into))]
89    pub data: Arc<ImageVectorData>,
90    /// Desired width, defaults to wrapping at intrinsic size.
91    #[builder(default = "DimensionValue::WRAP", setter(into))]
92    pub width: DimensionValue,
93    /// Desired height, defaults to wrapping at intrinsic size.
94    #[builder(default = "DimensionValue::WRAP", setter(into))]
95    pub height: DimensionValue,
96    /// Optional tint applied multiplicatively to the SVG colors.
97    #[builder(default = "Color::WHITE")]
98    pub tint: Color,
99}
100
101impl From<ImageVectorData> for ImageVectorArgs {
102    fn from(data: ImageVectorData) -> Self {
103        ImageVectorArgsBuilder::default()
104            .data(Arc::new(data))
105            .build()
106            .expect("ImageVectorArgsBuilder failed with required fields set")
107    }
108}
109
110#[tessera]
111pub fn image_vector(args: impl Into<ImageVectorArgs>) {
112    let image_args: ImageVectorArgs = args.into();
113
114    measure(Box::new(move |input| {
115        let intrinsic_width = px_from_f32(image_args.data.viewport_width);
116        let intrinsic_height = px_from_f32(image_args.data.viewport_height);
117
118        let constraint = Constraint::new(image_args.width, image_args.height);
119        let effective_constraint = constraint.merge(input.parent_constraint);
120
121        let width = match effective_constraint.width {
122            DimensionValue::Fixed(value) => value,
123            DimensionValue::Wrap { min, max } => min
124                .unwrap_or(Px(0))
125                .max(intrinsic_width)
126                .min(max.unwrap_or(Px::MAX)),
127            DimensionValue::Fill { min, max } => {
128                let parent_max = input.parent_constraint.width.get_max().unwrap_or(Px::MAX);
129                max.unwrap_or(parent_max)
130                    .max(min.unwrap_or(Px(0)))
131                    .max(intrinsic_width)
132            }
133        };
134
135        let height = match effective_constraint.height {
136            DimensionValue::Fixed(value) => value,
137            DimensionValue::Wrap { min, max } => min
138                .unwrap_or(Px(0))
139                .max(intrinsic_height)
140                .min(max.unwrap_or(Px::MAX)),
141            DimensionValue::Fill { min, max } => {
142                let parent_max = input.parent_constraint.height.get_max().unwrap_or(Px::MAX);
143                max.unwrap_or(parent_max)
144                    .max(min.unwrap_or(Px(0)))
145                    .max(intrinsic_height)
146            }
147        };
148
149        let command = ImageVectorCommand {
150            data: image_args.data.clone(),
151            tint: image_args.tint,
152        };
153
154        input
155            .metadatas
156            .entry(input.current_node_id)
157            .or_default()
158            .push_draw_command(command);
159
160        Ok(ComputedData { width, height })
161    }));
162}
163
164fn px_from_f32(value: f32) -> Px {
165    let clamped = value.max(0.0).min(i32::MAX as f32);
166    Px(clamped.round() as i32)
167}
168
169fn read_source_bytes(
170    source: &ImageVectorSource,
171) -> Result<(Vec<u8>, Option<std::path::PathBuf>), ImageVectorLoadError> {
172    match source {
173        ImageVectorSource::Path(path) => {
174            let bytes = fs::read(path).map_err(|source| ImageVectorLoadError::Io {
175                path: path.clone(),
176                source,
177            })?;
178            let dir = StdPath::new(path).parent().map(|p| p.to_path_buf());
179            Ok((bytes, dir))
180        }
181        ImageVectorSource::Bytes(bytes) => Ok((bytes.as_ref().to_vec(), None)),
182    }
183}
184
185fn build_vector_data(tree: &Tree) -> Result<ImageVectorData, ImageVectorLoadError> {
186    let size = tree.size();
187    let viewport_width = size.width();
188    let viewport_height = size.height();
189
190    if !viewport_width.is_finite()
191        || !viewport_height.is_finite()
192        || viewport_width <= 0.0
193        || viewport_height <= 0.0
194    {
195        return Err(ImageVectorLoadError::InvalidViewport);
196    }
197
198    let mut collector = VectorGeometryCollector::new(viewport_width, viewport_height);
199    visit_group(tree.root(), 1.0, &mut collector)?;
200
201    collector.finish()
202}
203
204fn visit_group(
205    group: &Group,
206    inherited_opacity: f32,
207    collector: &mut VectorGeometryCollector,
208) -> Result<(), ImageVectorLoadError> {
209    if group.clip_path().is_some() || group.mask().is_some() || !group.filters().is_empty() {
210        return Err(ImageVectorLoadError::UnsupportedFeature(
211            "clip paths, masks, and filters are not supported".to_string(),
212        ));
213    }
214
215    if group.blend_mode() != BlendMode::Normal {
216        return Err(ImageVectorLoadError::UnsupportedFeature(
217            "non-normal blend modes".to_string(),
218        ));
219    }
220
221    let accumulated_opacity = inherited_opacity * group.opacity().get();
222
223    for node in group.children() {
224        match node {
225            Node::Group(child) => visit_group(child, accumulated_opacity, collector)?,
226            Node::Path(path) => collector.process_path(path, accumulated_opacity)?,
227            Node::Image(_) | Node::Text(_) => {
228                return Err(ImageVectorLoadError::UnsupportedFeature(
229                    "non-path nodes in SVG are not supported".to_string(),
230                ));
231            }
232        }
233    }
234
235    Ok(())
236}
237
238struct VectorGeometryCollector {
239    viewport_width: f32,
240    viewport_height: f32,
241    buffers: VertexBuffers<ImageVectorVertex, u32>,
242}
243
244impl VectorGeometryCollector {
245    fn new(viewport_width: f32, viewport_height: f32) -> Self {
246        Self {
247            viewport_width,
248            viewport_height,
249            buffers: VertexBuffers::new(),
250        }
251    }
252
253    fn process_path(
254        &mut self,
255        path: &Path,
256        inherited_opacity: f32,
257    ) -> Result<(), ImageVectorLoadError> {
258        if !path.is_visible() {
259            return Ok(());
260        }
261
262        if path.rendering_mode() != usvg::ShapeRendering::default() {
263            return Err(ImageVectorLoadError::UnsupportedFeature(
264                "shape-rendering modes are not supported".to_string(),
265            ));
266        }
267
268        let lyon_path = convert_to_lyon_path(path)?;
269
270        match path.paint_order() {
271            PaintOrder::FillAndStroke => {
272                if let Some(fill) = path.fill() {
273                    self.tessellate_fill(&lyon_path, fill, inherited_opacity)?;
274                }
275                if let Some(stroke) = path.stroke() {
276                    self.tessellate_stroke(&lyon_path, stroke, inherited_opacity)?;
277                }
278            }
279            PaintOrder::StrokeAndFill => {
280                if let Some(stroke) = path.stroke() {
281                    self.tessellate_stroke(&lyon_path, stroke, inherited_opacity)?;
282                }
283                if let Some(fill) = path.fill() {
284                    self.tessellate_fill(&lyon_path, fill, inherited_opacity)?;
285                }
286            }
287        }
288
289        Ok(())
290    }
291
292    fn tessellate_fill(
293        &mut self,
294        path: &LyonPath,
295        fill: &usvg::Fill,
296        inherited_opacity: f32,
297    ) -> Result<(), ImageVectorLoadError> {
298        let color = color_from_paint(fill.paint(), fill.opacity().get(), inherited_opacity)?;
299        let fill_rule = match fill.rule() {
300            FillRule::EvenOdd => LyonFillRule::EvenOdd,
301            FillRule::NonZero => LyonFillRule::NonZero,
302        };
303
304        let options = FillOptions::default().with_fill_rule(fill_rule);
305        let viewport = [self.viewport_width, self.viewport_height];
306
307        FillTessellator::new().tessellate_path(
308            path,
309            &options,
310            &mut BuffersBuilder::new(&mut self.buffers, |vertex: FillVertex| {
311                ImageVectorVertex::new(vertex.position().to_array(), color, viewport)
312            }),
313        )?;
314
315        Ok(())
316    }
317
318    fn tessellate_stroke(
319        &mut self,
320        path: &LyonPath,
321        stroke: &Stroke,
322        inherited_opacity: f32,
323    ) -> Result<(), ImageVectorLoadError> {
324        if stroke.dasharray().is_some() {
325            return Err(ImageVectorLoadError::UnsupportedFeature(
326                "stroke dash arrays".to_string(),
327            ));
328        }
329
330        let color = color_from_paint(stroke.paint(), stroke.opacity().get(), inherited_opacity)?;
331
332        let mut options = StrokeOptions::default()
333            .with_line_width(stroke.width().get())
334            .with_line_cap(map_line_cap(stroke.linecap()))
335            .with_line_join(map_line_join(stroke.linejoin()));
336
337        options.miter_limit = stroke.miterlimit().get();
338
339        let viewport = [self.viewport_width, self.viewport_height];
340
341        StrokeTessellator::new().tessellate_path(
342            path,
343            &options,
344            &mut BuffersBuilder::new(&mut self.buffers, |vertex: StrokeVertex| {
345                ImageVectorVertex::new(vertex.position().to_array(), color, viewport)
346            }),
347        )?;
348
349        Ok(())
350    }
351
352    fn finish(self) -> Result<ImageVectorData, ImageVectorLoadError> {
353        if self.buffers.vertices.is_empty() || self.buffers.indices.is_empty() {
354            return Err(ImageVectorLoadError::EmptyGeometry);
355        }
356
357        Ok(ImageVectorData::new(
358            self.viewport_width,
359            self.viewport_height,
360            Arc::new(self.buffers.vertices),
361            Arc::new(self.buffers.indices),
362        ))
363    }
364}
365
366fn color_from_paint(
367    paint: &Paint,
368    paint_opacity: f32,
369    inherited_opacity: f32,
370) -> Result<Color, ImageVectorLoadError> {
371    let opacity = (paint_opacity * inherited_opacity).clamp(0.0, 1.0);
372    match paint {
373        Paint::Color(color) => Ok(Color::new(
374            f32::from(color.red) / 255.0,
375            f32::from(color.green) / 255.0,
376            f32::from(color.blue) / 255.0,
377            opacity,
378        )),
379        _ => Err(ImageVectorLoadError::UnsupportedFeature(
380            "only solid color fills and strokes are supported".to_string(),
381        )),
382    }
383}
384
385fn convert_to_lyon_path(path: &Path) -> Result<LyonPath, ImageVectorLoadError> {
386    let transformed = path
387        .data()
388        .clone()
389        .transform(path.abs_transform())
390        .ok_or(ImageVectorLoadError::TransformFailed)?;
391
392    let mut builder = LyonPath::builder().with_svg();
393    for segment in transformed.segments() {
394        match segment {
395            PathSegment::MoveTo(p0) => {
396                builder.move_to(point(p0.x, p0.y));
397            }
398            PathSegment::LineTo(p0) => {
399                builder.line_to(point(p0.x, p0.y));
400            }
401            PathSegment::QuadTo(p0, p1) => {
402                builder.quadratic_bezier_to(point(p0.x, p0.y), point(p1.x, p1.y));
403            }
404            PathSegment::CubicTo(p0, p1, p2) => {
405                builder.cubic_bezier_to(point(p0.x, p0.y), point(p1.x, p1.y), point(p2.x, p2.y));
406            }
407            PathSegment::Close => {
408                builder.close();
409            }
410        }
411    }
412
413    Ok(builder.build())
414}
415
416fn map_line_cap(cap: SvgLineCap) -> LyonLineCap {
417    match cap {
418        SvgLineCap::Butt => LyonLineCap::Butt,
419        SvgLineCap::Round => LyonLineCap::Round,
420        SvgLineCap::Square => LyonLineCap::Square,
421    }
422}
423
424fn map_line_join(join: SvgLineJoin) -> LyonLineJoin {
425    match join {
426        SvgLineJoin::Miter | SvgLineJoin::MiterClip => LyonLineJoin::Miter,
427        SvgLineJoin::Round => LyonLineJoin::Round,
428        SvgLineJoin::Bevel => LyonLineJoin::Bevel,
429    }
430}
431
432impl ImageVectorVertex {
433    fn new(position: [f32; 2], color: Color, viewport: [f32; 2]) -> Self {
434        ImageVectorVertex {
435            position: [position[0] / viewport[0], position[1] / viewport[1]],
436            color,
437        }
438    }
439}