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    /// Gradient table referenced by [`VectorColor::Gradient`] indices. Kept
30    /// as a side-table so [`VectorColor`] stays `Copy`.
31    pub gradients: Vec<VectorGradient>,
32}
33
34/// Render policy for app-supplied [`VectorAsset`]s.
35///
36/// `Painted` preserves authored fills, strokes, gradients, and
37/// `currentColor` paint, so backends use the colour-aware vector path.
38/// `Mask` treats the asset as coverage geometry and applies one caller-
39/// supplied colour, which lets backends use their MSDF atlas path.
40#[derive(Clone, Copy, Debug, Default, PartialEq)]
41pub enum VectorRenderMode {
42    #[default]
43    Painted,
44    Mask {
45        color: Color,
46    },
47}
48
49impl VectorRenderMode {
50    pub fn resolved_palette(self, palette: &crate::palette::Palette) -> Self {
51        match self {
52            Self::Painted => Self::Painted,
53            Self::Mask { color } => Self::Mask {
54                color: palette.resolve(color),
55            },
56        }
57    }
58}
59
60impl VectorAsset {
61    /// Build a [`VectorAsset`] from a list of paths and an explicit view
62    /// box, without going through SVG parsing. The companion to
63    /// [`PathBuilder`] for apps that compose vector content
64    /// programmatically (commit-graph curves, Gantt connectors, custom
65    /// chart marks). Equivalent to setting the public fields directly,
66    /// but documents the construction site and keeps the gradient table
67    /// empty by default.
68    pub fn from_paths(view_box: [f32; 4], paths: Vec<VectorPath>) -> Self {
69        Self {
70            view_box,
71            paths,
72            gradients: Vec::new(),
73        }
74    }
75
76    /// Whether any path's fill or stroke uses a gradient.
77    pub fn has_gradient(&self) -> bool {
78        self.paths.iter().any(|p| {
79            p.fill
80                .map(|f| matches!(f.color, VectorColor::Gradient(_)))
81                .unwrap_or(false)
82                || p.stroke
83                    .map(|s| matches!(s.color, VectorColor::Gradient(_)))
84                    .unwrap_or(false)
85        })
86    }
87
88    /// Return this asset with every solid color resolved through
89    /// `palette`. Token names are preserved by palette resolution, so
90    /// subsequent palette swaps can resolve the same source asset again
91    /// while the resolved RGBA still participates in atlas identity.
92    pub fn resolved_palette(&self, palette: &crate::palette::Palette) -> Self {
93        let mut out = self.clone();
94        for path in &mut out.paths {
95            if let Some(fill) = &mut path.fill {
96                fill.color = resolve_vector_color(fill.color, palette);
97            }
98            if let Some(stroke) = &mut path.stroke {
99                stroke.color = resolve_vector_color(stroke.color, palette);
100            }
101        }
102        out
103    }
104
105    /// Stable content-hash used as a cache key in MSDF / mesh atlases.
106    /// Two assets with identical view box, paths, fills, strokes, and
107    /// gradients hash to the same value — backends dedupe rasterised
108    /// MSDF / tessellated mesh entries on this so an app that builds
109    /// the same curve shape twice (e.g. two commits sharing a merge
110    /// connector geometry) shares one atlas slot.
111    ///
112    /// Floats hash via [`f32::to_bits`] — bitwise-equal-but-arithmetically-
113    /// equal cases (`-0.0` vs `0.0`, `NaN` payloads) are treated as
114    /// distinct, which matches what the atlas cache should do anyway.
115    pub fn content_hash(&self) -> u64 {
116        use std::hash::Hasher;
117        let mut h = StableHasher::new();
118        hash_view_box(&mut h, self.view_box);
119        write_len(&mut h, self.paths.len());
120        for path in &self.paths {
121            hash_path(&mut h, path);
122        }
123        write_len(&mut h, self.gradients.len());
124        for grad in &self.gradients {
125            hash_gradient(&mut h, grad);
126        }
127        h.finish()
128    }
129}
130
131fn resolve_vector_color(color: VectorColor, palette: &crate::palette::Palette) -> VectorColor {
132    match color {
133        VectorColor::Solid(c) => VectorColor::Solid(palette.resolve(c)),
134        VectorColor::CurrentColor | VectorColor::Gradient(_) => color,
135    }
136}
137
138/// A small fixed FNV-1a hasher for persistent-ish vector content
139/// identity. `DefaultHasher` is intentionally not specified by std;
140/// this keeps `VectorAsset::content_hash` deterministic across toolchain
141/// runs and target architectures.
142struct StableHasher {
143    state: u64,
144}
145
146impl StableHasher {
147    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
148    const PRIME: u64 = 0x0000_0100_0000_01b3;
149
150    fn new() -> Self {
151        Self {
152            state: Self::OFFSET,
153        }
154    }
155}
156
157impl std::hash::Hasher for StableHasher {
158    fn write(&mut self, bytes: &[u8]) {
159        for byte in bytes {
160            self.state ^= *byte as u64;
161            self.state = self.state.wrapping_mul(Self::PRIME);
162        }
163    }
164
165    fn finish(&self) -> u64 {
166        self.state
167    }
168}
169
170fn write_len(h: &mut impl std::hash::Hasher, len: usize) {
171    h.write_u64(len as u64);
172}
173
174fn hash_str(h: &mut impl std::hash::Hasher, value: &str) {
175    write_len(h, value.len());
176    h.write(value.as_bytes());
177}
178
179fn hash_view_box(h: &mut impl std::hash::Hasher, vb: [f32; 4]) {
180    for v in vb {
181        h.write_u32(v.to_bits());
182    }
183}
184
185fn hash_path(h: &mut impl std::hash::Hasher, path: &VectorPath) {
186    write_len(h, path.segments.len());
187    for seg in &path.segments {
188        hash_segment(h, seg);
189    }
190    match path.fill {
191        Some(f) => {
192            h.write_u8(1);
193            hash_fill(h, f);
194        }
195        None => h.write_u8(0),
196    }
197    match path.stroke {
198        Some(s) => {
199            h.write_u8(1);
200            hash_stroke(h, s);
201        }
202        None => h.write_u8(0),
203    }
204}
205
206fn hash_segment(h: &mut impl std::hash::Hasher, seg: &VectorSegment) {
207    match *seg {
208        VectorSegment::MoveTo(p) => {
209            h.write_u8(0);
210            hash_pt(h, p);
211        }
212        VectorSegment::LineTo(p) => {
213            h.write_u8(1);
214            hash_pt(h, p);
215        }
216        VectorSegment::QuadTo(c, p) => {
217            h.write_u8(2);
218            hash_pt(h, c);
219            hash_pt(h, p);
220        }
221        VectorSegment::CubicTo(c1, c2, p) => {
222            h.write_u8(3);
223            hash_pt(h, c1);
224            hash_pt(h, c2);
225            hash_pt(h, p);
226        }
227        VectorSegment::Close => h.write_u8(4),
228    }
229}
230
231fn hash_pt(h: &mut impl std::hash::Hasher, p: [f32; 2]) {
232    h.write_u32(p[0].to_bits());
233    h.write_u32(p[1].to_bits());
234}
235
236fn hash_fill(h: &mut impl std::hash::Hasher, f: VectorFill) {
237    hash_color(h, f.color);
238    h.write_u32(f.opacity.to_bits());
239    h.write_u8(match f.rule {
240        VectorFillRule::NonZero => 0,
241        VectorFillRule::EvenOdd => 1,
242    });
243}
244
245fn hash_stroke(h: &mut impl std::hash::Hasher, s: VectorStroke) {
246    hash_color(h, s.color);
247    h.write_u32(s.opacity.to_bits());
248    h.write_u32(s.width.to_bits());
249    h.write_u8(match s.line_cap {
250        VectorLineCap::Butt => 0,
251        VectorLineCap::Round => 1,
252        VectorLineCap::Square => 2,
253    });
254    h.write_u8(match s.line_join {
255        VectorLineJoin::Miter => 0,
256        VectorLineJoin::MiterClip => 1,
257        VectorLineJoin::Round => 2,
258        VectorLineJoin::Bevel => 3,
259    });
260    h.write_u32(s.miter_limit.to_bits());
261}
262
263fn hash_color(h: &mut impl std::hash::Hasher, c: VectorColor) {
264    match c {
265        VectorColor::CurrentColor => h.write_u8(0),
266        VectorColor::Solid(col) => {
267            h.write_u8(1);
268            h.write_u8(col.r);
269            h.write_u8(col.g);
270            h.write_u8(col.b);
271            h.write_u8(col.a);
272            // The token name participates in identity — the same rgba
273            // resolved from different tokens (e.g. a hard-coded
274            // overlay vs `tokens::ACCENT`) should still be one cache
275            // entry post-resolve, but the *unresolved* asset hashes
276            // distinctly so palette swaps invalidate cleanly.
277            match col.token {
278                Some(name) => {
279                    h.write_u8(1);
280                    hash_str(h, name);
281                }
282                None => h.write_u8(0),
283            }
284        }
285        VectorColor::Gradient(idx) => {
286            h.write_u8(2);
287            h.write_u32(idx);
288        }
289    }
290}
291
292fn hash_gradient(h: &mut impl std::hash::Hasher, g: &VectorGradient) {
293    match g {
294        VectorGradient::Linear(lin) => {
295            h.write_u8(0);
296            hash_pt(h, lin.p1);
297            hash_pt(h, lin.p2);
298            hash_stops(h, &lin.stops);
299            hash_spread(h, lin.spread);
300            for v in lin.absolute_to_local {
301                h.write_u32(v.to_bits());
302            }
303        }
304        VectorGradient::Radial(rad) => {
305            h.write_u8(1);
306            hash_pt(h, rad.center);
307            h.write_u32(rad.radius.to_bits());
308            hash_pt(h, rad.focal);
309            h.write_u32(rad.focal_radius.to_bits());
310            hash_stops(h, &rad.stops);
311            hash_spread(h, rad.spread);
312            for v in rad.absolute_to_local {
313                h.write_u32(v.to_bits());
314            }
315        }
316    }
317}
318
319fn hash_stops(h: &mut impl std::hash::Hasher, stops: &[VectorGradientStop]) {
320    write_len(h, stops.len());
321    for stop in stops {
322        h.write_u32(stop.offset.to_bits());
323        for c in stop.color {
324            h.write_u32(c.to_bits());
325        }
326    }
327}
328
329fn hash_spread(h: &mut impl std::hash::Hasher, s: VectorSpreadMethod) {
330    h.write_u8(match s {
331        VectorSpreadMethod::Pad => 0,
332        VectorSpreadMethod::Reflect => 1,
333        VectorSpreadMethod::Repeat => 2,
334    });
335}
336
337/// Imperative builder for a single [`VectorPath`]. Mirrors a subset of
338/// the SVG path command vocabulary (`M`, `L`, `C`, `Q`, `Z`) plus
339/// fill/stroke style. Returns a `VectorPath`; combine multiple via
340/// [`VectorAsset::from_paths`].
341///
342/// ```
343/// use aetna_core::vector::{
344///     PathBuilder, VectorAsset, VectorColor, VectorLineCap,
345/// };
346/// use aetna_core::tree::Color;
347///
348/// let curve = PathBuilder::new()
349///     .move_to(0.0, 0.0)
350///     .cubic_to(20.0, 0.0, 0.0, 60.0, 20.0, 60.0)
351///     .stroke_solid(Color::rgb(80, 200, 240), 2.0)
352///     .stroke_line_cap(VectorLineCap::Round)
353///     .build();
354/// let asset = VectorAsset::from_paths([0.0, 0.0, 20.0, 60.0], vec![curve]);
355/// // `asset.content_hash()` is stable across rebuilds with the same inputs,
356/// // so backends share one atlas slot per unique geometry.
357/// # let _ = asset;
358/// ```
359#[derive(Clone, Debug)]
360pub struct PathBuilder {
361    segments: Vec<VectorSegment>,
362    fill: Option<VectorFill>,
363    stroke: Option<VectorStroke>,
364}
365
366impl Default for PathBuilder {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372impl PathBuilder {
373    pub fn new() -> Self {
374        Self {
375            segments: Vec::new(),
376            fill: None,
377            stroke: None,
378        }
379    }
380
381    /// SVG `M x y`.
382    pub fn move_to(mut self, x: f32, y: f32) -> Self {
383        self.segments.push(VectorSegment::MoveTo([x, y]));
384        self
385    }
386
387    /// SVG `L x y`.
388    pub fn line_to(mut self, x: f32, y: f32) -> Self {
389        self.segments.push(VectorSegment::LineTo([x, y]));
390        self
391    }
392
393    /// SVG `Q cx cy x y`.
394    pub fn quad_to(mut self, cx: f32, cy: f32, x: f32, y: f32) -> Self {
395        self.segments.push(VectorSegment::QuadTo([cx, cy], [x, y]));
396        self
397    }
398
399    /// SVG `C c1x c1y c2x c2y x y`.
400    pub fn cubic_to(mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) -> Self {
401        self.segments
402            .push(VectorSegment::CubicTo([c1x, c1y], [c2x, c2y], [x, y]));
403        self
404    }
405
406    /// SVG `Z` — close the current subpath back to its `MoveTo`.
407    pub fn close(mut self) -> Self {
408        self.segments.push(VectorSegment::Close);
409        self
410    }
411
412    /// Fill with a solid colour at full opacity, non-zero rule. For
413    /// finer control set [`Self::fill`] directly.
414    pub fn fill_solid(mut self, color: crate::tree::Color) -> Self {
415        self.fill = Some(VectorFill {
416            color: VectorColor::Solid(color),
417            opacity: 1.0,
418            rule: VectorFillRule::NonZero,
419        });
420        self
421    }
422
423    /// Set the fill explicitly. `None` clears it.
424    pub fn fill(mut self, fill: Option<VectorFill>) -> Self {
425        self.fill = fill;
426        self
427    }
428
429    /// Stroke with a solid colour and explicit width, with default
430    /// line cap (`Butt`), line join (`Miter`), and miter limit (4.0).
431    /// For finer control chain [`Self::stroke_line_cap`] /
432    /// [`Self::stroke_line_join`] / [`Self::stroke_miter_limit`].
433    pub fn stroke_solid(mut self, color: crate::tree::Color, width: f32) -> Self {
434        self.stroke = Some(VectorStroke {
435            color: VectorColor::Solid(color),
436            opacity: 1.0,
437            width,
438            line_cap: VectorLineCap::Butt,
439            line_join: VectorLineJoin::Miter,
440            miter_limit: 4.0,
441        });
442        self
443    }
444
445    /// Set the stroke explicitly. `None` clears it.
446    pub fn stroke(mut self, stroke: Option<VectorStroke>) -> Self {
447        self.stroke = stroke;
448        self
449    }
450
451    pub fn stroke_line_cap(mut self, cap: VectorLineCap) -> Self {
452        if let Some(s) = self.stroke.as_mut() {
453            s.line_cap = cap;
454        }
455        self
456    }
457
458    pub fn stroke_line_join(mut self, join: VectorLineJoin) -> Self {
459        if let Some(s) = self.stroke.as_mut() {
460            s.line_join = join;
461        }
462        self
463    }
464
465    pub fn stroke_miter_limit(mut self, limit: f32) -> Self {
466        if let Some(s) = self.stroke.as_mut() {
467            s.miter_limit = limit;
468        }
469        self
470    }
471
472    pub fn stroke_opacity(mut self, opacity: f32) -> Self {
473        if let Some(s) = self.stroke.as_mut() {
474            s.opacity = opacity;
475        }
476        self
477    }
478
479    pub fn build(self) -> VectorPath {
480        VectorPath {
481            segments: self.segments,
482            fill: self.fill,
483            stroke: self.stroke,
484        }
485    }
486}
487
488#[derive(Clone, Debug, PartialEq)]
489pub struct VectorPath {
490    pub segments: Vec<VectorSegment>,
491    pub fill: Option<VectorFill>,
492    pub stroke: Option<VectorStroke>,
493}
494
495#[derive(Clone, Copy, Debug, PartialEq)]
496pub enum VectorSegment {
497    MoveTo([f32; 2]),
498    LineTo([f32; 2]),
499    QuadTo([f32; 2], [f32; 2]),
500    CubicTo([f32; 2], [f32; 2], [f32; 2]),
501    Close,
502}
503
504#[derive(Clone, Copy, Debug, PartialEq)]
505pub struct VectorFill {
506    pub color: VectorColor,
507    pub opacity: f32,
508    pub rule: VectorFillRule,
509}
510
511#[derive(Clone, Copy, Debug, PartialEq)]
512pub struct VectorStroke {
513    pub color: VectorColor,
514    pub opacity: f32,
515    pub width: f32,
516    pub line_cap: VectorLineCap,
517    pub line_join: VectorLineJoin,
518    pub miter_limit: f32,
519}
520
521#[derive(Clone, Copy, Debug, PartialEq)]
522pub enum VectorColor {
523    CurrentColor,
524    Solid(Color),
525    /// Index into [`VectorAsset::gradients`].
526    Gradient(u32),
527}
528
529/// A linear or radial gradient resolved to absolute SVG/viewBox space. The
530/// stored axis/centre coordinates live in the gradient's own coordinate
531/// system; `absolute_to_local` maps a point in absolute SVG space back into
532/// that system so per-vertex evaluation is one matrix-multiply away.
533#[derive(Clone, Debug, PartialEq)]
534pub enum VectorGradient {
535    Linear(VectorLinearGradient),
536    Radial(VectorRadialGradient),
537}
538
539#[derive(Clone, Debug, PartialEq)]
540pub struct VectorLinearGradient {
541    pub p1: [f32; 2],
542    pub p2: [f32; 2],
543    pub stops: Vec<VectorGradientStop>,
544    pub spread: VectorSpreadMethod,
545    /// Row-major 2x3 affine `[sx, kx, tx, ky, sy, ty]` mapping absolute
546    /// SVG coordinates into the gradient's own coordinate system.
547    pub absolute_to_local: [f32; 6],
548}
549
550#[derive(Clone, Debug, PartialEq)]
551pub struct VectorRadialGradient {
552    pub center: [f32; 2],
553    pub radius: f32,
554    pub focal: [f32; 2],
555    pub focal_radius: f32,
556    pub stops: Vec<VectorGradientStop>,
557    pub spread: VectorSpreadMethod,
558    pub absolute_to_local: [f32; 6],
559}
560
561/// A gradient stop. The colour is stored in linear premultiplied-friendly
562/// floats (sRGB → linear, with the per-stop opacity baked into the alpha)
563/// so vertex interpolation matches what the shader expects.
564#[derive(Clone, Copy, Debug, PartialEq)]
565pub struct VectorGradientStop {
566    pub offset: f32,
567    pub color: [f32; 4],
568}
569
570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
571pub enum VectorSpreadMethod {
572    Pad,
573    Reflect,
574    Repeat,
575}
576
577#[derive(Clone, Copy, Debug, PartialEq, Eq)]
578pub enum VectorFillRule {
579    NonZero,
580    EvenOdd,
581}
582
583#[derive(Clone, Copy, Debug, PartialEq, Eq)]
584pub enum VectorLineCap {
585    Butt,
586    Round,
587    Square,
588}
589
590#[derive(Clone, Copy, Debug, PartialEq, Eq)]
591pub enum VectorLineJoin {
592    Miter,
593    MiterClip,
594    Round,
595    Bevel,
596}
597
598#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
599pub enum IconMaterial {
600    /// Direct premultiplied color. This is the baseline material and
601    /// should match ordinary flat SVG rendering.
602    #[default]
603    Flat,
604    /// A proof material that uses local vector coordinates to add a
605    /// subtle top-left highlight and lower shadow. This exists to prove
606    /// the shared mesh carries enough data for shader-controlled icon
607    /// treatments.
608    Relief,
609    /// A glossy icon material with local-coordinate glints and a soft
610    /// inner shade. Pairs with translucent/glass surfaces.
611    Glass,
612}
613
614#[repr(C)]
615#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
616pub struct VectorMeshVertex {
617    /// Logical-pixel position after fitting the vector asset into its
618    /// destination rect.
619    pub pos: [f32; 2],
620    /// SVG/viewBox-space coordinate. Theme shaders can use this for
621    /// gradients, highlights, bevels, and other icon-local effects.
622    pub local: [f32; 2],
623    pub color: [f32; 4],
624    /// Reserved for material shaders: x = path index, y = primitive
625    /// kind (0 fill, 1 stroke), z/w reserved.
626    pub meta: [f32; 4],
627}
628
629#[derive(Clone, Debug, Default, PartialEq)]
630pub struct VectorMesh {
631    pub vertices: Vec<VectorMeshVertex>,
632}
633
634#[derive(Clone, Copy, Debug, PartialEq)]
635pub struct VectorMeshRun {
636    pub first: u32,
637    pub count: u32,
638}
639
640#[derive(Clone, Copy, Debug, PartialEq)]
641pub struct VectorMeshOptions {
642    pub rect: crate::tree::Rect,
643    pub current_color: Color,
644    pub stroke_width: f32,
645    pub tolerance: f32,
646}
647
648impl VectorMeshOptions {
649    pub fn icon(rect: crate::tree::Rect, current_color: Color, stroke_width: f32) -> Self {
650        Self {
651            rect,
652            current_color,
653            stroke_width,
654            tolerance: 0.05,
655        }
656    }
657}
658
659#[derive(Clone, Debug, PartialEq, Eq)]
660pub struct VectorParseError {
661    message: String,
662}
663
664impl VectorParseError {
665    fn new(message: impl Into<String>) -> Self {
666        Self {
667            message: message.into(),
668        }
669    }
670}
671
672impl fmt::Display for VectorParseError {
673    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
674        f.write_str(&self.message)
675    }
676}
677
678impl Error for VectorParseError {}
679
680pub fn parse_svg_asset(svg: &str) -> Result<VectorAsset, VectorParseError> {
681    parse_svg_asset_with_color_mode(svg, false)
682}
683
684pub fn tessellate_vector_asset(asset: &VectorAsset, options: VectorMeshOptions) -> VectorMesh {
685    let mut mesh = VectorMesh::default();
686    append_vector_asset_mesh(asset, options, &mut mesh.vertices);
687    mesh
688}
689
690pub fn append_vector_asset_mesh(
691    asset: &VectorAsset,
692    options: VectorMeshOptions,
693    out: &mut Vec<VectorMeshVertex>,
694) -> VectorMeshRun {
695    let first = out.len() as u32;
696    if options.rect.w <= 0.0 || options.rect.h <= 0.0 {
697        return VectorMeshRun { first, count: 0 };
698    }
699
700    let [vx, vy, vw, vh] = asset.view_box;
701    let sx = options.rect.w / vw.max(1.0);
702    let sy = options.rect.h / vh.max(1.0);
703    let stroke_scale = (sx + sy) * 0.5;
704
705    for (path_index, vector_path) in asset.paths.iter().enumerate() {
706        let path = build_lyon_path(vector_path, options.rect, [vx, vy], [sx, sy]);
707        if let Some(fill) = vector_path.fill {
708            let sampler = ColorSampler::build(
709                fill.color,
710                fill.opacity,
711                options.current_color,
712                &asset.gradients,
713            );
714            let mut geometry: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
715            let fill_options =
716                FillOptions::tolerance(options.tolerance).with_fill_rule(match fill.rule {
717                    VectorFillRule::NonZero => lyon_tessellation::FillRule::NonZero,
718                    VectorFillRule::EvenOdd => lyon_tessellation::FillRule::EvenOdd,
719                });
720            let _ = FillTessellator::new().tessellate_path(
721                &path,
722                &fill_options,
723                &mut BuffersBuilder::new(&mut geometry, |v: FillVertex<'_>| {
724                    make_mesh_vertex_sampled(
725                        v.position(),
726                        options.rect,
727                        [vx, vy],
728                        [sx, sy],
729                        &sampler,
730                        path_index,
731                        VectorPrimitiveKind::Fill,
732                    )
733                }),
734            );
735            append_indexed(&geometry, out);
736        }
737
738        if let Some(stroke) = vector_path.stroke {
739            let sampler = ColorSampler::build(
740                stroke.color,
741                stroke.opacity,
742                options.current_color,
743                &asset.gradients,
744            );
745            let width = if matches!(stroke.color, VectorColor::CurrentColor) {
746                options.stroke_width * stroke_scale
747            } else {
748                stroke.width * stroke_scale
749            }
750            .max(0.5);
751            let mut geometry: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
752            let stroke_options = StrokeOptions::tolerance(options.tolerance)
753                .with_line_width(width)
754                .with_line_cap(match stroke.line_cap {
755                    VectorLineCap::Butt => LineCap::Butt,
756                    VectorLineCap::Round => LineCap::Round,
757                    VectorLineCap::Square => LineCap::Square,
758                })
759                .with_line_join(match stroke.line_join {
760                    VectorLineJoin::Miter => LineJoin::Miter,
761                    VectorLineJoin::MiterClip => LineJoin::MiterClip,
762                    VectorLineJoin::Round => LineJoin::Round,
763                    VectorLineJoin::Bevel => LineJoin::Bevel,
764                })
765                .with_miter_limit(stroke.miter_limit.max(1.0));
766            let _ = StrokeTessellator::new().tessellate_path(
767                &path,
768                &stroke_options,
769                &mut BuffersBuilder::new(&mut geometry, |v: StrokeVertex<'_, '_>| {
770                    make_mesh_vertex_sampled(
771                        v.position(),
772                        options.rect,
773                        [vx, vy],
774                        [sx, sy],
775                        &sampler,
776                        path_index,
777                        VectorPrimitiveKind::Stroke,
778                    )
779                }),
780            );
781            append_indexed(&geometry, out);
782        }
783    }
784
785    VectorMeshRun {
786        first,
787        count: out.len() as u32 - first,
788    }
789}
790
791pub(crate) fn parse_current_color_svg_asset(svg: &str) -> Result<VectorAsset, VectorParseError> {
792    parse_svg_asset_with_color_mode(svg, true)
793}
794
795fn parse_svg_asset_with_color_mode(
796    svg: &str,
797    force_current_color: bool,
798) -> Result<VectorAsset, VectorParseError> {
799    let tree = usvg::Tree::from_str(svg, &usvg::Options::default())
800        .map_err(|e| VectorParseError::new(format!("invalid SVG: {e}")))?;
801    let size = tree.size();
802    let mut asset = VectorAsset {
803        view_box: [0.0, 0.0, size.width(), size.height()],
804        paths: Vec::new(),
805        gradients: Vec::new(),
806    };
807    collect_group(
808        tree.root(),
809        force_current_color,
810        &mut asset.paths,
811        &mut asset.gradients,
812    );
813    if asset.paths.is_empty() {
814        return Err(VectorParseError::new("SVG produced no renderable paths"));
815    }
816    Ok(asset)
817}
818
819fn collect_group(
820    group: &usvg::Group,
821    force_current_color: bool,
822    out: &mut Vec<VectorPath>,
823    gradients: &mut Vec<VectorGradient>,
824) {
825    for node in group.children() {
826        match node {
827            usvg::Node::Group(group) => collect_group(group, force_current_color, out, gradients),
828            usvg::Node::Path(path) if path.is_visible() => {
829                if let Some(vector_path) = convert_path(path, force_current_color, gradients) {
830                    out.push(vector_path);
831                }
832            }
833            _ => {}
834        }
835    }
836}
837
838fn convert_path(
839    path: &usvg::Path,
840    force_current_color: bool,
841    gradients: &mut Vec<VectorGradient>,
842) -> Option<VectorPath> {
843    let transform = path.abs_transform();
844    let mut segments = Vec::new();
845    for segment in path.data().segments() {
846        match segment {
847            tiny_skia_path::PathSegment::MoveTo(p) => {
848                segments.push(VectorSegment::MoveTo(map_point(transform, p)));
849            }
850            tiny_skia_path::PathSegment::LineTo(p) => {
851                segments.push(VectorSegment::LineTo(map_point(transform, p)));
852            }
853            tiny_skia_path::PathSegment::QuadTo(p0, p1) => {
854                segments.push(VectorSegment::QuadTo(
855                    map_point(transform, p0),
856                    map_point(transform, p1),
857                ));
858            }
859            tiny_skia_path::PathSegment::CubicTo(p0, p1, p2) => {
860                segments.push(VectorSegment::CubicTo(
861                    map_point(transform, p0),
862                    map_point(transform, p1),
863                    map_point(transform, p2),
864                ));
865            }
866            tiny_skia_path::PathSegment::Close => segments.push(VectorSegment::Close),
867        }
868    }
869    if segments.is_empty() {
870        return None;
871    }
872
873    Some(VectorPath {
874        segments,
875        fill: path
876            .fill()
877            .and_then(|fill| convert_fill(fill, transform, force_current_color, gradients)),
878        stroke: path
879            .stroke()
880            .and_then(|stroke| convert_stroke(stroke, transform, force_current_color, gradients)),
881    })
882}
883
884fn convert_fill(
885    fill: &usvg::Fill,
886    abs_transform: tiny_skia_path::Transform,
887    force_current_color: bool,
888    gradients: &mut Vec<VectorGradient>,
889) -> Option<VectorFill> {
890    Some(VectorFill {
891        color: convert_paint(fill.paint(), abs_transform, force_current_color, gradients)?,
892        opacity: fill.opacity().get(),
893        rule: match fill.rule() {
894            usvg::FillRule::NonZero => VectorFillRule::NonZero,
895            usvg::FillRule::EvenOdd => VectorFillRule::EvenOdd,
896        },
897    })
898}
899
900fn convert_stroke(
901    stroke: &usvg::Stroke,
902    abs_transform: tiny_skia_path::Transform,
903    force_current_color: bool,
904    gradients: &mut Vec<VectorGradient>,
905) -> Option<VectorStroke> {
906    Some(VectorStroke {
907        color: convert_paint(
908            stroke.paint(),
909            abs_transform,
910            force_current_color,
911            gradients,
912        )?,
913        opacity: stroke.opacity().get(),
914        width: stroke.width().get(),
915        line_cap: match stroke.linecap() {
916            usvg::LineCap::Butt => VectorLineCap::Butt,
917            usvg::LineCap::Round => VectorLineCap::Round,
918            usvg::LineCap::Square => VectorLineCap::Square,
919        },
920        line_join: match stroke.linejoin() {
921            usvg::LineJoin::Miter => VectorLineJoin::Miter,
922            usvg::LineJoin::MiterClip => VectorLineJoin::MiterClip,
923            usvg::LineJoin::Round => VectorLineJoin::Round,
924            usvg::LineJoin::Bevel => VectorLineJoin::Bevel,
925        },
926        miter_limit: stroke.miterlimit().get(),
927    })
928}
929
930fn convert_paint(
931    paint: &usvg::Paint,
932    abs_transform: tiny_skia_path::Transform,
933    force_current_color: bool,
934    gradients: &mut Vec<VectorGradient>,
935) -> Option<VectorColor> {
936    if force_current_color {
937        return Some(VectorColor::CurrentColor);
938    }
939    match paint {
940        usvg::Paint::Color(c) => Some(VectorColor::Solid(Color::rgba(c.red, c.green, c.blue, 255))),
941        usvg::Paint::LinearGradient(lg) => {
942            let g = convert_linear_gradient(lg, abs_transform)?;
943            let idx = gradients.len() as u32;
944            gradients.push(VectorGradient::Linear(g));
945            Some(VectorColor::Gradient(idx))
946        }
947        usvg::Paint::RadialGradient(rg) => {
948            let g = convert_radial_gradient(rg, abs_transform)?;
949            let idx = gradients.len() as u32;
950            gradients.push(VectorGradient::Radial(g));
951            Some(VectorColor::Gradient(idx))
952        }
953        usvg::Paint::Pattern(_) => None,
954    }
955}
956
957fn convert_linear_gradient(
958    lg: &usvg::LinearGradient,
959    abs_transform: tiny_skia_path::Transform,
960) -> Option<VectorLinearGradient> {
961    let stops = convert_stops(lg.stops());
962    if stops.is_empty() {
963        return None;
964    }
965    let absolute_to_local = build_absolute_to_local(abs_transform, lg.transform())?;
966    Some(VectorLinearGradient {
967        p1: [lg.x1(), lg.y1()],
968        p2: [lg.x2(), lg.y2()],
969        stops,
970        spread: convert_spread(lg.spread_method()),
971        absolute_to_local,
972    })
973}
974
975fn convert_radial_gradient(
976    rg: &usvg::RadialGradient,
977    abs_transform: tiny_skia_path::Transform,
978) -> Option<VectorRadialGradient> {
979    let stops = convert_stops(rg.stops());
980    if stops.is_empty() {
981        return None;
982    }
983    let absolute_to_local = build_absolute_to_local(abs_transform, rg.transform())?;
984    Some(VectorRadialGradient {
985        center: [rg.cx(), rg.cy()],
986        radius: rg.r().get(),
987        focal: [rg.fx(), rg.fy()],
988        focal_radius: rg.fr().get(),
989        stops,
990        spread: convert_spread(rg.spread_method()),
991        absolute_to_local,
992    })
993}
994
995fn convert_stops(stops: &[usvg::Stop]) -> Vec<VectorGradientStop> {
996    let mut out = Vec::with_capacity(stops.len());
997    let mut last_offset = 0.0_f32;
998    for stop in stops {
999        // SVG requires monotonically non-decreasing offsets; nudge so a
1000        // straight binary search over `out` always works.
1001        let offset = stop.offset().get().max(last_offset);
1002        last_offset = offset;
1003        let mut rgba = rgba_f32(Color::rgba(
1004            stop.color().red,
1005            stop.color().green,
1006            stop.color().blue,
1007            255,
1008        ));
1009        rgba[3] *= stop.opacity().get();
1010        out.push(VectorGradientStop {
1011            offset,
1012            color: rgba,
1013        });
1014    }
1015    out
1016}
1017
1018fn convert_spread(method: usvg::SpreadMethod) -> VectorSpreadMethod {
1019    match method {
1020        usvg::SpreadMethod::Pad => VectorSpreadMethod::Pad,
1021        usvg::SpreadMethod::Reflect => VectorSpreadMethod::Reflect,
1022        usvg::SpreadMethod::Repeat => VectorSpreadMethod::Repeat,
1023    }
1024}
1025
1026/// Build the inverse transform that maps an absolute SVG coordinate (post
1027/// `path.abs_transform()`) into the gradient's own coordinate system.
1028///
1029/// `gradient_transform` from usvg already takes a gradient-local point into
1030/// the path's *local* user space (with bbox-units pre-baked). Composing
1031/// with `abs_transform` lifts that into absolute space; inverting gives us
1032/// the back-mapping the per-vertex sampler needs.
1033fn build_absolute_to_local(
1034    abs_transform: tiny_skia_path::Transform,
1035    gradient_transform: tiny_skia_path::Transform,
1036) -> Option<[f32; 6]> {
1037    let local_to_absolute = abs_transform.pre_concat(gradient_transform);
1038    let inv = local_to_absolute.invert()?;
1039    Some([inv.sx, inv.kx, inv.tx, inv.ky, inv.sy, inv.ty])
1040}
1041
1042fn map_point(transform: tiny_skia_path::Transform, mut point: tiny_skia_path::Point) -> [f32; 2] {
1043    transform.map_point(&mut point);
1044    [point.x, point.y]
1045}
1046
1047#[derive(Clone, Copy)]
1048enum VectorPrimitiveKind {
1049    Fill,
1050    Stroke,
1051}
1052
1053fn build_lyon_path(
1054    path: &VectorPath,
1055    rect: crate::tree::Rect,
1056    view_origin: [f32; 2],
1057    scale: [f32; 2],
1058) -> LyonPath {
1059    let mut builder = LyonPath::builder();
1060    let mut open = false;
1061    for segment in &path.segments {
1062        match *segment {
1063            VectorSegment::MoveTo(p) => {
1064                if open {
1065                    builder.end(false);
1066                }
1067                builder.begin(map_mesh_point(rect, view_origin, scale, p));
1068                open = true;
1069            }
1070            VectorSegment::LineTo(p) => {
1071                builder.line_to(map_mesh_point(rect, view_origin, scale, p));
1072            }
1073            VectorSegment::QuadTo(c, p) => {
1074                builder.quadratic_bezier_to(
1075                    map_mesh_point(rect, view_origin, scale, c),
1076                    map_mesh_point(rect, view_origin, scale, p),
1077                );
1078            }
1079            VectorSegment::CubicTo(c0, c1, p) => {
1080                builder.cubic_bezier_to(
1081                    map_mesh_point(rect, view_origin, scale, c0),
1082                    map_mesh_point(rect, view_origin, scale, c1),
1083                    map_mesh_point(rect, view_origin, scale, p),
1084                );
1085            }
1086            VectorSegment::Close => {
1087                if open {
1088                    builder.close();
1089                    open = false;
1090                }
1091            }
1092        }
1093    }
1094    if open {
1095        builder.end(false);
1096    }
1097    builder.build()
1098}
1099
1100fn map_mesh_point(
1101    rect: crate::tree::Rect,
1102    view_origin: [f32; 2],
1103    scale: [f32; 2],
1104    p: [f32; 2],
1105) -> lyon_tessellation::math::Point {
1106    point(
1107        rect.x + (p[0] - view_origin[0]) * scale[0],
1108        rect.y + (p[1] - view_origin[1]) * scale[1],
1109    )
1110}
1111
1112fn make_mesh_vertex_sampled(
1113    p: lyon_tessellation::math::Point,
1114    rect: crate::tree::Rect,
1115    view_origin: [f32; 2],
1116    scale: [f32; 2],
1117    sampler: &ColorSampler<'_>,
1118    path_index: usize,
1119    kind: VectorPrimitiveKind,
1120) -> VectorMeshVertex {
1121    let local = [
1122        view_origin[0] + (p.x - rect.x) / scale[0].max(f32::EPSILON),
1123        view_origin[1] + (p.y - rect.y) / scale[1].max(f32::EPSILON),
1124    ];
1125    VectorMeshVertex {
1126        pos: [p.x, p.y],
1127        local,
1128        color: sampler.sample(local),
1129        meta: [
1130            path_index as f32,
1131            match kind {
1132                VectorPrimitiveKind::Fill => 0.0,
1133                VectorPrimitiveKind::Stroke => 1.0,
1134            },
1135            0.0,
1136            0.0,
1137        ],
1138    }
1139}
1140
1141/// Per-vertex colour resolver. Solid/`currentColor` paths bake to a single
1142/// constant; gradient paths defer to per-vertex evaluation against the
1143/// vertex's SVG-space `local` coordinate.
1144enum ColorSampler<'a> {
1145    Solid([f32; 4]),
1146    Gradient {
1147        gradient: &'a VectorGradient,
1148        opacity: f32,
1149    },
1150}
1151
1152impl<'a> ColorSampler<'a> {
1153    fn build(
1154        color: VectorColor,
1155        opacity: f32,
1156        current_color: Color,
1157        gradients: &'a [VectorGradient],
1158    ) -> Self {
1159        let opacity = opacity.clamp(0.0, 1.0);
1160        match color {
1161            VectorColor::CurrentColor => {
1162                let mut c = rgba_f32(current_color);
1163                c[3] *= opacity;
1164                Self::Solid(c)
1165            }
1166            VectorColor::Solid(c) => {
1167                let mut rgba = rgba_f32(c);
1168                rgba[3] *= opacity;
1169                Self::Solid(rgba)
1170            }
1171            VectorColor::Gradient(idx) => match gradients.get(idx as usize) {
1172                Some(gradient) => Self::Gradient { gradient, opacity },
1173                // Index out of range — should not happen for parsed assets;
1174                // keep the path renderable as transparent rather than crashing.
1175                None => Self::Solid([0.0; 4]),
1176            },
1177        }
1178    }
1179
1180    fn sample(&self, abs_local: [f32; 2]) -> [f32; 4] {
1181        match self {
1182            Self::Solid(c) => *c,
1183            Self::Gradient { gradient, opacity } => {
1184                let mut c = sample_gradient(gradient, abs_local);
1185                c[3] *= *opacity;
1186                c
1187            }
1188        }
1189    }
1190}
1191
1192fn sample_gradient(gradient: &VectorGradient, abs_local: [f32; 2]) -> [f32; 4] {
1193    match gradient {
1194        VectorGradient::Linear(g) => {
1195            let local = apply_affine(&g.absolute_to_local, abs_local);
1196            let dx = g.p2[0] - g.p1[0];
1197            let dy = g.p2[1] - g.p1[1];
1198            let len2 = (dx * dx + dy * dy).max(f32::EPSILON);
1199            let t = ((local[0] - g.p1[0]) * dx + (local[1] - g.p1[1]) * dy) / len2;
1200            sample_stops(&g.stops, apply_spread(t, g.spread))
1201        }
1202        VectorGradient::Radial(g) => {
1203            // Aetna v0: treat radial gradients as concentric about `center`
1204            // with radius `radius`. This matches the common authoring case
1205            // (focal == centre, focal_radius == 0); offset focal points are
1206            // accepted but rendered without the cone-projection nuance.
1207            let local = apply_affine(&g.absolute_to_local, abs_local);
1208            let dx = local[0] - g.center[0];
1209            let dy = local[1] - g.center[1];
1210            let radius = g.radius.max(f32::EPSILON);
1211            let t = (dx * dx + dy * dy).sqrt() / radius;
1212            sample_stops(&g.stops, apply_spread(t, g.spread))
1213        }
1214    }
1215}
1216
1217fn apply_affine(m: &[f32; 6], p: [f32; 2]) -> [f32; 2] {
1218    [
1219        p[0] * m[0] + p[1] * m[1] + m[2],
1220        p[0] * m[3] + p[1] * m[4] + m[5],
1221    ]
1222}
1223
1224fn apply_spread(t: f32, spread: VectorSpreadMethod) -> f32 {
1225    match spread {
1226        VectorSpreadMethod::Pad => t.clamp(0.0, 1.0),
1227        VectorSpreadMethod::Reflect => {
1228            let m = t.rem_euclid(2.0);
1229            if m > 1.0 { 2.0 - m } else { m }
1230        }
1231        VectorSpreadMethod::Repeat => t.rem_euclid(1.0),
1232    }
1233}
1234
1235fn sample_stops(stops: &[VectorGradientStop], t: f32) -> [f32; 4] {
1236    if stops.is_empty() {
1237        return [0.0; 4];
1238    }
1239    if t <= stops[0].offset {
1240        return stops[0].color;
1241    }
1242    let last = stops.len() - 1;
1243    if t >= stops[last].offset {
1244        return stops[last].color;
1245    }
1246    for i in 1..stops.len() {
1247        if t <= stops[i].offset {
1248            let prev = &stops[i - 1];
1249            let next = &stops[i];
1250            let span = (next.offset - prev.offset).max(f32::EPSILON);
1251            let frac = ((t - prev.offset) / span).clamp(0.0, 1.0);
1252            return [
1253                prev.color[0] + (next.color[0] - prev.color[0]) * frac,
1254                prev.color[1] + (next.color[1] - prev.color[1]) * frac,
1255                prev.color[2] + (next.color[2] - prev.color[2]) * frac,
1256                prev.color[3] + (next.color[3] - prev.color[3]) * frac,
1257            ];
1258        }
1259    }
1260    stops[last].color
1261}
1262
1263fn append_indexed(
1264    geometry: &VertexBuffers<VectorMeshVertex, u16>,
1265    out: &mut Vec<VectorMeshVertex>,
1266) {
1267    for index in &geometry.indices {
1268        if let Some(vertex) = geometry.vertices.get(*index as usize) {
1269            out.push(*vertex);
1270        }
1271    }
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276    use super::*;
1277    use crate::icons::{all_icon_names, icon_vector_asset};
1278
1279    #[test]
1280    fn parses_basic_svg_shapes_into_paths() {
1281        let asset = parse_svg_asset(
1282            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>"##,
1283        )
1284        .unwrap();
1285        assert_eq!(asset.view_box, [0.0, 0.0, 24.0, 24.0]);
1286        assert_eq!(asset.paths.len(), 1);
1287        assert!(asset.paths[0].stroke.is_some());
1288        assert!(asset.paths[0].segments.len() > 4);
1289    }
1290
1291    #[test]
1292    fn tessellates_every_builtin_icon() {
1293        for name in all_icon_names() {
1294            let mesh = tessellate_vector_asset(
1295                icon_vector_asset(*name),
1296                VectorMeshOptions::icon(
1297                    crate::tree::Rect::new(0.0, 0.0, 16.0, 16.0),
1298                    Color::rgb(15, 23, 42),
1299                    2.0,
1300                ),
1301            );
1302            assert!(
1303                !mesh.vertices.is_empty(),
1304                "{} produced no tessellated vertices",
1305                name.name()
1306            );
1307        }
1308    }
1309
1310    #[test]
1311    fn parses_linear_gradient_paint() {
1312        let asset = parse_svg_asset(
1313            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1314                <defs>
1315                    <linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1316                        <stop offset="0" stop-color="#ff0000"/>
1317                        <stop offset="1" stop-color="#0000ff"/>
1318                    </linearGradient>
1319                </defs>
1320                <rect width="100" height="100" fill="url(#g)"/>
1321            </svg>"##,
1322        )
1323        .unwrap();
1324        assert_eq!(asset.gradients.len(), 1);
1325        assert!(matches!(
1326            asset.paths[0].fill.unwrap().color,
1327            VectorColor::Gradient(_)
1328        ));
1329        match &asset.gradients[0] {
1330            VectorGradient::Linear(g) => {
1331                assert_eq!(g.stops.len(), 2);
1332                assert_eq!(g.spread, VectorSpreadMethod::Pad);
1333                assert_eq!(g.p1, [0.0, 0.0]);
1334                assert_eq!(g.p2, [100.0, 0.0]);
1335            }
1336            other => panic!("expected linear gradient, got {other:?}"),
1337        }
1338    }
1339
1340    #[test]
1341    fn bakes_gradient_into_per_vertex_colors() {
1342        let asset = parse_svg_asset(
1343            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1344                <defs>
1345                    <linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1346                        <stop offset="0" stop-color="#ff0000"/>
1347                        <stop offset="1" stop-color="#0000ff"/>
1348                    </linearGradient>
1349                </defs>
1350                <rect width="100" height="100" fill="url(#g)"/>
1351            </svg>"##,
1352        )
1353        .unwrap();
1354        let mesh = tessellate_vector_asset(
1355            &asset,
1356            VectorMeshOptions::icon(
1357                crate::tree::Rect::new(0.0, 0.0, 200.0, 200.0),
1358                Color::rgb(0, 0, 0),
1359                2.0,
1360            ),
1361        );
1362        assert!(!mesh.vertices.is_empty());
1363
1364        // Vertices on the left side of the rect should be reddish; on the
1365        // right side, bluish. (Linear gradients evaluate in linear-RGB
1366        // space, so red dominates in [0]/[2].)
1367        let mut min_x_vert = mesh.vertices[0];
1368        let mut max_x_vert = mesh.vertices[0];
1369        for v in &mesh.vertices {
1370            if v.local[0] < min_x_vert.local[0] {
1371                min_x_vert = *v;
1372            }
1373            if v.local[0] > max_x_vert.local[0] {
1374                max_x_vert = *v;
1375            }
1376        }
1377        assert!(
1378            min_x_vert.color[0] > min_x_vert.color[2],
1379            "left edge should be redder: {:?}",
1380            min_x_vert.color
1381        );
1382        assert!(
1383            max_x_vert.color[2] > max_x_vert.color[0],
1384            "right edge should be bluer: {:?}",
1385            max_x_vert.color
1386        );
1387    }
1388
1389    #[test]
1390    fn has_gradient_distinguishes_flat_from_gradient_assets() {
1391        let flat = parse_svg_asset(
1392            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="#fff"/></svg>"##,
1393        )
1394        .unwrap();
1395        assert!(!flat.has_gradient());
1396
1397        let gradient = parse_svg_asset(
1398            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1399                <defs><linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1400                    <stop offset="0" stop-color="#ff0000"/><stop offset="1" stop-color="#0000ff"/>
1401                </linearGradient></defs>
1402                <rect width="100" height="100" fill="url(#g)"/>
1403            </svg>"##,
1404        )
1405        .unwrap();
1406        assert!(gradient.has_gradient());
1407    }
1408
1409    #[test]
1410    fn parses_pipewire_volume_icon_with_all_gradients() {
1411        // Sanity-check end-to-end on a real-world authored SVG: five
1412        // linear/radial gradients plus an unsupported drop-shadow filter
1413        // (which is silently dropped, not an error).
1414        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
1415  <defs>
1416    <linearGradient id="arcGradient" x1="210" y1="720" x2="805" y2="260" gradientUnits="userSpaceOnUse">
1417      <stop offset="0" stop-color="#0667ff"/>
1418      <stop offset="0.52" stop-color="#139cff"/>
1419      <stop offset="1" stop-color="#11e4dc"/>
1420    </linearGradient>
1421    <linearGradient id="dotGradient" x1="585" y1="780" x2="805" y2="455" gradientUnits="userSpaceOnUse">
1422      <stop offset="0" stop-color="#065eff"/>
1423      <stop offset="0.55" stop-color="#0d9fff"/>
1424      <stop offset="1" stop-color="#10e5dc"/>
1425    </linearGradient>
1426    <radialGradient id="knobFace" cx="42%" cy="36%" r="72%">
1427      <stop offset="0" stop-color="#12366c"/>
1428      <stop offset="0.42" stop-color="#0b2554"/>
1429      <stop offset="1" stop-color="#071736"/>
1430    </radialGradient>
1431    <linearGradient id="knobRim" x1="320" y1="310" x2="735" y2="740" gradientUnits="userSpaceOnUse">
1432      <stop offset="0" stop-color="#214f9b"/>
1433      <stop offset="0.48" stop-color="#17386f"/>
1434      <stop offset="1" stop-color="#285aa7"/>
1435    </linearGradient>
1436    <linearGradient id="needleGradient" x1="565" y1="425" x2="670" y2="320" gradientUnits="userSpaceOnUse">
1437      <stop offset="0" stop-color="#0872ff"/>
1438      <stop offset="1" stop-color="#168aff"/>
1439    </linearGradient>
1440  </defs>
1441  <path d="M 296 720 A 300 300 0 1 1 794 409" fill="none" stroke="url(#arcGradient)" stroke-width="36" stroke-linecap="round"/>
1442  <circle cx="512" cy="512" r="210" fill="url(#knobRim)"/>
1443  <circle cx="512" cy="512" r="192" fill="url(#knobFace)"/>
1444  <line x1="569" y1="433" x2="663" y2="339" stroke="url(#needleGradient)" stroke-width="30" stroke-linecap="round"/>
1445  <circle cx="612" cy="787" r="13" fill="url(#dotGradient)"/>
1446  <circle cx="664" cy="764" r="14" fill="url(#dotGradient)"/>
1447</svg>"##;
1448        let asset = parse_svg_asset(svg).unwrap();
1449        // 1 arc stroke + 2 knob fills + 1 needle stroke + 2 dot fills = 6 paths.
1450        assert_eq!(asset.paths.len(), 6);
1451        // At least one gradient per distinct paint server (5). usvg may
1452        // duplicate when the same gradient is referenced by multiple
1453        // paths after bbox resolution; we don't pin the exact count
1454        // because that's a usvg-internal detail.
1455        assert!(asset.gradients.len() >= 5);
1456
1457        let mesh = tessellate_vector_asset(
1458            &asset,
1459            VectorMeshOptions::icon(
1460                crate::tree::Rect::new(0.0, 0.0, 256.0, 256.0),
1461                Color::rgb(0, 0, 0),
1462                2.0,
1463            ),
1464        );
1465        assert!(!mesh.vertices.is_empty());
1466        // Some vertices must carry non-zero colour — if gradients silently
1467        // dropped to transparent, every channel would be 0.
1468        let any_lit = mesh
1469            .vertices
1470            .iter()
1471            .any(|v| v.color[0] + v.color[1] + v.color[2] > 0.01);
1472        assert!(any_lit, "no lit vertices — gradients did not render");
1473    }
1474}