| 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 |