1use serde_json::Value;
10
11const CELL_ASPECT: f32 = 2.0;
13
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub enum Kind {
16 Solid,
17 Linear(f32),
19 Conic,
20 Radial,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Stop {
25 pub at: f32,
27 pub rgb: [u8; 3],
28}
29
30#[derive(Clone, Debug, PartialEq)]
31pub struct Paint {
32 pub kind: Kind,
33 pub stops: Vec<Stop>,
35 pub repeat: bool,
37 pub period: f32,
38}
39
40impl Paint {
41 fn new(kind: Kind, mut stops: Vec<Stop>, repeat: bool, period: f32) -> Option<Paint> {
42 if stops.is_empty() {
43 return None;
44 }
45 stops.sort_by(|a, b| a.at.total_cmp(&b.at));
46 let kind = if stops.len() == 1 { Kind::Solid } else { kind };
47 Some(Paint {
48 kind,
49 stops,
50 repeat,
51 period: if period > 0.0 { period } else { 1.0 },
52 })
53 }
54
55 pub fn from_heatsync(spec: &Value) -> Option<Paint> {
58 if let Some(fill) = spec.get("fill") {
59 let angle = num(fill.get("angle")).unwrap_or(0.0);
60 let layers = fill.get("layers").and_then(Value::as_array);
61 let layer = layers?.iter().find(|l| {
62 l.get("legacy") != Some(&Value::Bool(true))
63 && l.get("kind") != Some(&Value::from("image"))
64 });
65 if let Some(l) = layer {
66 let stops = stops_of(l.get("stops"), 100.0, hex_rgb)?;
67 let kind = match l.get("kind").and_then(Value::as_str) {
68 Some("conic") => Kind::Conic,
69 Some("radial") => Kind::Radial,
70 _ => Kind::Linear(angle + num(l.get("tilt")).unwrap_or(0.0)),
71 };
72 let repeat = l.get("repeat") == Some(&Value::Bool(true));
73 let tile = l.get("tile");
74 let size = num(tile.and_then(|t| t.get("size"))).unwrap_or(1.0);
75 let period = match tile.and_then(|t| t.get("unit")).and_then(Value::as_str) {
77 Some("px") => size / 8.0,
78 _ => size,
79 };
80 return Paint::new(kind, stops, repeat, period);
81 }
82 }
83 let base = spec.get("base")?;
84 let stops = stops_of(base.get("stops"), 100.0, hex_rgb)?;
85 let angle = num(base.get("angle")).unwrap_or(0.0);
86 let kind = match base.get("type").and_then(Value::as_str) {
87 Some("solid") => Kind::Solid,
88 Some("conic") => Kind::Conic,
89 _ => Kind::Linear(angle),
90 };
91 let banded = base.get("type").and_then(Value::as_str) == Some("repeating-linear");
93 let size = num(base.get("tileWidth")).unwrap_or(8.0);
94 Paint::new(kind, stops, banded, size / 8.0)
95 }
96
97 pub fn from_7tv(p: &Value) -> Option<Paint> {
100 let func = p
101 .get("function")
102 .and_then(Value::as_str)?
103 .to_ascii_lowercase();
104 let rgb = |v: &Value| stv_rgb(v.as_i64()?);
105 let stops = stops_of(p.get("stops"), 1.0, rgb);
106 match func.replace('-', "_").as_str() {
107 "linear_gradient" => Paint::new(
108 Kind::Linear(num(p.get("angle")).unwrap_or(0.0)),
109 stops?,
110 p.get("repeat") == Some(&Value::Bool(true)),
111 1.0,
112 ),
113 "radial_gradient" => Paint::new(
114 Kind::Radial,
115 stops?,
116 p.get("repeat") == Some(&Value::Bool(true)),
117 1.0,
118 ),
119 _ => {
121 let rgb = stv_rgb(p.get("color")?.as_i64()?)?;
122 Paint::new(Kind::Solid, vec![Stop { at: 0.0, rgb }], false, 1.0)
123 }
124 }
125 }
126
127 pub fn sample(&self, t: f32) -> [u8; 3] {
130 let s = &self.stops;
131 let t = if self.repeat {
132 (t / self.period).rem_euclid(1.0)
133 } else {
134 t
135 };
136 if t <= s[0].at {
137 return s[0].rgb;
138 }
139 let last = s[s.len() - 1];
140 if t >= last.at {
141 return last.rgb;
142 }
143 let i = s.windows(2).position(|w| t < w[1].at).unwrap_or(0);
144 let (a, b) = (s[i], s[i + 1]);
145 let f = (t - a.at) / (b.at - a.at);
146 let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * f).round() as u8;
147 [
148 mix(a.rgb[0], b.rgb[0]),
149 mix(a.rgb[1], b.rgb[1]),
150 mix(a.rgb[2], b.rgb[2]),
151 ]
152 }
153
154 pub fn colors(&self, n: usize) -> Vec<[u8; 3]> {
157 (0..n).map(|i| self.sample(self.t_at(i, n))).collect()
158 }
159
160 fn t_at(&self, i: usize, n: usize) -> f32 {
161 let w = n as f32;
162 let x = i as f32 + 0.5;
163 match self.kind {
164 Kind::Solid => 0.0,
165 Kind::Linear(deg) => {
166 let (sin, cos) = deg.to_radians().sin_cos();
167 let len = w * sin.abs() + CELL_ASPECT * cos.abs();
168 0.5 + (x - w / 2.0) * sin / len
169 }
170 Kind::Conic => x / w,
172 Kind::Radial => (x - w / 2.0).abs() / (w / 2.0),
173 }
174 }
175}
176
177fn num(v: Option<&Value>) -> Option<f32> {
178 v?.as_f64().map(|n| n as f32)
179}
180
181fn stops_of(
184 v: Option<&Value>,
185 scale: f32,
186 color: impl Fn(&Value) -> Option<[u8; 3]>,
187) -> Option<Vec<Stop>> {
188 let out: Vec<Stop> = v?
189 .as_array()?
190 .iter()
191 .filter_map(|s| {
192 let at = num(s.get("pos").or_else(|| s.get("at")))? / scale;
193 Some(Stop {
194 at: at.clamp(0.0, 1.0),
195 rgb: color(s.get("color")?)?,
196 })
197 })
198 .collect();
199 (!out.is_empty()).then_some(out)
200}
201
202fn hex_rgb(v: &Value) -> Option<[u8; 3]> {
204 let h = v.as_str()?.strip_prefix('#')?;
205 if !(h.len() == 6 || h.len() == 8) || !h.is_ascii() {
206 return None;
207 }
208 let b = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
209 Some([b(0)?, b(2)?, b(4)?])
210}
211
212fn stv_rgb(n: i64) -> Option<[u8; 3]> {
214 let v = n as i32 as u32;
215 Some([(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8])
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use serde_json::json;
222
223 #[test]
224 fn heatsync_base_linear() {
225 let p = Paint::from_heatsync(&json!({"base": {"type": "linear", "angle": 90,
226 "stops": [{"color": "#ff0000", "pos": 0}, {"color": "#0000ff", "pos": 100}]}}))
227 .unwrap();
228 assert_eq!(p.kind, Kind::Linear(90.0));
229 assert_eq!(p.sample(0.0), [255, 0, 0]);
230 assert_eq!(p.sample(1.0), [0, 0, 255]);
231 assert_eq!(p.sample(0.5), [128, 0, 128]);
232 }
233
234 #[test]
235 fn heatsync_solid_and_single_stop() {
236 let p = Paint::from_heatsync(&json!({"base": {"type": "solid",
237 "stops": [{"color": "#112233", "pos": 0}]}}))
238 .unwrap();
239 assert_eq!(p.kind, Kind::Solid);
240 assert!(p.colors(5).iter().all(|c| *c == [0x11, 0x22, 0x33]));
241 }
242
243 #[test]
244 fn heatsync_fill_wins_over_base_and_skips_image_layers() {
245 let p = Paint::from_heatsync(&json!({
246 "base": {"type": "solid", "stops": [{"color": "#000000", "pos": 0}]},
247 "fill": {"angle": 90, "layers": [
248 {"kind": "image", "src": "x"},
249 {"kind": "linear", "tilt": 0, "stops": [
250 {"color": "#ff0000ff", "pos": 0}, {"color": "#00ff00", "pos": 100}]}]}}))
251 .unwrap();
252 assert_eq!(p.kind, Kind::Linear(90.0));
253 assert_eq!(p.sample(0.0), [255, 0, 0]);
254 }
255
256 #[test]
257 fn heatsync_junk_is_none() {
258 assert!(Paint::from_heatsync(&json!({})).is_none());
259 assert!(Paint::from_heatsync(&json!({"base": {"type": "linear", "stops": []}})).is_none());
260 assert!(
261 Paint::from_heatsync(&json!({"base": {"stops": [{"color": "red", "pos": 0}]}}))
262 .is_none()
263 );
264 }
265
266 #[test]
267 fn seven_tv_gradient_decodes_packed_rgba() {
268 let p = Paint::from_7tv(&json!({"function": "LINEAR_GRADIENT", "angle": 90, "repeat": false,
270 "stops": [{"at": 0.0, "color": 0xff0000ffu32 as i32}, {"at": 1.0, "color": 0x0000ffffu32 as i32}]}))
271 .unwrap();
272 assert_eq!(p.sample(0.0), [255, 0, 0]);
273 assert_eq!(p.sample(1.0), [0, 0, 255]);
274 }
275
276 #[test]
277 fn seven_tv_solid_and_image() {
278 let solid =
279 Paint::from_7tv(&json!({"function": "SOLID", "color": 0x336699ffu32 as i32})).unwrap();
280 assert_eq!(solid.sample(0.7), [0x33, 0x66, 0x99]);
281 assert!(Paint::from_7tv(
282 &json!({"function": "URL", "image_url": "https://cdn.7tv.app/paint/x/1x.webp"})
283 )
284 .is_none());
285 }
286
287 #[test]
288 fn hard_stops_and_repeat() {
289 let p = Paint::from_7tv(&json!({"function": "LINEAR_GRADIENT", "angle": 90, "repeat": true,
290 "stops": [{"at": 0.0, "color": 0xff0000ffu32 as i32}, {"at": 0.5, "color": 0xff0000ffu32 as i32},
291 {"at": 0.5, "color": 0x0000ffffu32 as i32}, {"at": 1.0, "color": 0x0000ffffu32 as i32}]}))
292 .unwrap();
293 assert_eq!(p.sample(0.25), [255, 0, 0]);
294 assert_eq!(p.sample(0.75), [0, 0, 255]);
295 assert_eq!(p.sample(1.25), [255, 0, 0]); }
297
298 #[test]
299 fn horizontal_gradient_runs_left_to_right_and_vertical_is_flat() {
300 let stops = |angle| {
301 json!({"base": {"type": "linear", "angle": angle,
302 "stops": [{"color": "#000000", "pos": 0}, {"color": "#ffffff", "pos": 100}]}})
303 };
304 let h = Paint::from_heatsync(&stops(90)).unwrap().colors(10);
305 assert!(h.windows(2).all(|w| w[0][0] < w[1][0]));
306 let v = Paint::from_heatsync(&stops(0)).unwrap().colors(10);
307 assert!(v.iter().all(|c| *c == v[0]));
308 }
309
310 #[test]
311 fn one_cell_name_does_not_divide_by_zero() {
312 let p = Paint::from_heatsync(&json!({"base": {"type": "linear", "angle": 90,
313 "stops": [{"color": "#000000", "pos": 0}, {"color": "#ffffff", "pos": 100}]}}))
314 .unwrap();
315 assert_eq!(p.colors(1).len(), 1);
316 assert!(p.colors(0).is_empty());
317 }
318}