返回 CodeWhale
scene.rs
1 //! `scene()` and `braille.js`: one evaluated performance, composed for a
2 //! view. A surface may simplify geometry and paint less often, but it never
3 //! classifies tools or advances a second clock — it only reads the Director.
4
5 use super::acting::Director;
6 use super::data::p;
7 use super::ink::{self, Fill};
8 use super::props;
9 use super::rig::{
10 Cmd, Direction, MARK_DIRECTION, Parts, Path, Role, Shape, build, holes_for, rig_pose,
11 };
12
13 #[derive(Clone, Copy, Debug)]
14 pub struct View {
15 /// Allocated square in device-independent pixels (drives optical sizes).
16 pub size: f64,
17 pub dpr: f64,
18 /// 0 is the full contour rig; 2 is the optical-fit small icon.
19 pub lod: u8,
20 pub dir: Direction,
21 }
22
23 impl View {
24 pub fn hero(size: f64, dpr: f64) -> Self {
25 Self {
26 size,
27 dpr,
28 lod: 0,
29 dir: MARK_DIRECTION,
30 }
31 }
32 /// The reference switches to the optical small rig below 40 px.
33 pub fn fitted(size: f64, dpr: f64) -> Self {
34 Self {
35 lod: if size < 40. { 2 } else { 0 },
36 ..Self::hero(size, dpr)
37 }
38 }
39 }
40
41 /// `scene()`: calves, whale, props and particles, in paint order.
42 pub fn scene(director: &Director, view: View) -> Parts {
43 scene_posed(director, view, &[])
44 }
45
46 /// `scene()` with a surface's `view.pose` overrides (the cove's gaze),
47 /// applied before the pod adjustment exactly as the reference merges them.
48 pub fn scene_posed(director: &Director, view: View, overrides: &[(usize, f64)]) -> Parts {
49 let mut pose = director.pose();
50 for (param, value) in overrides {
51 if value.is_finite()
52 && let Some(slot) = pose.get_mut(*param)
53 {
54 *slot = *value;
55 }
56 }
57 let pod = director
58 .calves
59 .iter()
60 .map(|c| c.value)
61 .fold(f64::NEG_INFINITY, f64::max);
62 pose[p::scale] *= 1. - 0.16 * pod;
63 pose[p::x] += 10. * pod;
64 let mut parts = build(view.dir, &pose, view.lod, view.size, view.dpr);
65 if parts.shapes.is_empty() {
66 return parts;
67 }
68 let mut calves = Vec::new();
69 for (i, calf) in director.calves.iter().enumerate() {
70 let vis = calf.value;
71 if vis < 0.002 {
72 continue;
73 }
74 let i = i as f64;
75 let phase = if director.reduced {
76 0.
77 } else {
78 director.f / 30. * (0.8 + i * 0.13) + i * 2.1
79 };
80 let mut calf_pose = rig_pose();
81 calf_pose[p::x] = -44. - (1. - vis) * 10.;
82 calf_pose[p::y] = -30. + i * 30.;
83 calf_pose[p::scale] = 0.18 * vis;
84 calf_pose[p::fluke] = 6. + phase.sin() * 8.;
85 calf_pose[p::lid] = 0.;
86 let lod = if view.lod >= 2 { 2 } else { 0 };
87 let cp = build(view.dir, &calf_pose, lod, view.size, view.dpr);
88 calves.extend(cp.shapes.into_iter().map(|s| Shape {
89 id: format!("calf-{}-{}", i as usize + 1, s.id),
90 opacity: s.opacity * vis,
91 ..s
92 }));
93 }
94 let small = view.lod >= 2;
95 let prop_shapes = props::shapes(&pose, &parts, small);
96 let whale = std::mem::take(&mut parts.shapes);
97 parts.shapes = calves;
98 parts.shapes.extend(whale);
99 parts.shapes.extend(prop_shapes);
100 let particles = if director.reduced {
101 director.poster_particles(&parts.anchors)
102 } else {
103 director.particles.clone()
104 };
105 parts.shapes.extend(props::particles(&particles, small));
106 parts
107 }
108
109 /// Top-to-bottom bit layout of one 2×4 Braille cell.
110 pub const BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
111
112 pub(super) fn segments(path: &Path) -> Vec<[f64; 4]> {
113 let mut edges = Vec::new();
114 let mut pen: Option<[f64; 2]> = None;
115 let mut start = [0., 0.];
116 let mut line = |pen: &mut Option<[f64; 2]>, q: [f64; 2]| {
117 if let Some(from) = *pen {
118 edges.push([from[0], from[1], q[0], q[1]]);
119 }
120 *pen = Some(q);
121 };
122 for c in path {
123 match c {
124 Cmd::M(m) => {
125 pen = Some(*m);
126 start = *m;
127 }
128 Cmd::C(c) => {
129 let from = pen.unwrap_or([0., 0.]);
130 for i in 1..=10 {
131 let t = i as f64 / 10.;
132 let u = 1. - t;
133 line(
134 &mut pen,
135 [
136 u * u * u * from[0]
137 + 3. * u * u * t * c[0]
138 + 3. * u * t * t * c[2]
139 + t * t * t * c[4],
140 u * u * u * from[1]
141 + 3. * u * u * t * c[1]
142 + 3. * u * t * t * c[3]
143 + t * t * t * c[5],
144 ],
145 );
146 }
147 }
148 Cmd::Z => line(&mut pen, start),
149 }
150 }
151 edges
152 }
153
154 /// Packed row-major Braille cells, as `render_grid` consumes them.
155 #[derive(Clone, Debug, PartialEq, Eq)]
156 pub struct Grid {
157 pub cols: usize,
158 pub rows: usize,
159 pub cells: Vec<u8>,
160 }
161
162 impl Grid {
163 /// Canonical allocation limits; invalid public grids are never indexed.
164 pub fn is_valid(&self) -> bool {
165 valid_viewport(self.cols, self.rows, 0.5)
166 && self.cols.checked_mul(self.rows) == Some(self.cells.len())
167 }
168
169 /// Convert the evaluated native frame for the existing Whale painter.
170 pub fn to_terminal(&self) -> Option<crate::whale::Grid> {
171 self.is_valid().then(|| crate::whale::Grid {
172 cols: self.cols as u16,
173 rows: self.rows as u16,
174 cells: self.cells.clone(),
175 })
176 }
177
178 pub fn text(&self) -> String {
179 if !self.is_valid() {
180 return String::new();
181 }
182 self.cells
183 .chunks(self.cols)
184 .map(|row| {
185 row.iter()
186 .map(|b| {
187 if *b == 0 {
188 ' '
189 } else {
190 char::from_u32(0x2800 + u32::from(*b)).unwrap()
191 }
192 })
193 .collect::<String>()
194 })
195 .collect::<Vec<_>>()
196 .join("\n")
197 }
198 pub fn hex(&self) -> String {
199 if !self.is_valid() {
200 return String::new();
201 }
202 self.cells.iter().map(|b| format!("{b:02x}")).collect()
203 }
204 }
205
206 /// `rasterize()`: even-odd scan conversion of the one-ink scene into dots.
207 pub fn rasterize(parts: &Parts, cols: usize, rows: usize, cell_aspect: f64) -> Grid {
208 rasterize_with_inks(parts, cols, rows, cell_aspect, None).grid
209 }
210
211 /// Color any evaluated direction with the same byte geometry and native ink.
212 pub fn rasterize_colored(
213 parts: &Parts,
214 cols: usize,
215 rows: usize,
216 cell_aspect: f64,
217 dark: bool,
218 ) -> ColoredGrid {
219 rasterize_with_inks(parts, cols, rows, cell_aspect, Some(dark))
220 }
221
222 /// The canonical Braille geometry plus one foreground color per cell.
223 /// Empty dots and the one-ink apertures retain the caller's ground. A cell's
224 /// majority visible role wins; later paint order breaks ties, and occupied
225 /// dot colors of that role are averaged. Thus blue keeps its native diagonal
226 /// ombre while a pointer, pencil or tool can retain its own ink. This is a
227 /// terminal color approximation, not an alteration of the byte oracle.
228 #[derive(Clone, Debug, PartialEq, Eq)]
229 pub struct ColoredGrid {
230 pub grid: Grid,
231 pub colors: Vec<Option<u32>>,
232 }
233
234 impl ColoredGrid {
235 /// Paint the live frame through the existing Whale clipping/label path.
236 /// The supplied Whale owns the action's localized words. Invalid frames,
237 /// ASCII terminals and cramped areas keep that painter's word fallback.
238 pub fn paint(
239 &self,
240 whale: &crate::Whale,
241 area: ratatui::layout::Rect,
242 buf: &mut ratatui::buffer::Buffer,
243 theme: &crate::Theme,
244 ) {
245 self.paint_with_contrast(whale, area, buf, theme, 0.0);
246 }
247
248 /// Paint native geometry with an optional decorative contrast floor.
249 /// On a measured ground, only insufficient native inks are mixed toward
250 /// the theme's foreground. The packed dots and canonical color plane stay
251 /// unchanged. Use 3.0 for a small terminal companion; zero retains exact
252 /// native ink. Unknown grounds and plain-text profiles keep their fallback.
253 pub fn paint_with_contrast(
254 &self,
255 whale: &crate::Whale,
256 area: ratatui::layout::Rect,
257 buf: &mut ratatui::buffer::Buffer,
258 theme: &crate::Theme,
259 minimum: f32,
260 ) {
261 let area = area.intersection(buf.area);
262 if area.is_empty() {
263 return;
264 }
265 let valid = self.colors.len() == self.grid.cells.len();
266 let terminal =
267 valid
268 .then(|| self.grid.to_terminal())
269 .flatten()
270 .unwrap_or(crate::whale::Grid {
271 cols: 0,
272 rows: 0,
273 cells: Vec::new(),
274 });
275 let fits = !theme.ascii()
276 && valid
277 && self.grid.is_valid()
278 && terminal.cols >= 16
279 && terminal.rows >= 8
280 && terminal.cols <= area.width
281 && terminal.rows < area.height;
282 let label_y = if fits {
283 area.y + (area.height - terminal.rows - 1) / 2 + terminal.rows
284 } else {
285 area.y
286 };
287 // ratatui resets wide-glyph continuation cells, including their
288 // backgrounds. Keep the host's grounds while retaining the shared
289 // painter's label, clipping and style behavior. Only one row is saved,
290 // so a large allocation never copies the whole destination buffer.
291 let backgrounds: Vec<_> = (0..area.width)
292 .map(|x| buf[(area.x + x, label_y)].bg)
293 .collect();
294 whale.paint_frame(area, buf, theme, &terminal);
295 for (x, background) in (0..area.width).zip(backgrounds) {
296 buf[(area.x + x, label_y)].set_bg(background);
297 }
298 if !fits || !theme.paints_grounds() {
299 return;
300 }
301 let x0 = area.x + (area.width - terminal.cols) / 2;
302 let y0 = area.y + (area.height - terminal.rows - 1) / 2;
303 for row in 0..terminal.rows {
304 for col in 0..terminal.cols {
305 let i = usize::from(row) * self.grid.cols + usize::from(col);
306 if terminal.cells[i] == 0 {
307 continue;
308 }
309 if let Some(hex) = self.colors[i] {
310 let rgb = crate::color::rgb(hex);
311 let color = match (theme.depth(), rgb) {
312 (crate::color::ColorDepth::TrueColor, c) => c,
313 (
314 crate::color::ColorDepth::Ansi256,
315 ratatui::style::Color::Rgb(r, g, b),
316 ) => ratatui::style::Color::Indexed(crate::color::rgb_to_ansi256(r, g, b)),
317 _ => continue,
318 };
319 if let Some(cell) = buf.cell_mut((x0 + col, y0 + row)) {
320 let mut ink = color;
321 if minimum.is_finite()
322 && minimum > 0.0
323 && let Some((r, g, b)) = theme
324 .color(crate::Role::Foreground)
325 .and_then(crate::color::resolvable_rgb)
326 {
327 let foreground = ratatui::style::Color::Rgb(r, g, b);
328 for step in 0..=32 {
329 if crate::color::contrast_ratio(ink, cell.bg)
330 .is_none_or(|ratio| ratio >= minimum.min(7.0))
331 {
332 break;
333 }
334 let candidate =
335 crate::color::blend(foreground, rgb, step as f32 / 32.0);
336 ink = match (theme.depth(), candidate) {
337 (
338 crate::color::ColorDepth::Ansi256,
339 ratatui::style::Color::Rgb(r, g, b),
340 ) => ratatui::style::Color::Indexed(
341 crate::color::rgb_to_ansi256(r, g, b),
342 ),
343 _ => candidate,
344 };
345 }
346 }
347 cell.set_fg(ink);
348 }
349 }
350 }
351 }
352 }
353 }
354
355 #[derive(Clone, Copy)]
356 struct DotInk {
357 role: usize,
358 order: usize,
359 rgb: [u8; 3],
360 }
361
362 fn valid_viewport(cols: usize, rows: usize, cell_aspect: f64) -> bool {
363 (1..=160).contains(&cols)
364 && (1..=80).contains(&rows)
365 && cell_aspect.is_finite()
366 && cell_aspect > 0.
367 }
368
369 fn role_index(role: Role) -> usize {
370 match role {
371 Role::Body => 0,
372 Role::Tool => 1,
373 Role::Accent => 2,
374 Role::Pointer => 3,
375 Role::Water => 4,
376 Role::Hole | Role::Cutout => 5,
377 }
378 }
379
380 fn dot_ink(role: Role, dark: bool, x: f64, y: f64, order: usize) -> Option<DotInk> {
381 let hex = match ink::fill(role, dark)? {
382 Fill::Solid(hex) => hex,
383 Fill::Gradient([top, bottom]) => {
384 let t = ((x + y + 100.) / 200.).clamp(0., 1.);
385 let channel = |shift: u32| {
386 let a = ((top >> shift) & 255_u32) as f64;
387 let b = ((bottom >> shift) & 255_u32) as f64;
388 (a + (b - a) * t).round() as u32
389 };
390 (channel(16) << 16) | (channel(8) << 8) | channel(0)
391 }
392 };
393 Some(DotInk {
394 role: role_index(role),
395 order,
396 rgb: [(hex >> 16) as u8, (hex >> 8) as u8, hex as u8],
397 })
398 }
399
400 fn rasterize_with_inks(
401 parts: &Parts,
402 cols: usize,
403 rows: usize,
404 cell_aspect: f64,
405 dark: Option<bool>,
406 ) -> ColoredGrid {
407 if !valid_viewport(cols, rows, cell_aspect) {
408 return ColoredGrid {
409 grid: Grid {
410 cols: 0,
411 rows: 0,
412 cells: Vec::new(),
413 },
414 colors: Vec::new(),
415 };
416 }
417 let width = cols * 2;
418 let height = rows * 4;
419 let mut dots = vec![false; width * height];
420 let mut inks = dark.map(|_| vec![None; width * height]);
421 let scale = (width as f64 * cell_aspect * 2.).min(height as f64) / 124.;
422 let sx = scale / (cell_aspect * 2.);
423 let sy = scale;
424 let (hw, hh) = (width as f64 / 2., height as f64 / 2.);
425 for (order, shape) in parts.shapes.iter().enumerate() {
426 if !shape.opacity.is_finite()
427 || shape.opacity < 0.5
428 || shape.role == Role::Hole
429 || shape.role == Role::Cutout
430 {
431 continue;
432 }
433 let mut paths = vec![shape.path.clone()];
434 paths.extend(holes_for(&shape.id, &parts.shapes, true));
435 paths.retain(|path| {
436 matches!(path.first(), Some(Cmd::M(_)))
437 && path.iter().flat_map(Cmd::coords).all(|v| v.is_finite())
438 });
439 let edges: Vec<[f64; 4]> = paths
440 .iter()
441 .flat_map(segments)
442 .map(|[x1, y1, x2, y2]| [hw + x1 * sx, hh + y1 * sy, hw + x2 * sx, hh + y2 * sy])
443 .filter(|edge| edge.iter().all(|v| v.is_finite()))
444 .collect();
445 let mut cross = Vec::new();
446 for y in 0..height {
447 let cy = y as f64 + 0.5;
448 cross.clear();
449 for [x1, y1, x2, y2] in &edges {
450 if (*y1 > cy) != (*y2 > cy) {
451 cross.push(x1 + (cy - y1) * (x2 - x1) / (y2 - y1));
452 }
453 }
454 cross.sort_by(f64::total_cmp);
455 let mut i = 0;
456 while i + 1 < cross.len() {
457 let left = (cross[i] - 0.5).ceil().max(0.);
458 let right = (cross[i + 1] - 0.5).ceil().min(width as f64);
459 let mut x = left;
460 while x < right {
461 dots[y * width + x as usize] = true;
462 if let (Some(inks), Some(dark)) = (&mut inks, dark) {
463 inks[y * width + x as usize] =
464 dot_ink(shape.role, dark, (x + 0.5 - hw) / sx, (cy - hh) / sy, order);
465 }
466 x += 1.;
467 }
468 i += 2;
469 }
470 }
471 }
472 let mut cells = vec![0u8; cols * rows];
473 for y in 0..height {
474 for x in 0..width {
475 if dots[y * width + x] {
476 cells[(y / 4) * cols + x / 2] |= BITS[y % 4][x % 2];
477 }
478 }
479 }
480 let colors = if let Some(inks) = inks {
481 let mut colors = vec![None; cols * rows];
482 for row in 0..rows {
483 for col in 0..cols {
484 let mut counts = [0u32; 6];
485 let mut latest = [0usize; 6];
486 let mut sums = [[0u32; 3]; 6];
487 for dy in 0..4 {
488 for dx in 0..2 {
489 if let Some(ink) = inks[(row * 4 + dy) * width + col * 2 + dx] {
490 counts[ink.role] += 1;
491 latest[ink.role] = latest[ink.role].max(ink.order);
492 for (sum, channel) in sums[ink.role].iter_mut().zip(ink.rgb) {
493 *sum += u32::from(channel);
494 }
495 }
496 }
497 }
498 let role = (0..6)
499 .max_by_key(|role| (counts[*role], latest[*role]))
500 .unwrap();
501 if counts[role] > 0 {
502 let c = sums[role].map(|sum| (sum + counts[role] / 2) / counts[role]);
503 colors[row * cols + col] = Some((c[0] << 16) | (c[1] << 8) | c[2]);
504 }
505 }
506 }
507 colors
508 } else {
509 Vec::new()
510 };
511 ColoredGrid {
512 grid: Grid { cols, rows, cells },
513 colors,
514 }
515 }
516
517 /// `frame()`: the terminal still at `cols × rows`, via the small rig.
518 pub fn braille(director: &Director, cols: usize, rows: usize) -> Grid {
519 if !valid_viewport(cols, rows, 0.5) {
520 return Grid {
521 cols: 0,
522 rows: 0,
523 cells: Vec::new(),
524 };
525 }
526 let size = (cols * 2).min(rows * 4) as f64;
527 rasterize(
528 &scene(
529 director,
530 View {
531 lod: 2,
532 ..View::hero(size, 1.)
533 },
534 ),
535 cols,
536 rows,
537 0.5,
538 )
539 }
540
541 /// A live terminal frame with the character's native light/dark palette.
542 pub fn colored_braille(director: &Director, cols: usize, rows: usize, dark: bool) -> ColoredGrid {
543 if !valid_viewport(cols, rows, 0.5) {
544 return rasterize_with_inks(&Parts::default(), cols, rows, 0.5, Some(dark));
545 }
546 let size = (cols * 2).min(rows * 4) as f64;
547 let parts = scene(
548 director,
549 View {
550 lod: 2,
551 ..View::hero(size, 1.)
552 },
553 );
554 rasterize_with_inks(&parts, cols, rows, 0.5, Some(dark))
555 }
556
556 lines RUST