Skip to main content

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