git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/relief_spec.rs (5.6K)

  1 //! The `(relief)` config value type: one relief material folded into a
  2 //! single string, so any config key can carry its own material and
  3 //! `cce-relief --key <dotted.key>` can edit it in place.
  4 //!
  5 //! Format: whitespace-separated `name=value` pairs, e.g.
  6 //!
  7 //! ```text
  8 //! w=6.0 d=0.350 k=0.500,0.500,0.500 p=0.000:0.000,0.032:0.001,...,1.000:1.000
  9 //! ```
 10 //!
 11 //! - `w` — wall/roll width in logical px (required)
 12 //! - `h` — the wall's geometric drop, a length: `h=0.5mm`, `h=4px`, or a
 13 //!   bare number of logical px (optional; absent = follow the width at the
 14 //!   analytic ratio, `relief_shade::RECESS_DEPTH` × width)
 15 //! - `d` — light strength across the wall (`bevel_depth`; optional). Kept
 16 //!   as `d` on the wire for every reader already out there; `l` is read as
 17 //!   an alias. It is NOT a length — `h` is.
 18 //! - `k` — the editor's Shoulder/Base/Bias knob triple, a ride-along seed
 19 //!   so `cce-relief` reopens where it was left (optional)
 20 //! - `p` — the wall's height curve as the same ramp spec
 21 //!   `style.surface.relief.profile` carries (optional; absent or the
 22 //!   identity sentinel = the analytic profile)
 23 //!
 24 //! No value contains whitespace (ramp specs are `;`/`:`/`,`-delimited), so
 25 //! parsing is a plain split. Unknown pairs are skipped, not errors —
 26 //! forward compatibility for future fields.
 27 
 28 /// One parsed `(relief)` value. See the module doc for the string format.
 29 #[derive(Debug, Clone, PartialEq)]
 30 pub struct ReliefSpec {
 31     /// Wall/roll width in logical px.
 32     pub width: f32,
 33     /// The wall's drop, a length; `None` = follow the width at the analytic
 34     /// ratio.
 35     pub height: Option<crate::units::Len>,
 36     /// Light strength (`bevel_depth`); `None` = keep the process's material.
 37     pub light: Option<f32>,
 38     /// Shoulder/Base/Bias editor knobs behind `profile` — seed only, the
 39     /// renderer never reads them.
 40     pub knobs: Option<(f32, f32, f32)>,
 41     /// Wall profile ramp spec; `None` = the analytic profile. May carry the
 42     /// identity sentinel verbatim — installers filter it like the config
 43     /// loader does.
 44     pub profile: Option<String>,
 45 }
 46 
 47 impl ReliefSpec {
 48     /// Parse a `(relief)` value. `None` when `w=` is absent or malformed —
 49     /// a spec without a width says nothing drawable.
 50     pub fn parse(s: &str) -> Option<Self> {
 51         let mut width = None;
 52         let mut height = None;
 53         let mut light = None;
 54         let mut knobs = None;
 55         let mut profile = None;
 56         for tok in s.split_whitespace() {
 57             let Some((k, v)) = tok.split_once('=') else { continue };
 58             match k {
 59                 "w" => width = v.parse::<f32>().ok().filter(|w| w.is_finite() && *w >= 0.0),
 60                 "d" | "l" => light = v.parse::<f32>().ok().filter(|d| d.is_finite()),
 61                 "h" => {
 62                     height = crate::units::Len::parse(v)
 63                         .or_else(|| v.parse::<f32>().ok().map(crate::units::Len::px))
 64                         .filter(|l| l.value.is_finite() && l.value > 0.0);
 65                 }
 66                 "k" => {
 67                     let mut it = v.splitn(3, ',').map(|p| p.parse::<f32>().ok());
 68                     if let (Some(Some(a)), Some(Some(b)), Some(Some(c))) =
 69                         (it.next(), it.next(), it.next())
 70                     {
 71                         knobs = Some((a, b, c));
 72                     }
 73                 }
 74                 "p" => profile = Some(v.to_string()),
 75                 _ => {}
 76             }
 77         }
 78         Some(Self { width: width?, height, light, knobs, profile })
 79     }
 80 
 81     /// The string `parse` reads back — what `cce-relief --key` saves.
 82     pub fn serialize(&self) -> String {
 83         let mut out = format!("w={:.2}", self.width);
 84         if let Some(h) = self.height {
 85             out.push_str(&format!(" h={}", h.serialize()));
 86         }
 87         if let Some(d) = self.light {
 88             out.push_str(&format!(" d={d:.3}"));
 89         }
 90         if let Some((a, b, c)) = self.knobs {
 91             out.push_str(&format!(" k={a:.3},{b:.3},{c:.3}"));
 92         }
 93         if let Some(p) = &self.profile {
 94             out.push_str(&format!(" p={p}"));
 95         }
 96         out
 97     }
 98 }
 99 
100 #[cfg(test)]
101 mod tests {
102     use super::*;
103 
104     #[test]
105     fn roundtrip_full() {
106         let spec = ReliefSpec {
107             width: 6.0,
108             height: Some(crate::units::Len::mm(0.5)),
109             light: Some(0.35),
110             knobs: Some((0.8, 0.2, 0.5)),
111             profile: Some("0.000:0.000,0.500:0.700,1.000:1.000".to_string()),
112         };
113         assert_eq!(ReliefSpec::parse(&spec.serialize()), Some(spec));
114     }
115 
116     #[test]
117     fn width_only_and_unknown_pairs_skip() {
118         let spec = ReliefSpec::parse("w=4 future=stuff junk").unwrap();
119         assert_eq!(spec.width, 4.0);
120         assert_eq!(spec.height, None);
121         assert_eq!(spec.light, None);
122         assert_eq!(spec.knobs, None);
123         assert_eq!(spec.profile, None);
124     }
125 
126     #[test]
127     fn height_and_light_aliases() {
128         let s = ReliefSpec::parse("w=6 h=3 l=0.2").unwrap();
129         assert_eq!(s.height, Some(crate::units::Len::px(3.0)), "bare h is logical px");
130         assert_eq!(s.light, Some(0.2), "l is read as d");
131         let s = ReliefSpec::parse("w=6 h=0.5mm d=0.1").unwrap();
132         assert_eq!(s.height, Some(crate::units::Len::mm(0.5)));
133         assert_eq!(s.serialize(), "w=6.00 h=0.5mm d=0.100");
134         assert_eq!(ReliefSpec::parse("w=6 h=-1").unwrap().height, None);
135     }
136 
137     #[test]
138     fn missing_or_bad_width_rejects() {
139         assert_eq!(ReliefSpec::parse("d=0.3"), None);
140         assert_eq!(ReliefSpec::parse("w=-1"), None);
141         assert_eq!(ReliefSpec::parse(""), None);
142     }
143 }