返回 CodeWhale
graphics.rs
根目录 / crates / tui / src / tui / pet_watch / graphics.rs
1 //! Bounded, off-thread presentation. The world never sees interpolation or pixels.
2 use super::live::{Pose, Scene, View};
3 use base64::Engine;
4 use std::io::{self, Write};
5
6 pub fn image_id() -> u32 {
7 0x4357_0000 ^ std::process::id()
8 }
9 pub fn clear(output: &mut impl Write) -> io::Result<()> {
10 write!(output, "\x1b_Ga=d,d=I,i={},q=2;\x1b\\", image_id())
11 }
12
13 #[derive(Default)]
14 pub struct Renderer {
15 dimensions: (usize, usize),
16 background: Vec<u8>,
17 appearance: super::appearance::Appearance,
18 }
19 impl Renderer {
20 pub fn set_appearance(&mut self, appearance: &super::appearance::Appearance) {
21 if self.appearance != *appearance {
22 self.dimensions = (0, 0);
23 self.appearance = appearance.clone();
24 }
25 }
26 pub fn render(
27 &mut self,
28 pose: &Pose,
29 previous: Option<&Scene>,
30 mix: f64,
31 view: &View,
32 time: f64,
33 ) -> io::Result<(Vec<u8>, Option<Vec<u8>>)> {
34 let cols = usize::from(view.width.clamp(1, 512));
35 let rows = usize::from(view.height.clamp(1, 256));
36 let cw = if view.cell_width.is_finite() && view.cell_width >= 1.0 {
37 view.cell_width.min(64.0)
38 } else {
39 8.0
40 };
41 let ch = if view.cell_height.is_finite() && view.cell_height >= 1.0 {
42 view.cell_height.min(128.0)
43 } else {
44 16.0
45 };
46 let width = cols as f64 * cw;
47 let height = rows as f64 * ch;
48 let resolution = (960.0 / width).min(560.0 / height).min(1.0);
49 let w = (width * resolution).round().clamp(1.0, 960.0) as usize;
50 let h = (height * resolution).round().clamp(1.0, 560.0) as usize;
51 if self.dimensions != (w, h) {
52 self.dimensions = (w, h);
53 self.background = vec![0; w * h * 3];
54 for y in 0..h {
55 for x in 0..w {
56 let light = (1.0
57 - (((x as f64 / w as f64 - 0.5) * 1.1).powi(2)
58 + (y as f64 / h as f64 * 0.7).powi(2))
59 .sqrt())
60 .clamp(0.0, 1.0);
61 let at = (y * w + x) * 3;
62 for k in 0..3 {
63 self.background[at + k] = (f64::from(self.appearance.background[k])
64 * (1.0 - light)
65 + f64::from(self.appearance.background_top[k]) * light)
66 as u8;
67 }
68 }
69 }
70 }
71 let mut rgb = if view.pixels {
72 self.background.clone()
73 } else {
74 Vec::new()
75 };
76 let mut cells = vec![0u8; cols * rows];
77 let val = |name: &str, default: f64| pose.state[name].as_f64().unwrap_or(default);
78 let attention = val("attention", 0.0);
79 let flip = val("flip", 1.0);
80 let scale = (w as f64 * 0.52).min(h as f64 * 0.85) * (1.0 + attention * 0.07);
81 let ox = w as f64 * (0.5 + val("roamX", 0.0) * 0.30);
82 let oy = h as f64 * (0.47 + val("roamY", 0.0) * 0.30 + attention * 0.05);
83 let colour = [pose.style.r, pose.style.g, pose.style.b];
84 if view.pixels && self.appearance.environment {
85 for x in 16..w.saturating_sub(16) {
86 blend(&mut rgb, w, h, x as i32, 16, [45.0, 70.0, 81.0], 0.5);
87 }
88 for band in 0..6 {
89 for x in 0..w {
90 let y = h as f64 * 0.85
91 + ((x as f64 / 110.0) + band as f64 + time * 0.18).sin() * 6.0
92 + band as f64 * 5.0;
93 blend(
94 &mut rgb,
95 w,
96 h,
97 x as i32,
98 y as i32,
99 [96.0, 163.0, 185.0],
100 0.025,
101 );
102 }
103 }
104 }
105 let radius = (w.min(h) as f64 * 0.00285).clamp(0.68, 1.55) * self.appearance.dot_scale;
106 for (i, q) in pose.points.iter().enumerate() {
107 let p = previous.and_then(|s| s.points.get(i));
108 let xx = p.map_or(q[0], |p| p[0] + (q[0] - p[0]) * mix);
109 let yy = p.map_or(q[1], |p| p[1] + (q[1] - p[1]) * mix);
110 let x = ox + xx * scale * flip;
111 let y = oy + yy * scale;
112 let cx = (x / w as f64 * (cols * 2) as f64).floor() as i32;
113 let cy = (y / h as f64 * (rows * 4) as f64).floor() as i32;
114 if cx >= 0 && cy >= 0 && cx < (cols * 2) as i32 && cy < (rows * 4) as i32 {
115 let bit = [[0, 3], [1, 4], [2, 5], [6, 7]][cy as usize % 4][cx as usize % 2];
116 cells[(cy as usize / 4) * cols + cx as usize / 2] |= 1 << bit;
117 }
118 if !view.pixels {
119 continue;
120 }
121 let depth = 0.65 + 0.35 * ((i * 37 % 101) as f64 / 100.0);
122 let alpha = (pose.style.alpha * depth).clamp(0.0, 1.0);
123 let r = radius * depth;
124 let glow = if !pose.style.hollow && i % 5 == 0 {
125 1.0 + 4.0 * self.appearance.glow
126 } else {
127 1.0
128 };
129 let reach = (r * glow + 1.0).ceil() as i32;
130 for dy in -reach..=reach {
131 for dx in -reach..=reach {
132 let px = x.floor() as i32 + dx;
133 let py = y.floor() as i32 + dy;
134 let dist =
135 ((px as f64 + 0.5 - x).powi(2) + (py as f64 + 0.5 - y).powi(2)).sqrt();
136 let coverage = if pose.style.hollow {
137 (0.7 - (dist - r).abs()).clamp(0.0, 1.0)
138 } else {
139 (r + 0.5 - dist).clamp(0.0, 1.0)
140 };
141 let a = alpha * coverage
142 + if glow > 1.0 {
143 (1.0 - dist / (r * 3.0)).max(0.0) * 0.035
144 } else {
145 0.0
146 };
147 if a > 0.0 {
148 blend(&mut rgb, w, h, px, py, colour, a)
149 }
150 }
151 }
152 }
153 let image = if view.pixels {
154 let mut compressor =
155 flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
156 compressor.write_all(&rgb)?;
157 let data = base64::engine::general_purpose::STANDARD.encode(compressor.finish()?);
158 let mut protocol = Vec::with_capacity(data.len() + data.len() / 4096 * 32 + 160);
159 for (index, chunk) in data.as_bytes().chunks(4096).enumerate() {
160 let more = u8::from((index + 1) * 4096 < data.len());
161 if index == 0 {
162 write!(
163 protocol,
164 "\x1b_Ga=T,t=d,f=24,o=z,s={w},v={h},i={},p=1,c={cols},r={rows},C=1,q=2,m={more};",
165 image_id()
166 )?;
167 } else {
168 write!(protocol, "\x1b_Gm={more},q=2;")?;
169 }
170 protocol.extend_from_slice(chunk);
171 protocol.extend_from_slice(b"\x1b\\");
172 }
173 Some(protocol)
174 } else {
175 None
176 };
177 Ok((cells, image))
178 }
179 }
180 fn blend(rgb: &mut [u8], w: usize, h: usize, x: i32, y: i32, c: [f64; 3], a: f64) {
181 if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
182 return;
183 }
184 let offset = (y as usize * w + x as usize) * 3;
185 let a = a.clamp(0.0, 1.0);
186 for k in 0..3 {
187 rgb[offset + k] = (rgb[offset + k] as f64 * (1.0 - a) + c[k] * a).clamp(0.0, 255.0) as u8;
188 }
189 }
190
191 #[cfg(test)]
192 mod tests {
193 use super::super::live::Style;
194 use super::*;
195 #[test]
196 fn pixel_transport_is_bounded_chunked_rgb_and_braille_remains_available() {
197 let pose = Pose {
198 points: vec![[0.0, 0.0]; 980],
199 style: Style {
200 r: 120.,
201 g: 210.,
202 b: 230.,
203 alpha: 0.7,
204 hollow: false,
205 channel: "reasoning".into(),
206 arch: "gyre".into(),
207 },
208 state: serde_json::json!({}),
209 };
210 let mut renderer = Renderer::default();
211 let view = View {
212 width: 120,
213 height: 35,
214 pixels: true,
215 ..View::default()
216 };
217 let (cells, image) = renderer.render(&pose, None, 1.0, &view, 0.0).unwrap();
218 assert_eq!(cells.len(), 4200);
219 assert!(cells.iter().any(|b| *b != 0));
220 let image = String::from_utf8(image.unwrap()).unwrap();
221 assert!(image.contains("a=T,t=d,f=24,o=z,s=960,v=560"));
222 let mut encoded = String::new();
223 for chunk in image.split("\x1b\\").filter(|s| !s.is_empty()) {
224 let (_, data) = chunk.split_once(';').unwrap();
225 assert!(data.len() <= 4096);
226 encoded.push_str(data);
227 }
228 let compressed = base64::engine::general_purpose::STANDARD
229 .decode(encoded)
230 .unwrap();
231 let mut decoder = flate2::read::ZlibDecoder::new(compressed.as_slice());
232 let mut rgb = Vec::new();
233 std::io::Read::read_to_end(&mut decoder, &mut rgb).unwrap();
234 assert_eq!(rgb.len(), 960 * 560 * 3);
235 let (fallback, image) = renderer
236 .render(
237 &pose,
238 None,
239 1.0,
240 &View {
241 pixels: false,
242 ..view
243 },
244 0.0,
245 )
246 .unwrap();
247 assert_eq!(fallback, cells);
248 assert!(image.is_none());
249 }
250 }
251
251 lines RUST