Skip to main content

native_theme/model/
mod.rs

1// Theme model: ThemeVariant and NativeTheme, plus sub-module re-exports
2
3/// Animated icon types (frame sequences and transforms).
4pub mod animated;
5/// Bundled SVG icon lookup tables.
6pub mod bundled;
7/// Semantic theme color roles.
8pub mod colors;
9/// Font family and size configuration.
10pub mod fonts;
11/// Corner radius, border, and scroll geometry.
12pub mod geometry;
13/// Icon roles, sets, and provider trait.
14pub mod icons;
15/// Logical spacing scale (xxs through xxl).
16pub mod spacing;
17/// Per-widget sizing and spacing metrics.
18pub mod widget_metrics;
19
20pub use animated::{AnimatedIcon, Repeat, TransformAnimation};
21pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
22pub use colors::ThemeColors;
23pub use fonts::ThemeFonts;
24pub use geometry::ThemeGeometry;
25pub use icons::{
26    IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
27};
28pub use spacing::ThemeSpacing;
29pub use widget_metrics::{
30    ButtonMetrics, CheckboxMetrics, InputMetrics, ListItemMetrics, MenuItemMetrics,
31    ProgressBarMetrics, ScrollbarMetrics, SliderMetrics, SplitterMetrics, TabMetrics,
32    ToolbarMetrics, TooltipMetrics, WidgetMetrics,
33};
34
35use serde::{Deserialize, Serialize};
36
37/// A single light or dark theme variant containing all visual properties.
38///
39/// Composes colors, fonts, geometry, and spacing into one coherent set.
40/// Empty sub-structs are omitted from serialization to keep TOML files clean.
41///
42/// # Examples
43///
44/// ```
45/// use native_theme::{ThemeVariant, Rgba};
46///
47/// let mut variant = ThemeVariant::default();
48/// variant.colors.accent = Some(Rgba::rgb(0, 120, 215));
49/// variant.fonts.family = Some("Inter".into());
50/// assert!(!variant.is_empty());
51/// ```
52#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
53#[serde(default)]
54#[non_exhaustive]
55pub struct ThemeVariant {
56    /// Semantic color roles (accent, background, status, etc.).
57    #[serde(default, skip_serializing_if = "ThemeColors::is_empty")]
58    pub colors: ThemeColors,
59
60    /// Font family and size settings.
61    #[serde(default, skip_serializing_if = "ThemeFonts::is_empty")]
62    pub fonts: ThemeFonts,
63
64    /// Corner radius, border width, and scroll geometry.
65    #[serde(default, skip_serializing_if = "ThemeGeometry::is_empty")]
66    pub geometry: ThemeGeometry,
67
68    /// Logical spacing scale.
69    #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
70    pub spacing: ThemeSpacing,
71
72    /// Per-widget sizing and spacing metrics.
73    ///
74    /// Optional because not all themes or presets provide widget metrics.
75    /// When merging, if both base and overlay have widget metrics they are
76    /// merged recursively; if only the overlay has them they are cloned.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub widget_metrics: Option<WidgetMetrics>,
79
80    /// Icon set / naming convention for this variant (e.g., "sf-symbols", "freedesktop").
81    /// When None, resolved at runtime via system_icon_set().
82    #[serde(default, skip_serializing_if = "Option::is_none", alias = "icon_theme")]
83    pub icon_set: Option<String>,
84}
85
86impl ThemeVariant {
87    /// Merge an overlay into this value. `Some` fields in the overlay
88    /// replace the corresponding fields in self; `None` fields are
89    /// left unchanged. Nested structs are merged recursively.
90    pub fn merge(&mut self, overlay: &Self) {
91        self.colors.merge(&overlay.colors);
92        self.fonts.merge(&overlay.fonts);
93        self.geometry.merge(&overlay.geometry);
94        self.spacing.merge(&overlay.spacing);
95
96        match (&mut self.widget_metrics, &overlay.widget_metrics) {
97            (Some(base), Some(over)) => base.merge(over),
98            (None, Some(over)) => self.widget_metrics = Some(over.clone()),
99            _ => {}
100        }
101
102        if overlay.icon_set.is_some() {
103            self.icon_set.clone_from(&overlay.icon_set);
104        }
105    }
106
107    /// Returns true if all fields are at their default (None/empty) state.
108    pub fn is_empty(&self) -> bool {
109        self.colors.is_empty()
110            && self.fonts.is_empty()
111            && self.geometry.is_empty()
112            && self.spacing.is_empty()
113            && self.widget_metrics.as_ref().is_none_or(|wm| wm.is_empty())
114            && self.icon_set.is_none()
115    }
116}
117
118/// A complete native theme with a name and optional light/dark variants.
119///
120/// This is the top-level type that theme files deserialize into and that
121/// platform readers produce.
122///
123/// # Examples
124///
125/// ```
126/// use native_theme::NativeTheme;
127///
128/// // Load a bundled preset
129/// let theme = NativeTheme::preset("dracula").unwrap();
130/// assert_eq!(theme.name, "Dracula");
131///
132/// // Parse from a TOML string
133/// let toml = r##"
134/// name = "Custom"
135/// [light.colors]
136/// accent = "#ff6600"
137/// "##;
138/// let custom = NativeTheme::from_toml(toml).unwrap();
139/// assert_eq!(custom.name, "Custom");
140///
141/// // Merge themes (overlay wins for populated fields)
142/// let mut base = NativeTheme::preset("default").unwrap();
143/// base.merge(&custom);
144/// assert_eq!(base.name, "Default"); // base name is preserved
145/// ```
146#[derive(Clone, Debug, Default, Serialize, Deserialize)]
147#[non_exhaustive]
148#[must_use = "constructing a theme without using it is likely a bug"]
149pub struct NativeTheme {
150    /// Theme name (e.g., "Breeze", "Adwaita", "Windows 11").
151    pub name: String,
152
153    /// Light variant of the theme.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub light: Option<ThemeVariant>,
156
157    /// Dark variant of the theme.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub dark: Option<ThemeVariant>,
160}
161
162impl NativeTheme {
163    /// Create a new theme with the given name and no variants.
164    pub fn new(name: impl Into<String>) -> Self {
165        Self {
166            name: name.into(),
167            light: None,
168            dark: None,
169        }
170    }
171
172    /// Merge an overlay theme into this theme.
173    ///
174    /// The base name is kept. For each variant (light/dark):
175    /// - If both base and overlay have a variant, they are merged recursively.
176    /// - If only the overlay has a variant, it is cloned into the base.
177    /// - If only the base has a variant (or neither), no change.
178    pub fn merge(&mut self, overlay: &Self) {
179        // Keep base name (do not overwrite)
180
181        match (&mut self.light, &overlay.light) {
182            (Some(base), Some(over)) => base.merge(over),
183            (None, Some(over)) => self.light = Some(over.clone()),
184            _ => {}
185        }
186
187        match (&mut self.dark, &overlay.dark) {
188            (Some(base), Some(over)) => base.merge(over),
189            (None, Some(over)) => self.dark = Some(over.clone()),
190            _ => {}
191        }
192    }
193
194    /// Pick the appropriate variant for the given mode, with cross-fallback.
195    ///
196    /// When `is_dark` is true, prefers `dark` and falls back to `light`.
197    /// When `is_dark` is false, prefers `light` and falls back to `dark`.
198    /// Returns `None` only if the theme has no variants at all.
199    #[must_use = "this returns the selected variant; it does not apply it"]
200    pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
201        if is_dark {
202            self.dark.as_ref().or(self.light.as_ref())
203        } else {
204            self.light.as_ref().or(self.dark.as_ref())
205        }
206    }
207
208    /// Returns true if the theme has no variants set.
209    pub fn is_empty(&self) -> bool {
210        self.light.is_none() && self.dark.is_none()
211    }
212
213    /// Load a bundled theme preset by name.
214    ///
215    /// Returns the preset as a fully populated [`NativeTheme`] with both
216    /// light and dark variants.
217    ///
218    /// # Errors
219    /// Returns [`crate::Error::Unavailable`] if the preset name is not recognized.
220    ///
221    /// # Examples
222    /// ```
223    /// let theme = native_theme::NativeTheme::preset("default").unwrap();
224    /// assert!(theme.light.is_some());
225    /// ```
226    #[must_use = "this returns a theme preset; it does not apply it"]
227    pub fn preset(name: &str) -> crate::Result<Self> {
228        crate::presets::preset(name)
229    }
230
231    /// Parse a TOML string into a [`NativeTheme`].
232    ///
233    /// # TOML Format
234    ///
235    /// Theme files use the following structure. All fields are `Option<T>` --
236    /// omit any field you don't need. Unknown fields are silently ignored.
237    /// Hex colors accept `#RRGGBB` or `#RRGGBBAA` format.
238    ///
239    /// ```toml
240    /// name = "My Theme"
241    ///
242    /// [light.colors]
243    /// # Core (7)
244    /// accent = "#4a90d9"
245    /// background = "#fafafa"
246    /// foreground = "#2e3436"
247    /// surface = "#ffffff"
248    /// border = "#c0c0c0"
249    /// muted = "#929292"
250    /// shadow = "#00000018"
251    /// # Primary (2)
252    /// primary_background = "#4a90d9"
253    /// primary_foreground = "#ffffff"
254    /// # Secondary (2)
255    /// secondary_background = "#6c757d"
256    /// secondary_foreground = "#ffffff"
257    /// # Status (8) -- each has an optional _foreground variant
258    /// danger = "#dc3545"
259    /// danger_foreground = "#ffffff"
260    /// warning = "#f0ad4e"
261    /// warning_foreground = "#ffffff"
262    /// success = "#28a745"
263    /// success_foreground = "#ffffff"
264    /// info = "#4a90d9"
265    /// info_foreground = "#ffffff"
266    /// # Interactive (4)
267    /// selection = "#4a90d9"
268    /// selection_foreground = "#ffffff"
269    /// link = "#2a6cb6"
270    /// focus_ring = "#4a90d9"
271    /// # Panel (6) -- each has an optional _foreground variant
272    /// sidebar = "#f0f0f0"
273    /// sidebar_foreground = "#2e3436"
274    /// tooltip = "#2e3436"
275    /// tooltip_foreground = "#ffffff"
276    /// popover = "#ffffff"
277    /// popover_foreground = "#2e3436"
278    /// # Component (7) -- button and input have _foreground variants
279    /// button = "#e8e8e8"
280    /// button_foreground = "#2e3436"
281    /// input = "#ffffff"
282    /// input_foreground = "#2e3436"
283    /// disabled = "#c0c0c0"
284    /// separator = "#d0d0d0"
285    /// alternate_row = "#f5f5f5"
286    ///
287    /// [light.fonts]
288    /// family = "sans-serif"
289    /// size = 10.0
290    /// mono_family = "monospace"
291    /// mono_size = 10.0
292    ///
293    /// [light.geometry]
294    /// radius = 6.0
295    /// radius_lg = 12.0
296    /// frame_width = 1.0
297    /// disabled_opacity = 0.5
298    /// border_opacity = 0.15
299    /// scroll_width = 8.0
300    ///
301    /// [light.spacing]
302    /// xxs = 2.0
303    /// xs = 4.0
304    /// s = 6.0
305    /// m = 12.0
306    /// l = 18.0
307    /// xl = 24.0
308    /// xxl = 36.0
309    ///
310    /// # [dark.*] mirrors the same structure as [light.*]
311    /// ```
312    ///
313    /// # Errors
314    /// Returns [`crate::Error::Format`] if the TOML is invalid.
315    ///
316    /// # Examples
317    /// ```
318    /// let toml = r##"
319    /// name = "My Theme"
320    /// [light.colors]
321    /// accent = "#ff0000"
322    /// "##;
323    /// let theme = native_theme::NativeTheme::from_toml(toml).unwrap();
324    /// assert_eq!(theme.name, "My Theme");
325    /// ```
326    #[must_use = "this parses a TOML string into a theme; it does not apply it"]
327    pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
328        crate::presets::from_toml(toml_str)
329    }
330
331    /// Load a [`NativeTheme`] from a TOML file.
332    ///
333    /// # Errors
334    /// Returns [`crate::Error::Unavailable`] if the file cannot be read.
335    ///
336    /// # Examples
337    /// ```no_run
338    /// let theme = native_theme::NativeTheme::from_file("my-theme.toml").unwrap();
339    /// ```
340    #[must_use = "this loads a theme from a file; it does not apply it"]
341    pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
342        crate::presets::from_file(path)
343    }
344
345    /// List all available bundled preset names.
346    ///
347    /// # Examples
348    /// ```
349    /// let names = native_theme::NativeTheme::list_presets();
350    /// assert_eq!(names.len(), 17);
351    /// ```
352    #[must_use = "this returns the list of preset names"]
353    pub fn list_presets() -> &'static [&'static str] {
354        crate::presets::list_presets()
355    }
356
357    /// Serialize this theme to a TOML string.
358    ///
359    /// # Errors
360    /// Returns [`crate::Error::Format`] if serialization fails.
361    ///
362    /// # Examples
363    /// ```
364    /// let theme = native_theme::NativeTheme::preset("default").unwrap();
365    /// let toml_str = theme.to_toml().unwrap();
366    /// assert!(toml_str.contains("name = \"Default\""));
367    /// ```
368    #[must_use = "this serializes the theme to TOML; it does not write to a file"]
369    pub fn to_toml(&self) -> crate::Result<String> {
370        crate::presets::to_toml(self)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::Rgba;
378
379    // === ThemeVariant tests ===
380
381    #[test]
382    fn theme_variant_default_is_empty() {
383        assert!(ThemeVariant::default().is_empty());
384    }
385
386    #[test]
387    fn theme_variant_not_empty_when_color_set() {
388        let mut v = ThemeVariant::default();
389        v.colors.accent = Some(Rgba::rgb(0, 120, 215));
390        assert!(!v.is_empty());
391    }
392
393    #[test]
394    fn theme_variant_not_empty_when_font_set() {
395        let mut v = ThemeVariant::default();
396        v.fonts.family = Some("Inter".into());
397        assert!(!v.is_empty());
398    }
399
400    #[test]
401    fn theme_variant_merge_recursively() {
402        let mut base = ThemeVariant::default();
403        base.colors.background = Some(Rgba::rgb(255, 255, 255));
404        base.fonts.family = Some("Noto Sans".into());
405
406        let mut overlay = ThemeVariant::default();
407        overlay.colors.accent = Some(Rgba::rgb(0, 120, 215));
408        overlay.spacing.m = Some(12.0);
409
410        base.merge(&overlay);
411
412        // base background preserved
413        assert_eq!(base.colors.background, Some(Rgba::rgb(255, 255, 255)));
414        // overlay accent applied
415        assert_eq!(base.colors.accent, Some(Rgba::rgb(0, 120, 215)));
416        // base font preserved
417        assert_eq!(base.fonts.family.as_deref(), Some("Noto Sans"));
418        // overlay spacing applied
419        assert_eq!(base.spacing.m, Some(12.0));
420    }
421
422    // === NativeTheme tests ===
423
424    #[test]
425    fn native_theme_new_constructor() {
426        let theme = NativeTheme::new("Breeze");
427        assert_eq!(theme.name, "Breeze");
428        assert!(theme.light.is_none());
429        assert!(theme.dark.is_none());
430    }
431
432    #[test]
433    fn native_theme_default_is_empty() {
434        let theme = NativeTheme::default();
435        assert!(theme.is_empty());
436        assert_eq!(theme.name, "");
437    }
438
439    #[test]
440    fn native_theme_merge_keeps_base_name() {
441        let mut base = NativeTheme::new("Base Theme");
442        let overlay = NativeTheme::new("Overlay Theme");
443        base.merge(&overlay);
444        assert_eq!(base.name, "Base Theme");
445    }
446
447    #[test]
448    fn native_theme_merge_overlay_light_into_none() {
449        let mut base = NativeTheme::new("Theme");
450
451        let mut overlay = NativeTheme::new("Overlay");
452        let mut light = ThemeVariant::default();
453        light.colors.accent = Some(Rgba::rgb(0, 120, 215));
454        overlay.light = Some(light);
455
456        base.merge(&overlay);
457
458        assert!(base.light.is_some());
459        assert_eq!(
460            base.light.as_ref().unwrap().colors.accent,
461            Some(Rgba::rgb(0, 120, 215))
462        );
463    }
464
465    #[test]
466    fn native_theme_merge_both_light_variants() {
467        let mut base = NativeTheme::new("Theme");
468        let mut base_light = ThemeVariant::default();
469        base_light.colors.background = Some(Rgba::rgb(255, 255, 255));
470        base.light = Some(base_light);
471
472        let mut overlay = NativeTheme::new("Overlay");
473        let mut overlay_light = ThemeVariant::default();
474        overlay_light.colors.accent = Some(Rgba::rgb(0, 120, 215));
475        overlay.light = Some(overlay_light);
476
477        base.merge(&overlay);
478
479        let light = base.light.as_ref().unwrap();
480        // base background preserved
481        assert_eq!(light.colors.background, Some(Rgba::rgb(255, 255, 255)));
482        // overlay accent merged in
483        assert_eq!(light.colors.accent, Some(Rgba::rgb(0, 120, 215)));
484    }
485
486    #[test]
487    fn native_theme_merge_base_light_only_preserved() {
488        let mut base = NativeTheme::new("Theme");
489        let mut base_light = ThemeVariant::default();
490        base_light.fonts.family = Some("Inter".into());
491        base.light = Some(base_light);
492
493        let overlay = NativeTheme::new("Overlay"); // no light
494
495        base.merge(&overlay);
496
497        assert!(base.light.is_some());
498        assert_eq!(
499            base.light.as_ref().unwrap().fonts.family.as_deref(),
500            Some("Inter")
501        );
502    }
503
504    #[test]
505    fn native_theme_merge_dark_variant() {
506        let mut base = NativeTheme::new("Theme");
507
508        let mut overlay = NativeTheme::new("Overlay");
509        let mut dark = ThemeVariant::default();
510        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
511        overlay.dark = Some(dark);
512
513        base.merge(&overlay);
514
515        assert!(base.dark.is_some());
516        assert_eq!(
517            base.dark.as_ref().unwrap().colors.background,
518            Some(Rgba::rgb(30, 30, 30))
519        );
520    }
521
522    #[test]
523    fn native_theme_not_empty_with_light() {
524        let mut theme = NativeTheme::new("Theme");
525        theme.light = Some(ThemeVariant::default());
526        assert!(!theme.is_empty());
527    }
528
529    // === pick_variant tests ===
530
531    #[test]
532    fn pick_variant_dark_with_both_variants_returns_dark() {
533        let mut theme = NativeTheme::new("Test");
534        let mut light = ThemeVariant::default();
535        light.colors.background = Some(Rgba::rgb(255, 255, 255));
536        theme.light = Some(light);
537        let mut dark = ThemeVariant::default();
538        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
539        theme.dark = Some(dark);
540
541        let picked = theme.pick_variant(true).unwrap();
542        assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
543    }
544
545    #[test]
546    fn pick_variant_light_with_both_variants_returns_light() {
547        let mut theme = NativeTheme::new("Test");
548        let mut light = ThemeVariant::default();
549        light.colors.background = Some(Rgba::rgb(255, 255, 255));
550        theme.light = Some(light);
551        let mut dark = ThemeVariant::default();
552        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
553        theme.dark = Some(dark);
554
555        let picked = theme.pick_variant(false).unwrap();
556        assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
557    }
558
559    #[test]
560    fn pick_variant_dark_with_only_light_falls_back() {
561        let mut theme = NativeTheme::new("Test");
562        let mut light = ThemeVariant::default();
563        light.colors.background = Some(Rgba::rgb(255, 255, 255));
564        theme.light = Some(light);
565
566        let picked = theme.pick_variant(true).unwrap();
567        assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
568    }
569
570    #[test]
571    fn pick_variant_light_with_only_dark_falls_back() {
572        let mut theme = NativeTheme::new("Test");
573        let mut dark = ThemeVariant::default();
574        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
575        theme.dark = Some(dark);
576
577        let picked = theme.pick_variant(false).unwrap();
578        assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
579    }
580
581    #[test]
582    fn pick_variant_with_no_variants_returns_none() {
583        let theme = NativeTheme::new("Empty");
584        assert!(theme.pick_variant(true).is_none());
585        assert!(theme.pick_variant(false).is_none());
586    }
587
588    // === icon_set tests ===
589
590    #[test]
591    fn icon_set_default_is_none() {
592        assert!(ThemeVariant::default().icon_set.is_none());
593    }
594
595    #[test]
596    fn icon_set_merge_overlay() {
597        let mut base = ThemeVariant::default();
598        let overlay = ThemeVariant {
599            icon_set: Some("material".into()),
600            ..Default::default()
601        };
602        base.merge(&overlay);
603        assert_eq!(base.icon_set.as_deref(), Some("material"));
604    }
605
606    #[test]
607    fn icon_set_merge_none_preserves() {
608        let mut base = ThemeVariant {
609            icon_set: Some("sf-symbols".into()),
610            ..Default::default()
611        };
612        let overlay = ThemeVariant::default();
613        base.merge(&overlay);
614        assert_eq!(base.icon_set.as_deref(), Some("sf-symbols"));
615    }
616
617    #[test]
618    fn icon_set_is_empty_when_set() {
619        assert!(ThemeVariant::default().is_empty());
620        let v = ThemeVariant {
621            icon_set: Some("material".into()),
622            ..Default::default()
623        };
624        assert!(!v.is_empty());
625    }
626
627    #[test]
628    fn icon_set_toml_round_trip() {
629        let variant = ThemeVariant {
630            icon_set: Some("material".into()),
631            ..Default::default()
632        };
633        let toml_str = toml::to_string(&variant).unwrap();
634        assert!(toml_str.contains("icon_set"));
635        let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
636        assert_eq!(deserialized.icon_set.as_deref(), Some("material"));
637    }
638
639    #[test]
640    fn icon_set_toml_alias_backward_compat() {
641        // Old TOML files use "icon_theme" — verify the serde alias works
642        let toml_str = r#"icon_theme = "freedesktop""#;
643        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
644        assert_eq!(variant.icon_set.as_deref(), Some("freedesktop"));
645    }
646
647    #[test]
648    fn icon_set_toml_absent_deserializes_to_none() {
649        let toml_str = r##"
650[colors]
651accent = "#ff0000"
652"##;
653        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
654        assert!(variant.icon_set.is_none());
655    }
656
657    #[test]
658    fn native_theme_serde_toml_round_trip() {
659        let mut theme = NativeTheme::new("Test Theme");
660        let mut light = ThemeVariant::default();
661        light.colors.accent = Some(Rgba::rgb(0, 120, 215));
662        light.fonts.family = Some("Segoe UI".into());
663        light.geometry.radius = Some(4.0);
664        light.spacing.m = Some(12.0);
665        theme.light = Some(light);
666
667        let toml_str = toml::to_string(&theme).unwrap();
668        let deserialized: NativeTheme = toml::from_str(&toml_str).unwrap();
669
670        assert_eq!(deserialized.name, "Test Theme");
671        let l = deserialized.light.unwrap();
672        assert_eq!(l.colors.accent, Some(Rgba::rgb(0, 120, 215)));
673        assert_eq!(l.fonts.family.as_deref(), Some("Segoe UI"));
674        assert_eq!(l.geometry.radius, Some(4.0));
675        assert_eq!(l.spacing.m, Some(12.0));
676    }
677}