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 }