Skip to main content

aetna_core/
vector.rs

1//! Backend-agnostic SVG/vector asset IR.
2//!
3//! `usvg` owns SVG normalization: XML, inherited style, transforms,
4//! arcs, relative commands, and basic shapes are resolved before Aetna
5//! stores anything. The renderer-facing IR below is deliberately small:
6//! paths plus fill/stroke style. Backends can tessellate it with lyon or
7//! feed it into more specialized vector shaders later.
8
9use std::error::Error;
10use std::fmt;
11
12use crate::paint::rgba_f32;
13use crate::tree::Color;
14
15use bytemuck::{Pod, Zeroable};
16use lyon_tessellation::geometry_builder::{BuffersBuilder, VertexBuffers};
17use lyon_tessellation::math::point;
18use lyon_tessellation::path::Path as LyonPath;
19use lyon_tessellation::{
20    FillOptions, FillTessellator, FillVertex, LineCap, LineJoin, StrokeOptions, StrokeTessellator,
21    StrokeVertex,
22};
23use usvg::tiny_skia_path;
24
25#[derive(Clone, Debug, PartialEq)]
26pub struct VectorAsset {
27    pub view_box: [f32; 4],
28    pub paths: Vec<VectorPath>,
29}
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct VectorPath {
33    pub segments: Vec<VectorSegment>,
34    pub fill: Option<VectorFill>,
35    pub stroke: Option<VectorStroke>,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub enum VectorSegment {
40    MoveTo([f32; 2]),
41    LineTo([f32; 2]),
42    QuadTo([f32; 2], [f32; 2]),
43    CubicTo([f32; 2], [f32; 2], [f32; 2]),
44    Close,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct VectorFill {
49    pub color: VectorColor,
50    pub opacity: f32,
51    pub rule: VectorFillRule,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct VectorStroke {
56    pub color: VectorColor,
57    pub opacity: f32,
58    pub width: f32,
59    pub line_cap: VectorLineCap,
60    pub line_join: VectorLineJoin,
61    pub miter_limit: f32,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub enum VectorColor {
66    CurrentColor,
67    Solid(Color),
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum VectorFillRule {
72    NonZero,
73    EvenOdd,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum VectorLineCap {
78    Butt,
79    Round,
80    Square,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum VectorLineJoin {
85    Miter,
86    MiterClip,
87    Round,
88    Bevel,
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub enum IconMaterial {
93    /// Direct premultiplied color. This is the baseline material and
94    /// should match ordinary flat SVG rendering.
95    #[default]
96    Flat,
97    /// A proof material that uses local vector coordinates to add a
98    /// subtle top-left highlight and lower shadow. This exists to prove
99    /// the shared mesh carries enough data for shader-controlled icon
100    /// treatments.
101    Relief,
102    /// A glossy icon material with local-coordinate glints and a soft
103    /// inner shade. Pairs with translucent/glass surfaces.
104    Glass,
105}
106
107#[repr(C)]
108#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
109pub struct VectorMeshVertex {
110    /// Logical-pixel position after fitting the vector asset into its
111    /// destination rect.
112    pub pos: [f32; 2],
113    /// SVG/viewBox-space coordinate. Theme shaders can use this for
114    /// gradients, highlights, bevels, and other icon-local effects.
115    pub local: [f32; 2],
116    pub color: [f32; 4],
117    /// Reserved for material shaders: x = path index, y = primitive
118    /// kind (0 fill, 1 stroke), z/w reserved.
119    pub meta: [f32; 4],
120    /// Analytic-AA extrusion: a unit normal in logical px (zero for
121    /// solid interior verts). The vertex shader extrudes the position
122    /// by `aa * (1 / scale_factor)` so the fringe stays one **physical**
123    /// pixel wide regardless of icon render size, and emits a
124    /// per-vertex coverage of 1 for `aa == 0` and 0 for nonzero `aa` so
125    /// the fragment interpolates a smooth 1-px alpha ramp at the edge.
126    pub aa: [f32; 2],
127}
128
129#[derive(Clone, Debug, Default, PartialEq)]
130pub struct VectorMesh {
131    pub vertices: Vec<VectorMeshVertex>,
132}
133
134#[derive(Clone, Copy, Debug, PartialEq)]
135pub struct VectorMeshRun {
136    pub first: u32,
137    pub count: u32,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq)]
141pub struct VectorMeshOptions {
142    pub rect: crate::tree::Rect,
143    pub current_color: Color,
144    pub stroke_width: f32,
145    pub tolerance: f32,
146}
147
148impl VectorMeshOptions {
149    pub fn icon(rect: crate::tree::Rect, current_color: Color, stroke_width: f32) -> Self {
150        Self {
151            rect,
152            current_color,
153            stroke_width,
154            tolerance: 0.05,
155        }
156    }
157}
158
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct VectorParseError {
161    message: String,
162}
163
164impl VectorParseError {
165    fn new(message: impl Into<String>) -> Self {
166        Self {
167            message: message.into(),
168        }
169    }
170}
171
172impl fmt::Display for VectorParseError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(&self.message)
175    }
176}
177
178impl Error for VectorParseError {}
179
180pub fn parse_svg_asset(svg: &str) -> Result<VectorAsset, VectorParseError> {
181    parse_svg_asset_with_color_mode(svg, false)
182}
183
184pub fn tessellate_vector_asset(asset: &VectorAsset, options: VectorMeshOptions) -> VectorMesh {
185    let mut mesh = VectorMesh::default();
186    append_vector_asset_mesh(asset, options, &mut mesh.vertices);
187    mesh
188}
189
190pub fn append_vector_asset_mesh(
191    asset: &VectorAsset,
192    options: VectorMeshOptions,
193    out: &mut Vec<VectorMeshVertex>,
194) -> VectorMeshRun {
195    let first = out.len() as u32;
196    if options.rect.w <= 0.0 || options.rect.h <= 0.0 {
197        return VectorMeshRun { first, count: 0 };
198    }
199
200    let [vx, vy, vw, vh] = asset.view_box;
201    let sx = options.rect.w / vw.max(1.0);
202    let sy = options.rect.h / vh.max(1.0);
203    let stroke_scale = (sx + sy) * 0.5;
204
205    for (path_index, vector_path) in asset.paths.iter().enumerate() {
206        let path = build_lyon_path(vector_path, options.rect, [vx, vy], [sx, sy]);
207        if let Some(fill) = vector_path.fill {
208            let color = resolve_color(fill.color, options.current_color, fill.opacity);
209            let mut geometry: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
210            let fill_options =
211                FillOptions::tolerance(options.tolerance).with_fill_rule(match fill.rule {
212                    VectorFillRule::NonZero => lyon_tessellation::FillRule::NonZero,
213                    VectorFillRule::EvenOdd => lyon_tessellation::FillRule::EvenOdd,
214                });
215            let _ = FillTessellator::new().tessellate_path(
216                &path,
217                &fill_options,
218                &mut BuffersBuilder::new(&mut geometry, |v: FillVertex<'_>| {
219                    make_mesh_vertex(
220                        v.position(),
221                        options.rect,
222                        [vx, vy],
223                        [sx, sy],
224                        color,
225                        path_index,
226                        VectorPrimitiveKind::Fill,
227                    )
228                }),
229            );
230            append_indexed(&geometry, out);
231
232            // Analytic-AA fringe: a thin band centred on the fill
233            // boundary. Inner verts (path side) carry `aa = 0` so they
234            // sit exactly on the fill edge with full coverage; outer
235            // verts carry the unit normal so the vertex shader extrudes
236            // them by 1 physical pixel and they fade to zero coverage.
237            // The fragment alpha-interpolates between the two. Inside
238            // the fill the band overlaps existing fill triangles, which
239            // are already fully covered — alpha-blending leaves them at
240            // 1, so the only visible effect is the outward fade.
241            let mut fringe: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
242            // Width=1 logical unit puts the stroke verts ±0.5 px from
243            // the path; we rebase them onto the path inside the
244            // constructor so the geometry is anchored at the fill edge,
245            // and reuse the unit normal for shader-side extrusion.
246            let fringe_options = StrokeOptions::tolerance(options.tolerance)
247                .with_line_width(1.0)
248                .with_line_cap(LineCap::Butt)
249                .with_line_join(LineJoin::Miter)
250                .with_miter_limit(4.0);
251            let _ = StrokeTessellator::new().tessellate_path(
252                &path,
253                &fringe_options,
254                &mut BuffersBuilder::new(&mut fringe, |v: StrokeVertex<'_, '_>| {
255                    let position = v.position();
256                    let normal = v.normal();
257                    let side_sign = match v.side() {
258                        lyon_tessellation::Side::Negative => -1.0_f32,
259                        lyon_tessellation::Side::Positive => 1.0_f32,
260                    };
261                    // Move the stroke vert back to the fill boundary.
262                    let path_pos = lyon_tessellation::math::point(
263                        position.x - side_sign * normal.x * 0.5,
264                        position.y - side_sign * normal.y * 0.5,
265                    );
266                    let aa = match v.side() {
267                        lyon_tessellation::Side::Negative => [0.0, 0.0],
268                        lyon_tessellation::Side::Positive => [normal.x, normal.y],
269                    };
270                    make_mesh_vertex_with_aa(
271                        path_pos,
272                        options.rect,
273                        [vx, vy],
274                        [sx, sy],
275                        color,
276                        path_index,
277                        VectorPrimitiveKind::Fill,
278                        aa,
279                    )
280                }),
281            );
282            append_indexed(&fringe, out);
283        }
284
285        if let Some(stroke) = vector_path.stroke {
286            let color = resolve_color(stroke.color, options.current_color, stroke.opacity);
287            let width = if matches!(stroke.color, VectorColor::CurrentColor) {
288                options.stroke_width * stroke_scale
289            } else {
290                stroke.width * stroke_scale
291            }
292            .max(0.5);
293            let mut geometry: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
294            let stroke_options = StrokeOptions::tolerance(options.tolerance)
295                .with_line_width(width)
296                .with_line_cap(match stroke.line_cap {
297                    VectorLineCap::Butt => LineCap::Butt,
298                    VectorLineCap::Round => LineCap::Round,
299                    VectorLineCap::Square => LineCap::Square,
300                })
301                .with_line_join(match stroke.line_join {
302                    VectorLineJoin::Miter => LineJoin::Miter,
303                    VectorLineJoin::MiterClip => LineJoin::MiterClip,
304                    VectorLineJoin::Round => LineJoin::Round,
305                    VectorLineJoin::Bevel => LineJoin::Bevel,
306                })
307                .with_miter_limit(stroke.miter_limit.max(1.0));
308            let _ = StrokeTessellator::new().tessellate_path(
309                &path,
310                &stroke_options,
311                &mut BuffersBuilder::new(&mut geometry, |v: StrokeVertex<'_, '_>| {
312                    make_mesh_vertex(
313                        v.position(),
314                        options.rect,
315                        [vx, vy],
316                        [sx, sy],
317                        color,
318                        path_index,
319                        VectorPrimitiveKind::Stroke,
320                    )
321                }),
322            );
323            append_indexed(&geometry, out);
324        }
325    }
326
327    VectorMeshRun {
328        first,
329        count: out.len() as u32 - first,
330    }
331}
332
333pub(crate) fn parse_current_color_svg_asset(svg: &str) -> Result<VectorAsset, VectorParseError> {
334    parse_svg_asset_with_color_mode(svg, true)
335}
336
337fn parse_svg_asset_with_color_mode(
338    svg: &str,
339    force_current_color: bool,
340) -> Result<VectorAsset, VectorParseError> {
341    let tree = usvg::Tree::from_str(svg, &usvg::Options::default())
342        .map_err(|e| VectorParseError::new(format!("invalid SVG: {e}")))?;
343    let size = tree.size();
344    let mut asset = VectorAsset {
345        view_box: [0.0, 0.0, size.width(), size.height()],
346        paths: Vec::new(),
347    };
348    collect_group(tree.root(), force_current_color, &mut asset.paths);
349    if asset.paths.is_empty() {
350        return Err(VectorParseError::new("SVG produced no renderable paths"));
351    }
352    Ok(asset)
353}
354
355fn collect_group(group: &usvg::Group, force_current_color: bool, out: &mut Vec<VectorPath>) {
356    for node in group.children() {
357        match node {
358            usvg::Node::Group(group) => collect_group(group, force_current_color, out),
359            usvg::Node::Path(path) if path.is_visible() => {
360                if let Some(vector_path) = convert_path(path, force_current_color) {
361                    out.push(vector_path);
362                }
363            }
364            _ => {}
365        }
366    }
367}
368
369fn convert_path(path: &usvg::Path, force_current_color: bool) -> Option<VectorPath> {
370    let transform = path.abs_transform();
371    let mut segments = Vec::new();
372    for segment in path.data().segments() {
373        match segment {
374            tiny_skia_path::PathSegment::MoveTo(p) => {
375                segments.push(VectorSegment::MoveTo(map_point(transform, p)));
376            }
377            tiny_skia_path::PathSegment::LineTo(p) => {
378                segments.push(VectorSegment::LineTo(map_point(transform, p)));
379            }
380            tiny_skia_path::PathSegment::QuadTo(p0, p1) => {
381                segments.push(VectorSegment::QuadTo(
382                    map_point(transform, p0),
383                    map_point(transform, p1),
384                ));
385            }
386            tiny_skia_path::PathSegment::CubicTo(p0, p1, p2) => {
387                segments.push(VectorSegment::CubicTo(
388                    map_point(transform, p0),
389                    map_point(transform, p1),
390                    map_point(transform, p2),
391                ));
392            }
393            tiny_skia_path::PathSegment::Close => segments.push(VectorSegment::Close),
394        }
395    }
396    if segments.is_empty() {
397        return None;
398    }
399
400    Some(VectorPath {
401        segments,
402        fill: path
403            .fill()
404            .and_then(|fill| convert_fill(fill, force_current_color)),
405        stroke: path
406            .stroke()
407            .and_then(|stroke| convert_stroke(stroke, force_current_color)),
408    })
409}
410
411fn convert_fill(fill: &usvg::Fill, force_current_color: bool) -> Option<VectorFill> {
412    Some(VectorFill {
413        color: convert_paint(fill.paint(), force_current_color)?,
414        opacity: fill.opacity().get(),
415        rule: match fill.rule() {
416            usvg::FillRule::NonZero => VectorFillRule::NonZero,
417            usvg::FillRule::EvenOdd => VectorFillRule::EvenOdd,
418        },
419    })
420}
421
422fn convert_stroke(stroke: &usvg::Stroke, force_current_color: bool) -> Option<VectorStroke> {
423    Some(VectorStroke {
424        color: convert_paint(stroke.paint(), force_current_color)?,
425        opacity: stroke.opacity().get(),
426        width: stroke.width().get(),
427        line_cap: match stroke.linecap() {
428            usvg::LineCap::Butt => VectorLineCap::Butt,
429            usvg::LineCap::Round => VectorLineCap::Round,
430            usvg::LineCap::Square => VectorLineCap::Square,
431        },
432        line_join: match stroke.linejoin() {
433            usvg::LineJoin::Miter => VectorLineJoin::Miter,
434            usvg::LineJoin::MiterClip => VectorLineJoin::MiterClip,
435            usvg::LineJoin::Round => VectorLineJoin::Round,
436            usvg::LineJoin::Bevel => VectorLineJoin::Bevel,
437        },
438        miter_limit: stroke.miterlimit().get(),
439    })
440}
441
442fn convert_paint(paint: &usvg::Paint, force_current_color: bool) -> Option<VectorColor> {
443    if force_current_color {
444        return Some(VectorColor::CurrentColor);
445    }
446    match paint {
447        usvg::Paint::Color(c) => Some(VectorColor::Solid(Color::rgba(c.red, c.green, c.blue, 255))),
448        usvg::Paint::LinearGradient(_)
449        | usvg::Paint::RadialGradient(_)
450        | usvg::Paint::Pattern(_) => None,
451    }
452}
453
454fn map_point(transform: tiny_skia_path::Transform, mut point: tiny_skia_path::Point) -> [f32; 2] {
455    transform.map_point(&mut point);
456    [point.x, point.y]
457}
458
459#[derive(Clone, Copy)]
460enum VectorPrimitiveKind {
461    Fill,
462    Stroke,
463}
464
465fn build_lyon_path(
466    path: &VectorPath,
467    rect: crate::tree::Rect,
468    view_origin: [f32; 2],
469    scale: [f32; 2],
470) -> LyonPath {
471    let mut builder = LyonPath::builder();
472    let mut open = false;
473    for segment in &path.segments {
474        match *segment {
475            VectorSegment::MoveTo(p) => {
476                if open {
477                    builder.end(false);
478                }
479                builder.begin(map_mesh_point(rect, view_origin, scale, p));
480                open = true;
481            }
482            VectorSegment::LineTo(p) => {
483                builder.line_to(map_mesh_point(rect, view_origin, scale, p));
484            }
485            VectorSegment::QuadTo(c, p) => {
486                builder.quadratic_bezier_to(
487                    map_mesh_point(rect, view_origin, scale, c),
488                    map_mesh_point(rect, view_origin, scale, p),
489                );
490            }
491            VectorSegment::CubicTo(c0, c1, p) => {
492                builder.cubic_bezier_to(
493                    map_mesh_point(rect, view_origin, scale, c0),
494                    map_mesh_point(rect, view_origin, scale, c1),
495                    map_mesh_point(rect, view_origin, scale, p),
496                );
497            }
498            VectorSegment::Close => {
499                if open {
500                    builder.close();
501                    open = false;
502                }
503            }
504        }
505    }
506    if open {
507        builder.end(false);
508    }
509    builder.build()
510}
511
512fn map_mesh_point(
513    rect: crate::tree::Rect,
514    view_origin: [f32; 2],
515    scale: [f32; 2],
516    p: [f32; 2],
517) -> lyon_tessellation::math::Point {
518    point(
519        rect.x + (p[0] - view_origin[0]) * scale[0],
520        rect.y + (p[1] - view_origin[1]) * scale[1],
521    )
522}
523
524fn make_mesh_vertex(
525    p: lyon_tessellation::math::Point,
526    rect: crate::tree::Rect,
527    view_origin: [f32; 2],
528    scale: [f32; 2],
529    color: [f32; 4],
530    path_index: usize,
531    kind: VectorPrimitiveKind,
532) -> VectorMeshVertex {
533    make_mesh_vertex_with_aa(
534        p,
535        rect,
536        view_origin,
537        scale,
538        color,
539        path_index,
540        kind,
541        [0.0, 0.0],
542    )
543}
544
545#[allow(clippy::too_many_arguments)]
546fn make_mesh_vertex_with_aa(
547    p: lyon_tessellation::math::Point,
548    rect: crate::tree::Rect,
549    view_origin: [f32; 2],
550    scale: [f32; 2],
551    color: [f32; 4],
552    path_index: usize,
553    kind: VectorPrimitiveKind,
554    aa: [f32; 2],
555) -> VectorMeshVertex {
556    let local = [
557        view_origin[0] + (p.x - rect.x) / scale[0].max(f32::EPSILON),
558        view_origin[1] + (p.y - rect.y) / scale[1].max(f32::EPSILON),
559    ];
560    VectorMeshVertex {
561        pos: [p.x, p.y],
562        local,
563        color,
564        meta: [
565            path_index as f32,
566            match kind {
567                VectorPrimitiveKind::Fill => 0.0,
568                VectorPrimitiveKind::Stroke => 1.0,
569            },
570            0.0,
571            0.0,
572        ],
573        aa,
574    }
575}
576
577fn resolve_color(color: VectorColor, current_color: Color, opacity: f32) -> [f32; 4] {
578    let mut rgba = match color {
579        VectorColor::CurrentColor => rgba_f32(current_color),
580        VectorColor::Solid(color) => rgba_f32(color),
581    };
582    rgba[3] *= opacity.clamp(0.0, 1.0);
583    rgba
584}
585
586fn append_indexed(
587    geometry: &VertexBuffers<VectorMeshVertex, u16>,
588    out: &mut Vec<VectorMeshVertex>,
589) {
590    for index in &geometry.indices {
591        if let Some(vertex) = geometry.vertices.get(*index as usize) {
592            out.push(*vertex);
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::icons::{all_icon_names, icon_vector_asset};
601
602    #[test]
603    fn parses_basic_svg_shapes_into_paths() {
604        let asset = parse_svg_asset(
605            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="none" stroke="#000" stroke-width="2"/></svg>"##,
606        )
607        .unwrap();
608        assert_eq!(asset.view_box, [0.0, 0.0, 24.0, 24.0]);
609        assert_eq!(asset.paths.len(), 1);
610        assert!(asset.paths[0].stroke.is_some());
611        assert!(asset.paths[0].segments.len() > 4);
612    }
613
614    #[test]
615    fn tessellates_every_builtin_icon() {
616        for name in all_icon_names() {
617            let mesh = tessellate_vector_asset(
618                icon_vector_asset(*name),
619                VectorMeshOptions::icon(
620                    crate::tree::Rect::new(0.0, 0.0, 16.0, 16.0),
621                    Color::rgb(15, 23, 42),
622                    2.0,
623                ),
624            );
625            assert!(
626                !mesh.vertices.is_empty(),
627                "{} produced no tessellated vertices",
628                name.name()
629            );
630        }
631    }
632}