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
9// Lock in full per-item documentation for this module (issue #73).
10#![warn(missing_docs)]
11
12use std::error::Error;
13use std::fmt;
14
15use crate::color::ColorSpace;
16use crate::paint::rgba_f32_in;
17use crate::tree::Color;
18
19use bytemuck::{Pod, Zeroable};
20use lyon_tessellation::geometry_builder::{BuffersBuilder, VertexBuffers};
21use lyon_tessellation::math::point;
22use lyon_tessellation::path::Path as LyonPath;
23use lyon_tessellation::{
24    FillOptions, FillTessellator, FillVertex, LineCap, LineJoin, StrokeOptions, StrokeTessellator,
25    StrokeVertex,
26};
27use usvg::tiny_skia_path;
28
29/// A parsed, backend-agnostic vector asset: an SVG `viewBox` plus styled
30/// paths and a gradient side-table. Produced by [`parse_svg_asset`] or
31/// composed programmatically via [`VectorAsset::from_paths`] /
32/// [`PathBuilder`].
33#[derive(Clone, Debug, PartialEq)]
34pub struct VectorAsset {
35    /// SVG `viewBox` as `[min_x, min_y, width, height]`. All path
36    /// coordinates are absolute within this space.
37    pub view_box: [f32; 4],
38    /// Styled paths in document (paint) order, with transforms and basic
39    /// shapes already flattened by usvg.
40    pub paths: Vec<VectorPath>,
41    /// Gradient table referenced by [`VectorColor::Gradient`] indices. Kept
42    /// as a side-table so [`VectorColor`] stays `Copy`.
43    pub gradients: Vec<VectorGradient>,
44}
45
46/// Render policy for app-supplied [`VectorAsset`]s.
47///
48/// `Painted` preserves authored fills, strokes, gradients, and
49/// `currentColor` paint, so backends use the colour-aware vector path.
50/// `Mask` treats the asset as coverage geometry and applies one caller-
51/// supplied colour, which lets backends use their MSDF atlas path.
52#[derive(Clone, Copy, Debug, Default, PartialEq)]
53pub enum VectorRenderMode {
54    /// Render authored fills, strokes, gradients, and `currentColor` paint.
55    #[default]
56    Painted,
57    /// Treat the asset as coverage geometry painted in one colour.
58    Mask {
59        /// The single colour applied to the asset's coverage.
60        color: Color,
61    },
62}
63
64impl VectorRenderMode {
65    /// Resolve the mask colour (if any) through `palette`; `Painted` is
66    /// returned unchanged.
67    pub fn resolved_palette(self, palette: &crate::palette::Palette) -> Self {
68        match self {
69            Self::Painted => Self::Painted,
70            Self::Mask { color } => Self::Mask {
71                color: palette.resolve(color),
72            },
73        }
74    }
75}
76
77impl VectorAsset {
78    /// Build a [`VectorAsset`] from a list of paths and an explicit view
79    /// box, without going through SVG parsing. The companion to
80    /// [`PathBuilder`] for apps that compose vector content
81    /// programmatically (commit-graph curves, Gantt connectors, custom
82    /// chart marks). Equivalent to setting the public fields directly,
83    /// but documents the construction site and keeps the gradient table
84    /// empty by default.
85    pub fn from_paths(view_box: [f32; 4], paths: Vec<VectorPath>) -> Self {
86        Self {
87            view_box,
88            paths,
89            gradients: Vec::new(),
90        }
91    }
92
93    /// Whether any path's fill or stroke uses a gradient.
94    pub fn has_gradient(&self) -> bool {
95        self.paths.iter().any(|p| {
96            p.fill
97                .map(|f| matches!(f.color, VectorColor::Gradient(_)))
98                .unwrap_or(false)
99                || p.stroke
100                    .map(|s| matches!(s.color, VectorColor::Gradient(_)))
101                    .unwrap_or(false)
102        })
103    }
104
105    /// Return this asset with every solid color resolved through
106    /// `palette`. Token names are preserved by palette resolution, so
107    /// subsequent palette swaps can resolve the same source asset again
108    /// while the resolved RGBA still participates in atlas identity.
109    pub fn resolved_palette(&self, palette: &crate::palette::Palette) -> Self {
110        let mut out = self.clone();
111        for path in &mut out.paths {
112            if let Some(fill) = &mut path.fill {
113                fill.color = resolve_vector_color(fill.color, palette);
114            }
115            if let Some(stroke) = &mut path.stroke {
116                stroke.color = resolve_vector_color(stroke.color, palette);
117            }
118        }
119        out
120    }
121
122    /// Stable content-hash used as a cache key in MSDF / mesh atlases.
123    /// Two assets with identical view box, paths, fills, strokes, and
124    /// gradients hash to the same value — backends dedupe rasterised
125    /// MSDF / tessellated mesh entries on this so an app that builds
126    /// the same curve shape twice (e.g. two commits sharing a merge
127    /// connector geometry) shares one atlas slot.
128    ///
129    /// Floats hash via [`f32::to_bits`] — bitwise-equal-but-arithmetically-
130    /// equal cases (`-0.0` vs `0.0`, `NaN` payloads) are treated as
131    /// distinct, which matches what the atlas cache should do anyway.
132    pub fn content_hash(&self) -> u64 {
133        use std::hash::Hasher;
134        let mut h = StableHasher::new();
135        hash_view_box(&mut h, self.view_box);
136        write_len(&mut h, self.paths.len());
137        for path in &self.paths {
138            hash_path(&mut h, path);
139        }
140        write_len(&mut h, self.gradients.len());
141        for grad in &self.gradients {
142            hash_gradient(&mut h, grad);
143        }
144        h.finish()
145    }
146}
147
148fn resolve_vector_color(color: VectorColor, palette: &crate::palette::Palette) -> VectorColor {
149    match color {
150        VectorColor::Solid(c) => VectorColor::Solid(palette.resolve(c)),
151        VectorColor::CurrentColor | VectorColor::Gradient(_) => color,
152    }
153}
154
155/// A small fixed FNV-1a hasher for persistent-ish vector content
156/// identity. `DefaultHasher` is intentionally not specified by std;
157/// this keeps `VectorAsset::content_hash` deterministic across toolchain
158/// runs and target architectures.
159struct StableHasher {
160    state: u64,
161}
162
163impl StableHasher {
164    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
165    const PRIME: u64 = 0x0000_0100_0000_01b3;
166
167    fn new() -> Self {
168        Self {
169            state: Self::OFFSET,
170        }
171    }
172}
173
174impl std::hash::Hasher for StableHasher {
175    fn write(&mut self, bytes: &[u8]) {
176        for byte in bytes {
177            self.state ^= *byte as u64;
178            self.state = self.state.wrapping_mul(Self::PRIME);
179        }
180    }
181
182    fn finish(&self) -> u64 {
183        self.state
184    }
185}
186
187fn write_len(h: &mut impl std::hash::Hasher, len: usize) {
188    h.write_u64(len as u64);
189}
190
191fn hash_str(h: &mut impl std::hash::Hasher, value: &str) {
192    write_len(h, value.len());
193    h.write(value.as_bytes());
194}
195
196fn hash_view_box(h: &mut impl std::hash::Hasher, vb: [f32; 4]) {
197    for v in vb {
198        h.write_u32(v.to_bits());
199    }
200}
201
202fn hash_path(h: &mut impl std::hash::Hasher, path: &VectorPath) {
203    write_len(h, path.segments.len());
204    for seg in &path.segments {
205        hash_segment(h, seg);
206    }
207    match path.fill {
208        Some(f) => {
209            h.write_u8(1);
210            hash_fill(h, f);
211        }
212        None => h.write_u8(0),
213    }
214    match path.stroke {
215        Some(s) => {
216            h.write_u8(1);
217            hash_stroke(h, s);
218        }
219        None => h.write_u8(0),
220    }
221}
222
223fn hash_segment(h: &mut impl std::hash::Hasher, seg: &VectorSegment) {
224    match *seg {
225        VectorSegment::MoveTo(p) => {
226            h.write_u8(0);
227            hash_pt(h, p);
228        }
229        VectorSegment::LineTo(p) => {
230            h.write_u8(1);
231            hash_pt(h, p);
232        }
233        VectorSegment::QuadTo(c, p) => {
234            h.write_u8(2);
235            hash_pt(h, c);
236            hash_pt(h, p);
237        }
238        VectorSegment::CubicTo(c1, c2, p) => {
239            h.write_u8(3);
240            hash_pt(h, c1);
241            hash_pt(h, c2);
242            hash_pt(h, p);
243        }
244        VectorSegment::Close => h.write_u8(4),
245    }
246}
247
248fn hash_pt(h: &mut impl std::hash::Hasher, p: [f32; 2]) {
249    h.write_u32(p[0].to_bits());
250    h.write_u32(p[1].to_bits());
251}
252
253fn hash_fill(h: &mut impl std::hash::Hasher, f: VectorFill) {
254    hash_color(h, f.color);
255    h.write_u32(f.opacity.to_bits());
256    h.write_u8(match f.rule {
257        VectorFillRule::NonZero => 0,
258        VectorFillRule::EvenOdd => 1,
259    });
260}
261
262fn hash_stroke(h: &mut impl std::hash::Hasher, s: VectorStroke) {
263    hash_color(h, s.color);
264    h.write_u32(s.opacity.to_bits());
265    h.write_u32(s.width.to_bits());
266    h.write_u8(match s.line_cap {
267        VectorLineCap::Butt => 0,
268        VectorLineCap::Round => 1,
269        VectorLineCap::Square => 2,
270    });
271    h.write_u8(match s.line_join {
272        VectorLineJoin::Miter => 0,
273        VectorLineJoin::MiterClip => 1,
274        VectorLineJoin::Round => 2,
275        VectorLineJoin::Bevel => 3,
276    });
277    h.write_u32(s.miter_limit.to_bits());
278}
279
280fn hash_color(h: &mut impl std::hash::Hasher, c: VectorColor) {
281    match c {
282        VectorColor::CurrentColor => h.write_u8(0),
283        VectorColor::Solid(col) => {
284            h.write_u8(1);
285            h.write_u32(col.r.to_bits());
286            h.write_u32(col.g.to_bits());
287            h.write_u32(col.b.to_bits());
288            h.write_u32(col.a.to_bits());
289            // The space participates in identity — a color authored in
290            // BT.2020 vs sRGB hashes distinctly even at the same numeric
291            // channel values.
292            std::hash::Hash::hash(&col.space, h);
293            // The token name participates in identity — the same rgba
294            // resolved from different tokens (e.g. a hard-coded
295            // overlay vs `tokens::ACCENT`) should still be one cache
296            // entry post-resolve, but the *unresolved* asset hashes
297            // distinctly so palette swaps invalidate cleanly.
298            match col.token {
299                Some(name) => {
300                    h.write_u8(1);
301                    hash_str(h, name);
302                }
303                None => h.write_u8(0),
304            }
305        }
306        VectorColor::Gradient(idx) => {
307            h.write_u8(2);
308            h.write_u32(idx);
309        }
310    }
311}
312
313fn hash_gradient(h: &mut impl std::hash::Hasher, g: &VectorGradient) {
314    match g {
315        VectorGradient::Linear(lin) => {
316            h.write_u8(0);
317            hash_pt(h, lin.p1);
318            hash_pt(h, lin.p2);
319            hash_stops(h, &lin.stops);
320            hash_spread(h, lin.spread);
321            for v in lin.absolute_to_local {
322                h.write_u32(v.to_bits());
323            }
324        }
325        VectorGradient::Radial(rad) => {
326            h.write_u8(1);
327            hash_pt(h, rad.center);
328            h.write_u32(rad.radius.to_bits());
329            hash_pt(h, rad.focal);
330            h.write_u32(rad.focal_radius.to_bits());
331            hash_stops(h, &rad.stops);
332            hash_spread(h, rad.spread);
333            for v in rad.absolute_to_local {
334                h.write_u32(v.to_bits());
335            }
336        }
337    }
338}
339
340fn hash_stops(h: &mut impl std::hash::Hasher, stops: &[VectorGradientStop]) {
341    write_len(h, stops.len());
342    for stop in stops {
343        h.write_u32(stop.offset.to_bits());
344        for c in stop.color {
345            h.write_u32(c.to_bits());
346        }
347    }
348}
349
350fn hash_spread(h: &mut impl std::hash::Hasher, s: VectorSpreadMethod) {
351    h.write_u8(match s {
352        VectorSpreadMethod::Pad => 0,
353        VectorSpreadMethod::Reflect => 1,
354        VectorSpreadMethod::Repeat => 2,
355    });
356}
357
358/// Imperative builder for a single [`VectorPath`]. Mirrors a subset of
359/// the SVG path command vocabulary (`M`, `L`, `C`, `Q`, `Z`) plus
360/// fill/stroke style. Returns a `VectorPath`; combine multiple via
361/// [`VectorAsset::from_paths`].
362///
363/// ```
364/// use damascene_core::vector::{
365///     PathBuilder, VectorAsset, VectorColor, VectorLineCap,
366/// };
367/// use damascene_core::tree::Color;
368///
369/// let curve = PathBuilder::new()
370///     .move_to(0.0, 0.0)
371///     .cubic_to(20.0, 0.0, 0.0, 60.0, 20.0, 60.0)
372///     .stroke_solid(Color::srgb_u8(80, 200, 240), 2.0)
373///     .stroke_line_cap(VectorLineCap::Round)
374///     .build();
375/// let asset = VectorAsset::from_paths([0.0, 0.0, 20.0, 60.0], vec![curve]);
376/// // `asset.content_hash()` is stable across rebuilds with the same inputs,
377/// // so backends share one atlas slot per unique geometry.
378/// # let _ = asset;
379/// ```
380#[derive(Clone, Debug)]
381pub struct PathBuilder {
382    segments: Vec<VectorSegment>,
383    fill: Option<VectorFill>,
384    stroke: Option<VectorStroke>,
385}
386
387impl Default for PathBuilder {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393impl PathBuilder {
394    /// Create an empty builder with no segments, fill, or stroke.
395    pub fn new() -> Self {
396        Self {
397            segments: Vec::new(),
398            fill: None,
399            stroke: None,
400        }
401    }
402
403    /// SVG `M x y`.
404    pub fn move_to(mut self, x: f32, y: f32) -> Self {
405        self.segments.push(VectorSegment::MoveTo([x, y]));
406        self
407    }
408
409    /// SVG `L x y`.
410    pub fn line_to(mut self, x: f32, y: f32) -> Self {
411        self.segments.push(VectorSegment::LineTo([x, y]));
412        self
413    }
414
415    /// SVG `Q cx cy x y`.
416    pub fn quad_to(mut self, cx: f32, cy: f32, x: f32, y: f32) -> Self {
417        self.segments.push(VectorSegment::QuadTo([cx, cy], [x, y]));
418        self
419    }
420
421    /// SVG `C c1x c1y c2x c2y x y`.
422    pub fn cubic_to(mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) -> Self {
423        self.segments
424            .push(VectorSegment::CubicTo([c1x, c1y], [c2x, c2y], [x, y]));
425        self
426    }
427
428    /// SVG `Z` — close the current subpath back to its `MoveTo`.
429    pub fn close(mut self) -> Self {
430        self.segments.push(VectorSegment::Close);
431        self
432    }
433
434    /// Fill with a solid colour at full opacity, non-zero rule. For
435    /// finer control set [`Self::fill`] directly.
436    pub fn fill_solid(mut self, color: crate::tree::Color) -> Self {
437        self.fill = Some(VectorFill {
438            color: VectorColor::Solid(color),
439            opacity: 1.0,
440            rule: VectorFillRule::NonZero,
441        });
442        self
443    }
444
445    /// Set the fill explicitly. `None` clears it.
446    pub fn fill(mut self, fill: Option<VectorFill>) -> Self {
447        self.fill = fill;
448        self
449    }
450
451    /// Stroke with a solid colour and explicit width, with default
452    /// line cap (`Butt`), line join (`Miter`), and miter limit (4.0).
453    /// For finer control chain [`Self::stroke_line_cap`] /
454    /// [`Self::stroke_line_join`] / [`Self::stroke_miter_limit`].
455    pub fn stroke_solid(mut self, color: crate::tree::Color, width: f32) -> Self {
456        self.stroke = Some(VectorStroke {
457            color: VectorColor::Solid(color),
458            opacity: 1.0,
459            width,
460            line_cap: VectorLineCap::Butt,
461            line_join: VectorLineJoin::Miter,
462            miter_limit: 4.0,
463        });
464        self
465    }
466
467    /// Set the stroke explicitly. `None` clears it.
468    pub fn stroke(mut self, stroke: Option<VectorStroke>) -> Self {
469        self.stroke = stroke;
470        self
471    }
472
473    /// SVG `stroke-linecap`. No-op unless a stroke is already set.
474    pub fn stroke_line_cap(mut self, cap: VectorLineCap) -> Self {
475        if let Some(s) = self.stroke.as_mut() {
476            s.line_cap = cap;
477        }
478        self
479    }
480
481    /// SVG `stroke-linejoin`. No-op unless a stroke is already set.
482    pub fn stroke_line_join(mut self, join: VectorLineJoin) -> Self {
483        if let Some(s) = self.stroke.as_mut() {
484            s.line_join = join;
485        }
486        self
487    }
488
489    /// SVG `stroke-miterlimit`. No-op unless a stroke is already set.
490    pub fn stroke_miter_limit(mut self, limit: f32) -> Self {
491        if let Some(s) = self.stroke.as_mut() {
492            s.miter_limit = limit;
493        }
494        self
495    }
496
497    /// SVG `stroke-opacity` in `0.0..=1.0`. No-op unless a stroke is
498    /// already set.
499    pub fn stroke_opacity(mut self, opacity: f32) -> Self {
500        if let Some(s) = self.stroke.as_mut() {
501            s.opacity = opacity;
502        }
503        self
504    }
505
506    /// Finish the builder into a [`VectorPath`].
507    pub fn build(self) -> VectorPath {
508        VectorPath {
509            segments: self.segments,
510            fill: self.fill,
511            stroke: self.stroke,
512        }
513    }
514}
515
516/// One styled path: segments plus optional fill and stroke. The
517/// flattened equivalent of an SVG `<path>` element, with transforms
518/// already applied so coordinates are absolute viewBox space.
519#[derive(Clone, Debug, PartialEq)]
520pub struct VectorPath {
521    /// Path commands in order, in absolute viewBox coordinates.
522    pub segments: Vec<VectorSegment>,
523    /// Fill style, or `None` (SVG `fill="none"` or an unsupported paint
524    /// such as a pattern).
525    pub fill: Option<VectorFill>,
526    /// Stroke style, or `None` when the path is not stroked.
527    pub stroke: Option<VectorStroke>,
528}
529
530/// One absolute path command (SVG `M`/`L`/`Q`/`C`/`Z`). Points are
531/// `[x, y]` in viewBox space.
532#[derive(Clone, Copy, Debug, PartialEq)]
533pub enum VectorSegment {
534    /// SVG `M x y`: start a new subpath at the point.
535    MoveTo([f32; 2]),
536    /// SVG `L x y`: straight line to the point.
537    LineTo([f32; 2]),
538    /// SVG `Q cx cy x y`: quadratic Bézier (control point, endpoint).
539    QuadTo([f32; 2], [f32; 2]),
540    /// SVG `C c1x c1y c2x c2y x y`: cubic Bézier (two control points,
541    /// endpoint).
542    CubicTo([f32; 2], [f32; 2], [f32; 2]),
543    /// SVG `Z`: close the current subpath back to its `MoveTo`.
544    Close,
545}
546
547/// Fill style for a path (SVG `fill`, `fill-opacity`, `fill-rule`).
548#[derive(Clone, Copy, Debug, PartialEq)]
549pub struct VectorFill {
550    /// Fill paint (SVG `fill`).
551    pub color: VectorColor,
552    /// SVG `fill-opacity` in `0.0..=1.0`, multiplied into the paint's
553    /// alpha at tessellation.
554    pub opacity: f32,
555    /// SVG `fill-rule`.
556    pub rule: VectorFillRule,
557}
558
559/// Stroke style for a path (SVG `stroke` and its companion properties).
560#[derive(Clone, Copy, Debug, PartialEq)]
561pub struct VectorStroke {
562    /// Stroke paint (SVG `stroke`).
563    pub color: VectorColor,
564    /// SVG `stroke-opacity` in `0.0..=1.0`, multiplied into the paint's
565    /// alpha at tessellation.
566    pub opacity: f32,
567    /// SVG `stroke-width` in viewBox units; scaled to the destination
568    /// rect at tessellation. `currentColor` strokes are instead widened
569    /// by [`VectorMeshOptions::stroke_width`].
570    pub width: f32,
571    /// SVG `stroke-linecap`.
572    pub line_cap: VectorLineCap,
573    /// SVG `stroke-linejoin`.
574    pub line_join: VectorLineJoin,
575    /// SVG `stroke-miterlimit` (clamped to `>= 1.0` at tessellation).
576    pub miter_limit: f32,
577}
578
579/// Paint for a fill or stroke. Kept `Copy` by referencing gradients
580/// through an index into the asset's side-table.
581#[derive(Clone, Copy, Debug, PartialEq)]
582pub enum VectorColor {
583    /// SVG `currentColor`: substituted with
584    /// [`VectorMeshOptions::current_color`] at tessellation.
585    CurrentColor,
586    /// A solid colour. Palette tokens stay unresolved until
587    /// [`VectorAsset::resolved_palette`].
588    Solid(Color),
589    /// Index into [`VectorAsset::gradients`].
590    Gradient(u32),
591}
592
593/// A linear or radial gradient resolved to absolute SVG/viewBox space. The
594/// stored axis/centre coordinates live in the gradient's own coordinate
595/// system; `absolute_to_local` maps a point in absolute SVG space back into
596/// that system so per-vertex evaluation is one matrix-multiply away.
597#[derive(Clone, Debug, PartialEq)]
598pub enum VectorGradient {
599    /// SVG `<linearGradient>`.
600    Linear(VectorLinearGradient),
601    /// SVG `<radialGradient>`.
602    Radial(VectorRadialGradient),
603}
604
605/// An SVG `<linearGradient>` resolved by usvg (`objectBoundingBox` units
606/// already baked into the transform).
607#[derive(Clone, Debug, PartialEq)]
608pub struct VectorLinearGradient {
609    /// Gradient axis start (SVG `x1`/`y1`) in the gradient's local space.
610    pub p1: [f32; 2],
611    /// Gradient axis end (SVG `x2`/`y2`) in the gradient's local space.
612    pub p2: [f32; 2],
613    /// Colour stops, sorted by non-decreasing offset.
614    pub stops: Vec<VectorGradientStop>,
615    /// SVG `spreadMethod`: how the gradient parameter wraps outside `0..=1`.
616    pub spread: VectorSpreadMethod,
617    /// Row-major 2x3 affine `[sx, kx, tx, ky, sy, ty]` mapping absolute
618    /// SVG coordinates into the gradient's own coordinate system.
619    pub absolute_to_local: [f32; 6],
620}
621
622/// An SVG `<radialGradient>` resolved by usvg (`objectBoundingBox` units
623/// already baked into the transform).
624///
625/// Sampling currently treats the gradient as concentric about `center`
626/// with radius `radius`; offset focal points parse but render without
627/// the focal-cone projection.
628#[derive(Clone, Debug, PartialEq)]
629pub struct VectorRadialGradient {
630    /// Centre (SVG `cx`/`cy`) in the gradient's local space.
631    pub center: [f32; 2],
632    /// Radius (SVG `r`) in the gradient's local space.
633    pub radius: f32,
634    /// Focal point (SVG `fx`/`fy`); see the concentric-sampling note above.
635    pub focal: [f32; 2],
636    /// Focal radius (SVG `fr`); see the concentric-sampling note above.
637    pub focal_radius: f32,
638    /// Colour stops, sorted by non-decreasing offset.
639    pub stops: Vec<VectorGradientStop>,
640    /// SVG `spreadMethod`: how the gradient parameter wraps outside `0..=1`.
641    pub spread: VectorSpreadMethod,
642    /// Row-major 2x3 affine `[sx, kx, tx, ky, sy, ty]` mapping absolute
643    /// SVG coordinates into the gradient's own coordinate system.
644    pub absolute_to_local: [f32; 6],
645}
646
647/// A gradient stop. The colour is stored in linear premultiplied-friendly
648/// floats (sRGB → linear, with the per-stop opacity baked into the alpha)
649/// so vertex interpolation matches what the shader expects.
650#[derive(Clone, Copy, Debug, PartialEq)]
651pub struct VectorGradientStop {
652    /// Stop position along the gradient in `0.0..=1.0` (SVG stop
653    /// `offset`), non-decreasing across the stop list.
654    pub offset: f32,
655    /// Canonical linear sRGB, baked at parse time. Assets are cached and
656    /// space-independent; conversion into the negotiated working space
657    /// happens at tessellation (see [`VectorMeshOptions::working_color_space`]).
658    pub color: [f32; 4],
659}
660
661/// SVG gradient `spreadMethod`: how the gradient parameter behaves
662/// outside `0..=1`.
663#[derive(Clone, Copy, Debug, PartialEq, Eq)]
664pub enum VectorSpreadMethod {
665    /// SVG `pad` (the default): clamp to the edge stops.
666    Pad,
667    /// SVG `reflect`: mirror back and forth.
668    Reflect,
669    /// SVG `repeat`: wrap around.
670    Repeat,
671}
672
673/// SVG `fill-rule`: how self-intersecting paths determine interior
674/// coverage.
675#[derive(Clone, Copy, Debug, PartialEq, Eq)]
676pub enum VectorFillRule {
677    /// SVG `nonzero` (the default): winding-number rule.
678    NonZero,
679    /// SVG `evenodd`: crossing-parity rule.
680    EvenOdd,
681}
682
683/// SVG `stroke-linecap`: the shape drawn at the ends of open subpaths.
684#[derive(Clone, Copy, Debug, PartialEq, Eq)]
685pub enum VectorLineCap {
686    /// SVG `butt` (the default): flat edge exactly at the endpoint.
687    Butt,
688    /// SVG `round`: semicircular cap.
689    Round,
690    /// SVG `square`: square cap extending half the stroke width.
691    Square,
692}
693
694/// SVG `stroke-linejoin`: the shape drawn where path segments meet.
695#[derive(Clone, Copy, Debug, PartialEq, Eq)]
696pub enum VectorLineJoin {
697    /// SVG `miter` (the default): sharp corner, subject to the miter limit.
698    Miter,
699    /// SVG `miter-clip`: miter clipped at the limit instead of falling
700    /// back to bevel.
701    MiterClip,
702    /// SVG `round`: circular-arc corner.
703    Round,
704    /// SVG `bevel`: flat corner.
705    Bevel,
706}
707
708/// Shader-side material treatment for icon meshes, selected per theme
709/// via [`crate::theme::Theme::with_icon_material`].
710#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
711pub enum IconMaterial {
712    /// Direct premultiplied color. This is the baseline material and
713    /// should match ordinary flat SVG rendering.
714    #[default]
715    Flat,
716    /// A proof material that uses local vector coordinates to add a
717    /// subtle top-left highlight and lower shadow. This exists to prove
718    /// the shared mesh carries enough data for shader-controlled icon
719    /// treatments.
720    Relief,
721    /// A glossy icon material with local-coordinate glints and a soft
722    /// inner shade. Pairs with translucent/glass surfaces.
723    Glass,
724}
725
726/// One tessellated vertex of a [`VectorMesh`]. `#[repr(C)]` and `Pod`
727/// so backends can upload vertex buffers directly.
728#[repr(C)]
729#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
730pub struct VectorMeshVertex {
731    /// Logical-pixel position after fitting the vector asset into its
732    /// destination rect.
733    pub pos: [f32; 2],
734    /// SVG/viewBox-space coordinate. Theme shaders can use this for
735    /// gradients, highlights, bevels, and other icon-local effects.
736    pub local: [f32; 2],
737    /// Vertex RGBA in the mesh's working color space (see
738    /// [`VectorMeshOptions::working_color_space`]), with fill/stroke
739    /// opacity baked into alpha.
740    pub color: [f32; 4],
741    /// Reserved for material shaders: x = path index, y = primitive
742    /// kind (0 fill, 1 stroke), z/w reserved.
743    pub meta: [f32; 4],
744}
745
746/// A tessellated vector asset as a flat, non-indexed triangle list.
747#[derive(Clone, Debug, Default, PartialEq)]
748pub struct VectorMesh {
749    /// Triangle-list vertices: every three consecutive vertices form one
750    /// triangle (indices are pre-expanded).
751    pub vertices: Vec<VectorMeshVertex>,
752}
753
754/// The span appended to a shared vertex vector by
755/// [`append_vector_asset_mesh`] — a draw range for non-indexed rendering.
756#[derive(Clone, Copy, Debug, PartialEq)]
757pub struct VectorMeshRun {
758    /// Index of the run's first vertex in the destination vector.
759    pub first: u32,
760    /// Number of vertices in the run (a multiple of 3; 0 for a
761    /// degenerate destination rect).
762    pub count: u32,
763}
764
765/// Parameters for tessellating a [`VectorAsset`] into a [`VectorMesh`].
766#[derive(Clone, Copy, Debug, PartialEq)]
767pub struct VectorMeshOptions {
768    /// Destination rectangle in logical pixels; the asset's view box is
769    /// scaled (per-axis, so possibly non-uniformly) to fill it.
770    pub rect: crate::tree::Rect,
771    /// Colour substituted for SVG `currentColor` fills and strokes.
772    pub current_color: Color,
773    /// Stroke width in viewBox units applied to `currentColor` strokes,
774    /// overriding their authored width; other strokes keep their own.
775    pub stroke_width: f32,
776    /// Curve-flattening tolerance for the lyon tessellators, in
777    /// destination logical pixels (lower is smoother;
778    /// [`VectorMeshOptions::icon`] uses `0.05`).
779    pub tolerance: f32,
780    /// Working color space vertex colors are packed in — solid fills,
781    /// `currentColor`, and sampled gradient stops all cross the
782    /// working-space boundary here (`rgba_f32_in` semantics). Backends
783    /// pass their painter's negotiated space.
784    pub working_color_space: ColorSpace,
785}
786
787impl VectorMeshOptions {
788    /// Options preset for UI icons: the given rect, `currentColor`,
789    /// stroke width, and working space, with the icon-tuned tolerance of
790    /// `0.05` logical pixels.
791    pub fn icon(
792        rect: crate::tree::Rect,
793        current_color: Color,
794        stroke_width: f32,
795        working_color_space: ColorSpace,
796    ) -> Self {
797        Self {
798            rect,
799            current_color,
800            stroke_width,
801            tolerance: 0.05,
802            working_color_space,
803        }
804    }
805}
806
807/// Error returned by [`parse_svg_asset`]: the SVG failed to parse, or it
808/// produced no renderable paths.
809#[derive(Clone, Debug, PartialEq, Eq)]
810pub struct VectorParseError {
811    message: String,
812}
813
814impl VectorParseError {
815    fn new(message: impl Into<String>) -> Self {
816        Self {
817            message: message.into(),
818        }
819    }
820}
821
822impl fmt::Display for VectorParseError {
823    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
824        f.write_str(&self.message)
825    }
826}
827
828impl Error for VectorParseError {}
829
830/// Parse an SVG string into a [`VectorAsset`], preserving authored
831/// fills, strokes, and gradients.
832///
833/// usvg performs the normalization: XML, style inheritance, transforms,
834/// arcs, and basic shapes are resolved, and groups are flattened to
835/// paths. Unsupported paint (patterns) and non-path content (text,
836/// images, filters) are silently dropped. Errors if the SVG fails to
837/// parse or yields no renderable paths.
838pub fn parse_svg_asset(svg: &str) -> Result<VectorAsset, VectorParseError> {
839    parse_svg_asset_with_color_mode(svg, false)
840}
841
842/// Tessellate `asset` into a standalone triangle-list [`VectorMesh`].
843/// Convenience over [`append_vector_asset_mesh`] for callers that do not
844/// batch several assets into one shared vertex vector.
845pub fn tessellate_vector_asset(asset: &VectorAsset, options: VectorMeshOptions) -> VectorMesh {
846    let mut mesh = VectorMesh::default();
847    append_vector_asset_mesh(asset, options, &mut mesh.vertices);
848    mesh
849}
850
851/// Tessellate `asset` and append its triangle-list vertices to `out`,
852/// returning the appended span as a [`VectorMeshRun`].
853///
854/// Fills and strokes are flattened with lyon at `options.tolerance`,
855/// scaled from the asset's view box into `options.rect`, and coloured
856/// in `options.working_color_space` (solid fills, `currentColor`, and
857/// gradient samples alike). Returns an empty run when the destination
858/// rect has zero or negative area.
859pub fn append_vector_asset_mesh(
860    asset: &VectorAsset,
861    options: VectorMeshOptions,
862    out: &mut Vec<VectorMeshVertex>,
863) -> VectorMeshRun {
864    let first = out.len() as u32;
865    if options.rect.w <= 0.0 || options.rect.h <= 0.0 {
866        return VectorMeshRun { first, count: 0 };
867    }
868
869    let [vx, vy, vw, vh] = asset.view_box;
870    // SVG: a viewBox with a zero (or negative) dimension disables
871    // rendering of the element entirely. Dividing by the real extent
872    // otherwise keeps sub-unit view boxes (legal SVG, e.g. 0.5 units
873    // wide) scaling correctly instead of silently rendering at the
874    // wrong size through a `max(1.0)` guard.
875    if vw <= 0.0 || vh <= 0.0 {
876        return VectorMeshRun { first, count: 0 };
877    }
878    let sx = options.rect.w / vw;
879    let sy = options.rect.h / vh;
880    let stroke_scale = (sx + sy) * 0.5;
881
882    for (path_index, vector_path) in asset.paths.iter().enumerate() {
883        let path = build_lyon_path(vector_path, options.rect, [vx, vy], [sx, sy]);
884        if let Some(fill) = vector_path.fill {
885            let sampler = ColorSampler::build(
886                fill.color,
887                fill.opacity,
888                options.current_color,
889                &asset.gradients,
890                options.working_color_space,
891            );
892            let mut geometry: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
893            let fill_options =
894                FillOptions::tolerance(options.tolerance).with_fill_rule(match fill.rule {
895                    VectorFillRule::NonZero => lyon_tessellation::FillRule::NonZero,
896                    VectorFillRule::EvenOdd => lyon_tessellation::FillRule::EvenOdd,
897                });
898            let _ = FillTessellator::new().tessellate_path(
899                &path,
900                &fill_options,
901                &mut BuffersBuilder::new(&mut geometry, |v: FillVertex<'_>| {
902                    make_mesh_vertex_sampled(
903                        v.position(),
904                        options.rect,
905                        [vx, vy],
906                        [sx, sy],
907                        &sampler,
908                        path_index,
909                        VectorPrimitiveKind::Fill,
910                    )
911                }),
912            );
913            append_indexed(&geometry, out);
914        }
915
916        if let Some(stroke) = vector_path.stroke {
917            let sampler = ColorSampler::build(
918                stroke.color,
919                stroke.opacity,
920                options.current_color,
921                &asset.gradients,
922                options.working_color_space,
923            );
924            let width = if matches!(stroke.color, VectorColor::CurrentColor) {
925                options.stroke_width * stroke_scale
926            } else {
927                stroke.width * stroke_scale
928            }
929            .max(0.5);
930            let mut geometry: VertexBuffers<VectorMeshVertex, u16> = VertexBuffers::new();
931            let stroke_options = StrokeOptions::tolerance(options.tolerance)
932                .with_line_width(width)
933                .with_line_cap(match stroke.line_cap {
934                    VectorLineCap::Butt => LineCap::Butt,
935                    VectorLineCap::Round => LineCap::Round,
936                    VectorLineCap::Square => LineCap::Square,
937                })
938                .with_line_join(match stroke.line_join {
939                    VectorLineJoin::Miter => LineJoin::Miter,
940                    VectorLineJoin::MiterClip => LineJoin::MiterClip,
941                    VectorLineJoin::Round => LineJoin::Round,
942                    VectorLineJoin::Bevel => LineJoin::Bevel,
943                })
944                .with_miter_limit(stroke.miter_limit.max(1.0));
945            let _ = StrokeTessellator::new().tessellate_path(
946                &path,
947                &stroke_options,
948                &mut BuffersBuilder::new(&mut geometry, |v: StrokeVertex<'_, '_>| {
949                    make_mesh_vertex_sampled(
950                        v.position(),
951                        options.rect,
952                        [vx, vy],
953                        [sx, sy],
954                        &sampler,
955                        path_index,
956                        VectorPrimitiveKind::Stroke,
957                    )
958                }),
959            );
960            append_indexed(&geometry, out);
961        }
962    }
963
964    VectorMeshRun {
965        first,
966        count: out.len() as u32 - first,
967    }
968}
969
970pub(crate) fn parse_current_color_svg_asset(svg: &str) -> Result<VectorAsset, VectorParseError> {
971    parse_svg_asset_with_color_mode(svg, true)
972}
973
974fn parse_svg_asset_with_color_mode(
975    svg: &str,
976    force_current_color: bool,
977) -> Result<VectorAsset, VectorParseError> {
978    let tree = usvg::Tree::from_str(svg, &usvg::Options::default())
979        .map_err(|e| VectorParseError::new(format!("invalid SVG: {e}")))?;
980    let size = tree.size();
981    let mut asset = VectorAsset {
982        view_box: [0.0, 0.0, size.width(), size.height()],
983        paths: Vec::new(),
984        gradients: Vec::new(),
985    };
986    collect_group(
987        tree.root(),
988        force_current_color,
989        &mut asset.paths,
990        &mut asset.gradients,
991    );
992    if asset.paths.is_empty() {
993        return Err(VectorParseError::new("SVG produced no renderable paths"));
994    }
995    Ok(asset)
996}
997
998fn collect_group(
999    group: &usvg::Group,
1000    force_current_color: bool,
1001    out: &mut Vec<VectorPath>,
1002    gradients: &mut Vec<VectorGradient>,
1003) {
1004    for node in group.children() {
1005        match node {
1006            usvg::Node::Group(group) => collect_group(group, force_current_color, out, gradients),
1007            usvg::Node::Path(path) if path.is_visible() => {
1008                if let Some(vector_path) = convert_path(path, force_current_color, gradients) {
1009                    out.push(vector_path);
1010                }
1011            }
1012            _ => {}
1013        }
1014    }
1015}
1016
1017fn convert_path(
1018    path: &usvg::Path,
1019    force_current_color: bool,
1020    gradients: &mut Vec<VectorGradient>,
1021) -> Option<VectorPath> {
1022    let transform = path.abs_transform();
1023    let mut segments = Vec::new();
1024    for segment in path.data().segments() {
1025        match segment {
1026            tiny_skia_path::PathSegment::MoveTo(p) => {
1027                segments.push(VectorSegment::MoveTo(map_point(transform, p)));
1028            }
1029            tiny_skia_path::PathSegment::LineTo(p) => {
1030                segments.push(VectorSegment::LineTo(map_point(transform, p)));
1031            }
1032            tiny_skia_path::PathSegment::QuadTo(p0, p1) => {
1033                segments.push(VectorSegment::QuadTo(
1034                    map_point(transform, p0),
1035                    map_point(transform, p1),
1036                ));
1037            }
1038            tiny_skia_path::PathSegment::CubicTo(p0, p1, p2) => {
1039                segments.push(VectorSegment::CubicTo(
1040                    map_point(transform, p0),
1041                    map_point(transform, p1),
1042                    map_point(transform, p2),
1043                ));
1044            }
1045            tiny_skia_path::PathSegment::Close => segments.push(VectorSegment::Close),
1046        }
1047    }
1048    if segments.is_empty() {
1049        return None;
1050    }
1051
1052    Some(VectorPath {
1053        segments,
1054        fill: path
1055            .fill()
1056            .and_then(|fill| convert_fill(fill, transform, force_current_color, gradients)),
1057        stroke: path
1058            .stroke()
1059            .and_then(|stroke| convert_stroke(stroke, transform, force_current_color, gradients)),
1060    })
1061}
1062
1063fn convert_fill(
1064    fill: &usvg::Fill,
1065    abs_transform: tiny_skia_path::Transform,
1066    force_current_color: bool,
1067    gradients: &mut Vec<VectorGradient>,
1068) -> Option<VectorFill> {
1069    Some(VectorFill {
1070        color: convert_paint(fill.paint(), abs_transform, force_current_color, gradients)?,
1071        opacity: fill.opacity().get(),
1072        rule: match fill.rule() {
1073            usvg::FillRule::NonZero => VectorFillRule::NonZero,
1074            usvg::FillRule::EvenOdd => VectorFillRule::EvenOdd,
1075        },
1076    })
1077}
1078
1079fn convert_stroke(
1080    stroke: &usvg::Stroke,
1081    abs_transform: tiny_skia_path::Transform,
1082    force_current_color: bool,
1083    gradients: &mut Vec<VectorGradient>,
1084) -> Option<VectorStroke> {
1085    Some(VectorStroke {
1086        color: convert_paint(
1087            stroke.paint(),
1088            abs_transform,
1089            force_current_color,
1090            gradients,
1091        )?,
1092        opacity: stroke.opacity().get(),
1093        width: stroke.width().get(),
1094        line_cap: match stroke.linecap() {
1095            usvg::LineCap::Butt => VectorLineCap::Butt,
1096            usvg::LineCap::Round => VectorLineCap::Round,
1097            usvg::LineCap::Square => VectorLineCap::Square,
1098        },
1099        line_join: match stroke.linejoin() {
1100            usvg::LineJoin::Miter => VectorLineJoin::Miter,
1101            usvg::LineJoin::MiterClip => VectorLineJoin::MiterClip,
1102            usvg::LineJoin::Round => VectorLineJoin::Round,
1103            usvg::LineJoin::Bevel => VectorLineJoin::Bevel,
1104        },
1105        miter_limit: stroke.miterlimit().get(),
1106    })
1107}
1108
1109fn convert_paint(
1110    paint: &usvg::Paint,
1111    abs_transform: tiny_skia_path::Transform,
1112    force_current_color: bool,
1113    gradients: &mut Vec<VectorGradient>,
1114) -> Option<VectorColor> {
1115    if force_current_color {
1116        return Some(VectorColor::CurrentColor);
1117    }
1118    match paint {
1119        usvg::Paint::Color(c) => Some(VectorColor::Solid(Color::srgb_u8a(
1120            c.red, c.green, c.blue, 255,
1121        ))),
1122        usvg::Paint::LinearGradient(lg) => {
1123            let g = convert_linear_gradient(lg, abs_transform)?;
1124            let idx = gradients.len() as u32;
1125            gradients.push(VectorGradient::Linear(g));
1126            Some(VectorColor::Gradient(idx))
1127        }
1128        usvg::Paint::RadialGradient(rg) => {
1129            let g = convert_radial_gradient(rg, abs_transform)?;
1130            let idx = gradients.len() as u32;
1131            gradients.push(VectorGradient::Radial(g));
1132            Some(VectorColor::Gradient(idx))
1133        }
1134        usvg::Paint::Pattern(_) => None,
1135    }
1136}
1137
1138fn convert_linear_gradient(
1139    lg: &usvg::LinearGradient,
1140    abs_transform: tiny_skia_path::Transform,
1141) -> Option<VectorLinearGradient> {
1142    let stops = convert_stops(lg.stops());
1143    if stops.is_empty() {
1144        return None;
1145    }
1146    let absolute_to_local = build_absolute_to_local(abs_transform, lg.transform())?;
1147    Some(VectorLinearGradient {
1148        p1: [lg.x1(), lg.y1()],
1149        p2: [lg.x2(), lg.y2()],
1150        stops,
1151        spread: convert_spread(lg.spread_method()),
1152        absolute_to_local,
1153    })
1154}
1155
1156fn convert_radial_gradient(
1157    rg: &usvg::RadialGradient,
1158    abs_transform: tiny_skia_path::Transform,
1159) -> Option<VectorRadialGradient> {
1160    let stops = convert_stops(rg.stops());
1161    if stops.is_empty() {
1162        return None;
1163    }
1164    let absolute_to_local = build_absolute_to_local(abs_transform, rg.transform())?;
1165    Some(VectorRadialGradient {
1166        center: [rg.cx(), rg.cy()],
1167        radius: rg.r().get(),
1168        focal: [rg.fx(), rg.fy()],
1169        focal_radius: rg.fr().get(),
1170        stops,
1171        spread: convert_spread(rg.spread_method()),
1172        absolute_to_local,
1173    })
1174}
1175
1176fn convert_stops(stops: &[usvg::Stop]) -> Vec<VectorGradientStop> {
1177    let mut out = Vec::with_capacity(stops.len());
1178    let mut last_offset = 0.0_f32;
1179    for stop in stops {
1180        // SVG requires monotonically non-decreasing offsets; nudge so a
1181        // straight binary search over `out` always works.
1182        let offset = stop.offset().get().max(last_offset);
1183        last_offset = offset;
1184        // Canonicalize to linear sRGB explicitly (not the working-space
1185        // default): parsed assets are cached across frames, so the bake
1186        // must not depend on any negotiated space.
1187        let mut rgba = rgba_f32_in(
1188            Color::srgb_u8a(stop.color().red, stop.color().green, stop.color().blue, 255),
1189            ColorSpace::SRGB_LINEAR,
1190        );
1191        rgba[3] *= stop.opacity().get();
1192        out.push(VectorGradientStop {
1193            offset,
1194            color: rgba,
1195        });
1196    }
1197    out
1198}
1199
1200fn convert_spread(method: usvg::SpreadMethod) -> VectorSpreadMethod {
1201    match method {
1202        usvg::SpreadMethod::Pad => VectorSpreadMethod::Pad,
1203        usvg::SpreadMethod::Reflect => VectorSpreadMethod::Reflect,
1204        usvg::SpreadMethod::Repeat => VectorSpreadMethod::Repeat,
1205    }
1206}
1207
1208/// Build the inverse transform that maps an absolute SVG coordinate (post
1209/// `path.abs_transform()`) into the gradient's own coordinate system.
1210///
1211/// `gradient_transform` from usvg already takes a gradient-local point into
1212/// the path's *local* user space (with bbox-units pre-baked). Composing
1213/// with `abs_transform` lifts that into absolute space; inverting gives us
1214/// the back-mapping the per-vertex sampler needs.
1215fn build_absolute_to_local(
1216    abs_transform: tiny_skia_path::Transform,
1217    gradient_transform: tiny_skia_path::Transform,
1218) -> Option<[f32; 6]> {
1219    let local_to_absolute = abs_transform.pre_concat(gradient_transform);
1220    let inv = local_to_absolute.invert()?;
1221    Some([inv.sx, inv.kx, inv.tx, inv.ky, inv.sy, inv.ty])
1222}
1223
1224fn map_point(transform: tiny_skia_path::Transform, mut point: tiny_skia_path::Point) -> [f32; 2] {
1225    transform.map_point(&mut point);
1226    [point.x, point.y]
1227}
1228
1229#[derive(Clone, Copy)]
1230enum VectorPrimitiveKind {
1231    Fill,
1232    Stroke,
1233}
1234
1235fn build_lyon_path(
1236    path: &VectorPath,
1237    rect: crate::tree::Rect,
1238    view_origin: [f32; 2],
1239    scale: [f32; 2],
1240) -> LyonPath {
1241    let mut builder = LyonPath::builder();
1242    let mut open = false;
1243    for segment in &path.segments {
1244        match *segment {
1245            VectorSegment::MoveTo(p) => {
1246                if open {
1247                    builder.end(false);
1248                }
1249                builder.begin(map_mesh_point(rect, view_origin, scale, p));
1250                open = true;
1251            }
1252            VectorSegment::LineTo(p) => {
1253                builder.line_to(map_mesh_point(rect, view_origin, scale, p));
1254            }
1255            VectorSegment::QuadTo(c, p) => {
1256                builder.quadratic_bezier_to(
1257                    map_mesh_point(rect, view_origin, scale, c),
1258                    map_mesh_point(rect, view_origin, scale, p),
1259                );
1260            }
1261            VectorSegment::CubicTo(c0, c1, p) => {
1262                builder.cubic_bezier_to(
1263                    map_mesh_point(rect, view_origin, scale, c0),
1264                    map_mesh_point(rect, view_origin, scale, c1),
1265                    map_mesh_point(rect, view_origin, scale, p),
1266                );
1267            }
1268            VectorSegment::Close => {
1269                if open {
1270                    builder.close();
1271                    open = false;
1272                }
1273            }
1274        }
1275    }
1276    if open {
1277        builder.end(false);
1278    }
1279    builder.build()
1280}
1281
1282fn map_mesh_point(
1283    rect: crate::tree::Rect,
1284    view_origin: [f32; 2],
1285    scale: [f32; 2],
1286    p: [f32; 2],
1287) -> lyon_tessellation::math::Point {
1288    point(
1289        rect.x + (p[0] - view_origin[0]) * scale[0],
1290        rect.y + (p[1] - view_origin[1]) * scale[1],
1291    )
1292}
1293
1294fn make_mesh_vertex_sampled(
1295    p: lyon_tessellation::math::Point,
1296    rect: crate::tree::Rect,
1297    view_origin: [f32; 2],
1298    scale: [f32; 2],
1299    sampler: &ColorSampler<'_>,
1300    path_index: usize,
1301    kind: VectorPrimitiveKind,
1302) -> VectorMeshVertex {
1303    let local = [
1304        view_origin[0] + (p.x - rect.x) / scale[0].max(f32::EPSILON),
1305        view_origin[1] + (p.y - rect.y) / scale[1].max(f32::EPSILON),
1306    ];
1307    VectorMeshVertex {
1308        pos: [p.x, p.y],
1309        local,
1310        color: sampler.sample(local),
1311        meta: [
1312            path_index as f32,
1313            match kind {
1314                VectorPrimitiveKind::Fill => 0.0,
1315                VectorPrimitiveKind::Stroke => 1.0,
1316            },
1317            0.0,
1318            0.0,
1319        ],
1320    }
1321}
1322
1323/// Per-vertex colour resolver. Solid/`currentColor` paths bake to a single
1324/// constant; gradient paths defer to per-vertex evaluation against the
1325/// vertex's SVG-space `local` coordinate. All variants resolve into the
1326/// mesh's working color space.
1327enum ColorSampler<'a> {
1328    Solid([f32; 4]),
1329    Gradient {
1330        gradient: &'a VectorGradient,
1331        opacity: f32,
1332        working: ColorSpace,
1333    },
1334}
1335
1336impl<'a> ColorSampler<'a> {
1337    fn build(
1338        color: VectorColor,
1339        opacity: f32,
1340        current_color: Color,
1341        gradients: &'a [VectorGradient],
1342        working: ColorSpace,
1343    ) -> Self {
1344        let opacity = opacity.clamp(0.0, 1.0);
1345        match color {
1346            VectorColor::CurrentColor => {
1347                let mut c = rgba_f32_in(current_color, working);
1348                c[3] *= opacity;
1349                Self::Solid(c)
1350            }
1351            VectorColor::Solid(c) => {
1352                let mut rgba = rgba_f32_in(c, working);
1353                rgba[3] *= opacity;
1354                Self::Solid(rgba)
1355            }
1356            VectorColor::Gradient(idx) => match gradients.get(idx as usize) {
1357                Some(gradient) => Self::Gradient {
1358                    gradient,
1359                    opacity,
1360                    working,
1361                },
1362                // Index out of range — should not happen for parsed assets;
1363                // keep the path renderable as transparent rather than crashing.
1364                None => Self::Solid([0.0; 4]),
1365            },
1366        }
1367    }
1368
1369    fn sample(&self, abs_local: [f32; 2]) -> [f32; 4] {
1370        match self {
1371            Self::Solid(c) => *c,
1372            Self::Gradient {
1373                gradient,
1374                opacity,
1375                working,
1376            } => {
1377                // Stops are canonical linear sRGB (parse-time); convert the
1378                // lerped sample into the working space. Both spaces are
1379                // linear, so converting after the lerp equals converting
1380                // each stop first.
1381                let [r, g, b, a] = sample_gradient(gradient, abs_local);
1382                let mut c = rgba_f32_in(Color::srgb_linear(r, g, b, a), *working);
1383                c[3] *= *opacity;
1384                c
1385            }
1386        }
1387    }
1388}
1389
1390fn sample_gradient(gradient: &VectorGradient, abs_local: [f32; 2]) -> [f32; 4] {
1391    match gradient {
1392        VectorGradient::Linear(g) => {
1393            let local = apply_affine(&g.absolute_to_local, abs_local);
1394            let dx = g.p2[0] - g.p1[0];
1395            let dy = g.p2[1] - g.p1[1];
1396            let len2 = (dx * dx + dy * dy).max(f32::EPSILON);
1397            let t = ((local[0] - g.p1[0]) * dx + (local[1] - g.p1[1]) * dy) / len2;
1398            sample_stops(&g.stops, apply_spread(t, g.spread))
1399        }
1400        VectorGradient::Radial(g) => {
1401            // Damascene v0: treat radial gradients as concentric about `center`
1402            // with radius `radius`. This matches the common authoring case
1403            // (focal == centre, focal_radius == 0); offset focal points are
1404            // accepted but rendered without the cone-projection nuance.
1405            let local = apply_affine(&g.absolute_to_local, abs_local);
1406            let dx = local[0] - g.center[0];
1407            let dy = local[1] - g.center[1];
1408            let radius = g.radius.max(f32::EPSILON);
1409            let t = (dx * dx + dy * dy).sqrt() / radius;
1410            sample_stops(&g.stops, apply_spread(t, g.spread))
1411        }
1412    }
1413}
1414
1415fn apply_affine(m: &[f32; 6], p: [f32; 2]) -> [f32; 2] {
1416    [
1417        p[0] * m[0] + p[1] * m[1] + m[2],
1418        p[0] * m[3] + p[1] * m[4] + m[5],
1419    ]
1420}
1421
1422fn apply_spread(t: f32, spread: VectorSpreadMethod) -> f32 {
1423    match spread {
1424        VectorSpreadMethod::Pad => t.clamp(0.0, 1.0),
1425        VectorSpreadMethod::Reflect => {
1426            let m = t.rem_euclid(2.0);
1427            if m > 1.0 { 2.0 - m } else { m }
1428        }
1429        VectorSpreadMethod::Repeat => t.rem_euclid(1.0),
1430    }
1431}
1432
1433fn sample_stops(stops: &[VectorGradientStop], t: f32) -> [f32; 4] {
1434    if stops.is_empty() {
1435        return [0.0; 4];
1436    }
1437    if t <= stops[0].offset {
1438        return stops[0].color;
1439    }
1440    let last = stops.len() - 1;
1441    if t >= stops[last].offset {
1442        return stops[last].color;
1443    }
1444    for i in 1..stops.len() {
1445        if t <= stops[i].offset {
1446            let prev = &stops[i - 1];
1447            let next = &stops[i];
1448            let span = (next.offset - prev.offset).max(f32::EPSILON);
1449            let frac = ((t - prev.offset) / span).clamp(0.0, 1.0);
1450            return [
1451                prev.color[0] + (next.color[0] - prev.color[0]) * frac,
1452                prev.color[1] + (next.color[1] - prev.color[1]) * frac,
1453                prev.color[2] + (next.color[2] - prev.color[2]) * frac,
1454                prev.color[3] + (next.color[3] - prev.color[3]) * frac,
1455            ];
1456        }
1457    }
1458    stops[last].color
1459}
1460
1461fn append_indexed(
1462    geometry: &VertexBuffers<VectorMeshVertex, u16>,
1463    out: &mut Vec<VectorMeshVertex>,
1464) {
1465    for index in &geometry.indices {
1466        if let Some(vertex) = geometry.vertices.get(*index as usize) {
1467            out.push(*vertex);
1468        }
1469    }
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474    use super::*;
1475    use crate::icons::{all_icon_names, icon_vector_asset};
1476
1477    #[test]
1478    fn parses_basic_svg_shapes_into_paths() {
1479        let asset = parse_svg_asset(
1480            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>"##,
1481        )
1482        .unwrap();
1483        assert_eq!(asset.view_box, [0.0, 0.0, 24.0, 24.0]);
1484        assert_eq!(asset.paths.len(), 1);
1485        assert!(asset.paths[0].stroke.is_some());
1486        assert!(asset.paths[0].segments.len() > 4);
1487    }
1488
1489    #[test]
1490    fn sub_unit_view_box_scales_to_fill_the_destination_rect() {
1491        // A 0.5×0.5 viewBox is legal SVG; the old `vw.max(1.0)`
1492        // div-by-zero guard silently rendered it at half size.
1493        let asset = parse_svg_asset(
1494            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0.5 0.5"><rect width="0.5" height="0.5" fill="#f00"/></svg>"##,
1495        )
1496        .unwrap();
1497        let mesh = tessellate_vector_asset(
1498            &asset,
1499            VectorMeshOptions::icon(
1500                crate::tree::Rect::new(0.0, 0.0, 100.0, 100.0),
1501                Color::srgb_u8(0, 0, 0),
1502                2.0,
1503                ColorSpace::SRGB_LINEAR,
1504            ),
1505        );
1506        let max_x = mesh
1507            .vertices
1508            .iter()
1509            .map(|v| v.pos[0])
1510            .fold(f32::MIN, f32::max);
1511        let max_y = mesh
1512            .vertices
1513            .iter()
1514            .map(|v| v.pos[1])
1515            .fold(f32::MIN, f32::max);
1516        assert!(
1517            (max_x - 100.0).abs() < 0.5 && (max_y - 100.0).abs() < 0.5,
1518            "0.5-unit square should fill the 100px rect, got extent ({max_x}, {max_y})"
1519        );
1520    }
1521
1522    #[test]
1523    fn zero_dimension_view_box_renders_nothing() {
1524        // SVG: `viewBox` with a zero width or height disables rendering
1525        // of the element.
1526        let asset = VectorAsset::from_paths([0.0, 0.0, 0.0, 24.0], Vec::new());
1527        let mesh = tessellate_vector_asset(
1528            &asset,
1529            VectorMeshOptions::icon(
1530                crate::tree::Rect::new(0.0, 0.0, 16.0, 16.0),
1531                Color::srgb_u8(0, 0, 0),
1532                2.0,
1533                ColorSpace::SRGB_LINEAR,
1534            ),
1535        );
1536        assert!(mesh.vertices.is_empty());
1537    }
1538
1539    #[test]
1540    fn tessellates_every_builtin_icon() {
1541        for name in all_icon_names() {
1542            let mesh = tessellate_vector_asset(
1543                icon_vector_asset(*name),
1544                VectorMeshOptions::icon(
1545                    crate::tree::Rect::new(0.0, 0.0, 16.0, 16.0),
1546                    Color::srgb_u8(15, 23, 42),
1547                    2.0,
1548                    ColorSpace::SRGB_LINEAR,
1549                ),
1550            );
1551            assert!(
1552                !mesh.vertices.is_empty(),
1553                "{} produced no tessellated vertices",
1554                name.name()
1555            );
1556        }
1557    }
1558
1559    #[test]
1560    fn parses_linear_gradient_paint() {
1561        let asset = parse_svg_asset(
1562            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1563                <defs>
1564                    <linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1565                        <stop offset="0" stop-color="#ff0000"/>
1566                        <stop offset="1" stop-color="#0000ff"/>
1567                    </linearGradient>
1568                </defs>
1569                <rect width="100" height="100" fill="url(#g)"/>
1570            </svg>"##,
1571        )
1572        .unwrap();
1573        assert_eq!(asset.gradients.len(), 1);
1574        assert!(matches!(
1575            asset.paths[0].fill.unwrap().color,
1576            VectorColor::Gradient(_)
1577        ));
1578        match &asset.gradients[0] {
1579            VectorGradient::Linear(g) => {
1580                assert_eq!(g.stops.len(), 2);
1581                assert_eq!(g.spread, VectorSpreadMethod::Pad);
1582                assert_eq!(g.p1, [0.0, 0.0]);
1583                assert_eq!(g.p2, [100.0, 0.0]);
1584            }
1585            other => panic!("expected linear gradient, got {other:?}"),
1586        }
1587    }
1588
1589    #[test]
1590    fn bakes_gradient_into_per_vertex_colors() {
1591        let asset = parse_svg_asset(
1592            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1593                <defs>
1594                    <linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1595                        <stop offset="0" stop-color="#ff0000"/>
1596                        <stop offset="1" stop-color="#0000ff"/>
1597                    </linearGradient>
1598                </defs>
1599                <rect width="100" height="100" fill="url(#g)"/>
1600            </svg>"##,
1601        )
1602        .unwrap();
1603        let mesh = tessellate_vector_asset(
1604            &asset,
1605            VectorMeshOptions::icon(
1606                crate::tree::Rect::new(0.0, 0.0, 200.0, 200.0),
1607                Color::srgb_u8(0, 0, 0),
1608                2.0,
1609                ColorSpace::SRGB_LINEAR,
1610            ),
1611        );
1612        assert!(!mesh.vertices.is_empty());
1613
1614        // Vertices on the left side of the rect should be reddish; on the
1615        // right side, bluish. (Linear gradients evaluate in linear-RGB
1616        // space, so red dominates in [0]/[2].)
1617        let mut min_x_vert = mesh.vertices[0];
1618        let mut max_x_vert = mesh.vertices[0];
1619        for v in &mesh.vertices {
1620            if v.local[0] < min_x_vert.local[0] {
1621                min_x_vert = *v;
1622            }
1623            if v.local[0] > max_x_vert.local[0] {
1624                max_x_vert = *v;
1625            }
1626        }
1627        assert!(
1628            min_x_vert.color[0] > min_x_vert.color[2],
1629            "left edge should be redder: {:?}",
1630            min_x_vert.color
1631        );
1632        assert!(
1633            max_x_vert.color[2] > max_x_vert.color[0],
1634            "right edge should be bluer: {:?}",
1635            max_x_vert.color
1636        );
1637    }
1638
1639    #[test]
1640    fn has_gradient_distinguishes_flat_from_gradient_assets() {
1641        let flat = parse_svg_asset(
1642            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="#fff"/></svg>"##,
1643        )
1644        .unwrap();
1645        assert!(!flat.has_gradient());
1646
1647        let gradient = parse_svg_asset(
1648            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1649                <defs><linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1650                    <stop offset="0" stop-color="#ff0000"/><stop offset="1" stop-color="#0000ff"/>
1651                </linearGradient></defs>
1652                <rect width="100" height="100" fill="url(#g)"/>
1653            </svg>"##,
1654        )
1655        .unwrap();
1656        assert!(gradient.has_gradient());
1657    }
1658
1659    #[test]
1660    fn parses_pipewire_volume_icon_with_all_gradients() {
1661        // Sanity-check end-to-end on a real-world authored SVG: five
1662        // linear/radial gradients plus an unsupported drop-shadow filter
1663        // (which is silently dropped, not an error).
1664        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
1665  <defs>
1666    <linearGradient id="arcGradient" x1="210" y1="720" x2="805" y2="260" gradientUnits="userSpaceOnUse">
1667      <stop offset="0" stop-color="#0667ff"/>
1668      <stop offset="0.52" stop-color="#139cff"/>
1669      <stop offset="1" stop-color="#11e4dc"/>
1670    </linearGradient>
1671    <linearGradient id="dotGradient" x1="585" y1="780" x2="805" y2="455" gradientUnits="userSpaceOnUse">
1672      <stop offset="0" stop-color="#065eff"/>
1673      <stop offset="0.55" stop-color="#0d9fff"/>
1674      <stop offset="1" stop-color="#10e5dc"/>
1675    </linearGradient>
1676    <radialGradient id="knobFace" cx="42%" cy="36%" r="72%">
1677      <stop offset="0" stop-color="#12366c"/>
1678      <stop offset="0.42" stop-color="#0b2554"/>
1679      <stop offset="1" stop-color="#071736"/>
1680    </radialGradient>
1681    <linearGradient id="knobRim" x1="320" y1="310" x2="735" y2="740" gradientUnits="userSpaceOnUse">
1682      <stop offset="0" stop-color="#214f9b"/>
1683      <stop offset="0.48" stop-color="#17386f"/>
1684      <stop offset="1" stop-color="#285aa7"/>
1685    </linearGradient>
1686    <linearGradient id="needleGradient" x1="565" y1="425" x2="670" y2="320" gradientUnits="userSpaceOnUse">
1687      <stop offset="0" stop-color="#0872ff"/>
1688      <stop offset="1" stop-color="#168aff"/>
1689    </linearGradient>
1690  </defs>
1691  <path d="M 296 720 A 300 300 0 1 1 794 409" fill="none" stroke="url(#arcGradient)" stroke-width="36" stroke-linecap="round"/>
1692  <circle cx="512" cy="512" r="210" fill="url(#knobRim)"/>
1693  <circle cx="512" cy="512" r="192" fill="url(#knobFace)"/>
1694  <line x1="569" y1="433" x2="663" y2="339" stroke="url(#needleGradient)" stroke-width="30" stroke-linecap="round"/>
1695  <circle cx="612" cy="787" r="13" fill="url(#dotGradient)"/>
1696  <circle cx="664" cy="764" r="14" fill="url(#dotGradient)"/>
1697</svg>"##;
1698        let asset = parse_svg_asset(svg).unwrap();
1699        // 1 arc stroke + 2 knob fills + 1 needle stroke + 2 dot fills = 6 paths.
1700        assert_eq!(asset.paths.len(), 6);
1701        // At least one gradient per distinct paint server (5). usvg may
1702        // duplicate when the same gradient is referenced by multiple
1703        // paths after bbox resolution; we don't pin the exact count
1704        // because that's a usvg-internal detail.
1705        assert!(asset.gradients.len() >= 5);
1706
1707        let mesh = tessellate_vector_asset(
1708            &asset,
1709            VectorMeshOptions::icon(
1710                crate::tree::Rect::new(0.0, 0.0, 256.0, 256.0),
1711                Color::srgb_u8(0, 0, 0),
1712                2.0,
1713                ColorSpace::SRGB_LINEAR,
1714            ),
1715        );
1716        assert!(!mesh.vertices.is_empty());
1717        // Some vertices must carry non-zero colour — if gradients silently
1718        // dropped to transparent, every channel would be 0.
1719        let any_lit = mesh
1720            .vertices
1721            .iter()
1722            .any(|v| v.color[0] + v.color[1] + v.color[2] > 0.01);
1723        assert!(any_lit, "no lit vertices — gradients did not render");
1724    }
1725
1726    /// Regression for #77: tess vertex colors must land in the mesh's
1727    /// working color space — solid fills, `currentColor`, and gradient
1728    /// samples alike. In Display-P3-linear, sRGB red keeps a non-zero
1729    /// green component; the old default-space packing left it at 0.
1730    #[test]
1731    fn vertex_colors_convert_into_the_working_space() {
1732        let p3 = ColorSpace::DISPLAY_P3_LINEAR;
1733        let red = Color::srgb_u8(255, 0, 0);
1734        let expected = rgba_f32_in(red, p3);
1735        assert!(expected[1] > 0.01, "P3 red must have green > 0");
1736
1737        // Solid authored fill.
1738        let solid = parse_svg_asset(
1739            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#ff0000"/></svg>"##,
1740        )
1741        .unwrap();
1742        let mesh = tessellate_vector_asset(
1743            &solid,
1744            VectorMeshOptions::icon(
1745                crate::tree::Rect::new(0.0, 0.0, 100.0, 100.0),
1746                Color::srgb_u8(0, 0, 0),
1747                2.0,
1748                p3,
1749            ),
1750        );
1751        for v in &mesh.vertices {
1752            for (got, want) in v.color.iter().zip(&expected) {
1753                assert!(
1754                    (got - want).abs() < 1e-5,
1755                    "solid fill should pack P3-converted red, got {:?} want {expected:?}",
1756                    v.color
1757                );
1758            }
1759        }
1760
1761        // Gradient with identical red endpoints: the lerp is constant, so
1762        // every sampled vertex must also be the P3-converted red.
1763        let grad = parse_svg_asset(
1764            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1765                <defs>
1766                    <linearGradient id="g" x1="0" y1="0" x2="100" y2="0" gradientUnits="userSpaceOnUse">
1767                        <stop offset="0" stop-color="#ff0000"/>
1768                        <stop offset="1" stop-color="#ff0000"/>
1769                    </linearGradient>
1770                </defs>
1771                <rect width="100" height="100" fill="url(#g)"/>
1772            </svg>"##,
1773        )
1774        .unwrap();
1775        let mesh = tessellate_vector_asset(
1776            &grad,
1777            VectorMeshOptions::icon(
1778                crate::tree::Rect::new(0.0, 0.0, 100.0, 100.0),
1779                Color::srgb_u8(0, 0, 0),
1780                2.0,
1781                p3,
1782            ),
1783        );
1784        assert!(!mesh.vertices.is_empty());
1785        for v in &mesh.vertices {
1786            for (got, want) in v.color.iter().zip(&expected) {
1787                assert!(
1788                    (got - want).abs() < 1e-4,
1789                    "gradient sample should be P3-converted red, got {:?} want {expected:?}",
1790                    v.color
1791                );
1792            }
1793        }
1794    }
1795}