Skip to main content

omp_tui/components/
scene.rs

1//! Raytraced braille viewport: the retained face of [`crate::scene`].
2
3use crate::{
4	anim::FRAME,
5	component::{Component, PaintCtx, Slot, next_slot},
6	context::UiContext,
7	frame::Rect,
8	props::{Prop, PropValue, Props},
9	scene::{Trace, rasterize},
10};
11
12/// A live 3D viewport that rasterizes a [`Trace`] scene into braille cells.
13///
14/// Use [`crate::scene::PathTracer`] for physical geometry and materials, or
15/// implement [`Trace`] for procedural shading and animated scene state.
16///
17/// The scene advances on the shared presentation clock and repaints at
18/// [`FRAME`] cadence while presented; a [`still`](Self::still) scene paints
19/// once and requests nothing. Unlit cells stay transparent, so set `bg` for
20/// a backdrop. Mount it in `dom!` as an expression child, or register a
21/// factory for a `<scene>` markup tag through [`crate::Elements`].
22///
23/// ```
24/// use omp_tui::{
25/// 	components::Scene,
26/// 	dom,
27/// 	scene::{Ray, Vec3, vec3},
28/// };
29///
30/// // A still orb; animated scenes implement `scene::Trace` instead.
31/// let orb = |ray: Ray| -> (Vec3, f32) {
32/// 	let along = -ray.origin.dot(ray.dir);
33/// 	let nearest = ray.origin + ray.dir * along;
34/// 	let glow = 1.0 - nearest.dot(nearest);
35/// 	(vec3(0.4, 0.8, 1.0) * (0.4 + 0.6 * glow), if glow > 0.0 { 1.0 } else { 0.0 })
36/// };
37/// let tree = dom! {
38/// 	<col>
39/// 		{Scene::new(orb).size(24, 8).still()}
40/// 	</col>
41/// };
42/// # let _ = tree;
43/// ```
44pub struct Scene {
45	props: Props,
46	slot:  Slot,
47	trace: Box<dyn Trace>,
48	cols:  u16,
49	rows:  u16,
50	live:  bool,
51}
52
53impl Scene {
54	/// Creates a 24×8-cell viewport over `scene`.
55	pub fn new(scene: impl Trace + 'static) -> Self {
56		Self {
57			props: Props::new(),
58			slot:  next_slot(),
59			trace: Box::new(scene),
60			cols:  24,
61			rows:  8,
62			live:  true,
63		}
64	}
65
66	/// Sets the viewport size in terminal cells.
67	pub const fn size(mut self, cols: u16, rows: u16) -> Self {
68		self.cols = cols;
69		self.rows = rows;
70		self
71	}
72
73	/// Paints once instead of animating — for scenes that ignore the clock.
74	pub const fn still(mut self) -> Self {
75		self.live = false;
76		self
77	}
78
79	/// Sets one scene property.
80	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
81		self.props.set(prop, value);
82		self
83	}
84}
85
86impl Component for Scene {
87	fn props(&self) -> &Props {
88		&self.props
89	}
90
91	fn props_mut(&mut self) -> &mut Props {
92		&mut self.props
93	}
94
95	fn slot(&self) -> Slot {
96		self.slot
97	}
98
99	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
100		(self.cols, self.cols)
101	}
102
103	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
104		self.rows
105	}
106
107	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
108		if rect.y >= pc.clip || rect.width == 0 || rect.height == 0 {
109			return;
110		}
111		let camera = self.trace.advance(pc.now);
112		let base = self.props.style(&pc.ctx.theme);
113		let cols = self.cols.min(rect.width);
114		let rows = self.rows.min(rect.height).min(pc.clip - rect.y);
115		let frame = &mut *pc.frame;
116		let mut buffer = [0_u8; 4];
117		rasterize(&*self.trace, &camera, cols, rows, |x, y, glyph, color| {
118			frame.put(rect.x + x, rect.y + y, glyph.encode_utf8(&mut buffer), base.fg(color));
119		});
120		if self.live {
121			pc.wake(self.slot, pc.now + FRAME);
122		}
123	}
124}
125
126#[cfg(test)]
127mod tests {
128	use std::time::Duration;
129
130	use super::*;
131	use crate::{
132		anim,
133		scene::{Camera, Light, Material, Object, PathTracer, Ray, Sphere, Vec3, World, vec3},
134		test_support::frame_row_text,
135		ui::Ui,
136	};
137
138	fn orb(ray: Ray) -> (Vec3, f32) {
139		let along = -ray.origin.dot(ray.dir);
140		let nearest = ray.origin + ray.dir * along;
141		if along > 0.0 && nearest.dot(nearest) <= 1.0 {
142			(vec3(1.0, 1.0, 1.0), 1.0)
143		} else {
144			(Vec3::ZERO, 0.0)
145		}
146	}
147
148	fn physical_orb() -> PathTracer {
149		let world = World::new(vec![Object::new(
150			Sphere::new(Vec3::ZERO, 1.0),
151			Material::diffuse(Vec3::rgb(56, 189, 248)),
152		)])
153		.with_light(Light::directional(vec3(-1.0, -1.0, -1.0), Vec3::ONE, 2.0));
154		PathTracer::new(world)
155	}
156
157	fn lit_braille(ui: &Ui) -> bool {
158		(0..ui.frame().size().height).any(|row| {
159			frame_row_text(ui.frame(), row)
160				.chars()
161				.any(|glyph| ('\u{2801}'..='\u{28ff}').contains(&glyph))
162		})
163	}
164
165	#[test]
166	fn live_scene_paints_braille_and_reschedules() {
167		let mut ui = Ui::from_root(Scene::new(orb).size(12, 6), 12, UiContext::default());
168		assert!(lit_braille(&ui), "the orb lights braille cells");
169		assert_eq!(ui.next_wake(), Some(anim::FRAME));
170		assert!(ui.tick(anim::FRAME), "the frame deadline repaints");
171		assert_eq!(ui.next_wake(), Some(anim::FRAME + anim::FRAME));
172	}
173
174	#[test]
175	fn still_physical_scene_traces_and_requests_no_wake() {
176		let ui =
177			Ui::from_root(Scene::new(physical_orb()).size(12, 6).still(), 12, UiContext::default());
178		assert!(lit_braille(&ui));
179		assert_eq!(ui.next_wake(), None);
180	}
181
182	#[test]
183	fn animated_scene_reads_the_paint_clock() {
184		/// Sweeps the camera a quarter turn per second.
185		struct Turntable;
186		impl Trace for Turntable {
187			fn advance(&mut self, now: Duration) -> Camera {
188				Camera { yaw: now.as_secs_f32() * std::f32::consts::FRAC_PI_2, ..Camera::default() }
189			}
190
191			fn shade(&self, ray: Ray) -> (Vec3, f32) {
192				orb(Ray { origin: ray.origin - vec3(1.4, 0.0, 0.0), dir: ray.dir })
193			}
194		}
195
196		let rows = |ui: &Ui| -> Vec<String> {
197			(0..ui.frame().size().height)
198				.map(|row| frame_row_text(ui.frame(), row))
199				.collect()
200		};
201		let mut ui = Ui::from_root(Scene::new(Turntable).size(16, 8), 16, UiContext::default());
202		let start = rows(&ui);
203		ui.tick(Duration::from_millis(500));
204		assert_ne!(start, rows(&ui), "the clock swings the camera");
205	}
206}