use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use crate::base::{Point, Rgba};
use crate::gfx::mosaic::{MosaicMode, MosaicRenderer};
use crate::layout::Style as LayoutStyle;
use crate::theme::TokenSet;
use crate::three::raster::Framebuffer;
use crate::three::scene::{Camera, Light, Scene, SceneRenderer};
use crate::three::{Model, Pose};
use crate::ui::{Element, MouseKind, UiEvent};
const DRAG_YAW_PER_CELL: f32 = 0.05;
const DRAG_PITCH_PER_CELL: f32 = 0.10;
pub const DEFAULT_ORBIT: (f32, f32, f32) = (0.6, 0.35, 1.0);
pub struct Viewport3D {
model: Arc<Model>,
fog: f32,
yaw: f32,
pitch: f32,
zoom: f32,
spin: f32,
animation: Option<(usize, f32)>,
mode: MosaicMode,
light: Light,
background: Rgba,
double_sided: bool,
on_orbit: Option<Box<dyn FnMut(f32, f32)>>,
on_zoom: Option<Box<dyn FnMut(f32)>>,
layout: LayoutStyle,
}
impl Viewport3D {
pub fn new(model: Arc<Model>) -> Viewport3D {
Viewport3D {
model,
fog: 0.0,
yaw: DEFAULT_ORBIT.0,
pitch: DEFAULT_ORBIT.1,
zoom: DEFAULT_ORBIT.2,
spin: 0.0,
animation: None,
mode: MosaicMode::HalfBlock,
light: Light::default(),
background: Rgba::TRANSPARENT,
double_sided: true,
on_orbit: None,
on_zoom: None,
layout: LayoutStyle::default(),
}
}
pub fn orbit(mut self, yaw: f32, pitch: f32, zoom: f32) -> Viewport3D {
self.yaw = yaw;
self.pitch = pitch;
self.zoom = zoom.max(0.05);
self
}
pub fn spin(mut self, extra_yaw: f32) -> Viewport3D {
self.spin = extra_yaw;
self
}
pub fn animate(mut self, index: usize, t: f32) -> Viewport3D {
self.animation = Some((index, t));
self
}
pub fn mode(mut self, mode: MosaicMode) -> Viewport3D {
self.mode = mode;
self
}
pub fn light(mut self, light: Light) -> Viewport3D {
self.light = light;
self
}
pub fn light_angles(mut self, azimuth: f32, elevation: f32) -> Viewport3D {
self.light = Light {
direction: Light::from_angles(azimuth, elevation).direction,
..self.light
};
self
}
pub fn fog(mut self, strength: f32) -> Viewport3D {
self.fog = strength.clamp(0.0, 1.0);
self
}
pub fn background(mut self, ground: Rgba) -> Viewport3D {
self.background = ground;
self
}
pub fn cull_backfaces(mut self, cull: bool) -> Viewport3D {
self.double_sided = !cull;
self
}
pub fn on_orbit(mut self, f: impl FnMut(f32, f32) + 'static) -> Viewport3D {
self.on_orbit = Some(Box::new(f));
self
}
pub fn on_zoom(mut self, f: impl FnMut(f32) + 'static) -> Viewport3D {
self.on_zoom = Some(Box::new(f));
self
}
pub fn layout(mut self, style: LayoutStyle) -> Viewport3D {
self.layout = style;
self
}
pub fn view(self, cx: crate::reactive::Scope) -> crate::ui::View {
let t = crate::widgets::theme_tokens(cx);
self.element(&t).build()
}
pub fn element(self, _t: &TokenSet) -> Element {
let model = self.model;
let (yaw, pitch, zoom, spin) = (self.yaw, self.pitch, self.zoom, self.spin);
let (mode, light, background, double_sided, fog) = (
self.mode,
self.light,
self.background,
self.double_sided,
self.fog,
);
let animation = self.animation;
let mut fb: Option<Framebuffer> = None;
let mut mosaic = MosaicRenderer::new();
let mut scene_renderer = SceneRenderer::new();
let mut pose = Pose::default();
let draw_model = model.clone();
let mut el = Element::new().style(self.layout).draw(move |canvas, rect| {
if rect.w <= 0 || rect.h <= 0 {
return;
}
let (subw, subh) = mode.cell_pixels();
let (pw, ph) = (rect.w as u32 * subw, rect.h as u32 * subh);
let fb = match &mut fb {
Some(f) if f.width() == pw && f.height() == ph => f,
slot => slot.insert(Framebuffer::new(pw, ph)),
};
let Some((min, max)) = draw_model.bounds() else {
return; };
let mut camera = Camera::framing(min, max, yaw + spin, pitch);
camera.distance *= zoom;
let mut scene = Scene::new(&draw_model, camera);
scene.light = light;
scene.background = background;
scene.double_sided = double_sided;
if let Some((index, t)) = animation {
let duration = draw_model
.animations()
.get(index)
.map(|a| a.duration())
.unwrap_or(0.0);
let looped = if duration > 1e-6 {
t.rem_euclid(duration)
} else {
0.0
};
if draw_model.sample_pose_full(index, looped, &mut pose) {
scene.pose = Some(&pose);
}
}
scene_renderer.render(&scene, fb);
if fog > 0.0 && !background.is_transparent() {
fb.depth_fog(background, fog);
}
let grid = mosaic.render(fb.bitmap(), rect.w as u32, rect.h as u32, mode);
for (pos, ch, fg, bg) in grid.cell_patches(rect.origin()) {
canvas.put(pos, ch, fg, bg);
}
});
if self.on_orbit.is_some() || self.on_zoom.is_some() {
let mut on_orbit = self.on_orbit;
let mut on_zoom = self.on_zoom;
let last: Rc<RefCell<Option<Point>>> = Rc::new(RefCell::new(None));
el = el.on_event(move |ctx, event| {
let UiEvent::Mouse(m) = event else { return };
match m.kind {
MouseKind::Down(crate::ui::MouseButton::Left) => {
*last.borrow_mut() = Some(m.pos);
if let Some(id) = ctx.target() {
ctx.capture_pointer(id);
}
}
MouseKind::Drag(crate::ui::MouseButton::Left) => {
let mut anchor = last.borrow_mut();
if let (Some(prev), Some(cb)) = (*anchor, on_orbit.as_mut()) {
let dx = (m.pos.x - prev.x) as f32;
let dy = (m.pos.y - prev.y) as f32;
if dx != 0.0 || dy != 0.0 {
cb(-dx * DRAG_YAW_PER_CELL, dy * DRAG_PITCH_PER_CELL);
}
}
*anchor = Some(m.pos);
}
MouseKind::Up(crate::ui::MouseButton::Left) => {
*last.borrow_mut() = None;
ctx.release_pointer();
}
MouseKind::ScrollUp => {
if let Some(cb) = on_zoom.as_mut() {
cb(1.0);
}
ctx.stop_propagation();
}
MouseKind::ScrollDown => {
if let Some(cb) = on_zoom.as_mut() {
cb(-1.0);
}
ctx.stop_propagation();
}
_ => {}
}
});
}
el
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Size;
use crate::theme::default_theme;
use crate::three::primitives::{cube, model_of};
use crate::widgets::test_util::draw_into;
fn cube_model() -> Arc<Model> {
Arc::new(model_of(cube(2.0), [1.0, 1.0, 1.0, 1.0]))
}
fn coverage(c: &crate::ui::BufferCanvas, size: Size) -> usize {
let mut n = 0;
for y in 0..size.h {
for x in 0..size.w {
let cell = c.cell(Point::new(x, y)).unwrap();
if cell.0 != ' ' || cell.2.a != 0 {
n += 1;
}
}
}
n
}
#[test]
fn renders_cube_with_nonzero_coverage() {
let t = default_theme().tokens;
let size = Size::new(30, 15);
let el = Viewport3D::new(cube_model()).element(&t);
let c = draw_into(el, size);
let n = coverage(&c, size);
assert!(n > 20, "cube coverage {n} cells");
}
#[test]
fn orbit_changes_pixels_deterministically() {
let t = default_theme().tokens;
let size = Size::new(24, 12);
let paint = |yaw: f32| {
let el = Viewport3D::new(cube_model())
.orbit(yaw, 0.35, 1.0)
.element(&t);
let c = draw_into(el, size);
(0..size.h).map(|y| c.row_text(y)).collect::<Vec<_>>()
};
let a = paint(0.6);
let b = paint(0.6);
assert_eq!(a, b, "same props, same pixels");
let c = paint(1.4);
assert_ne!(a, c, "orbit must change the frame");
}
#[test]
fn spin_prop_drives_turntable() {
let t = default_theme().tokens;
let size = Size::new(24, 12);
let frame = |spin: f32| {
let el = Viewport3D::new(cube_model()).spin(spin).element(&t);
let c = draw_into(el, size);
(0..size.h).map(|y| c.row_text(y)).collect::<Vec<_>>()
};
assert_ne!(frame(0.0), frame(0.9));
}
#[test]
fn zoom_scales_apparent_size() {
let t = default_theme().tokens;
let size = Size::new(30, 15);
let cover = |zoom: f32| {
let el = Viewport3D::new(cube_model())
.orbit(0.6, 0.35, zoom)
.element(&t);
let c = draw_into(el, size);
coverage(&c, size)
};
assert!(
cover(0.6) > cover(2.0),
"closer camera must cover more cells ({} vs {})",
cover(0.6),
cover(2.0)
);
}
#[test]
fn fog_and_light_angles_change_the_frame() {
let t = default_theme().tokens;
let size = Size::new(24, 12);
let dump = |el: Element| {
let c = draw_into(el, size);
(0..size.h)
.flat_map(|y| (0..size.w).map(move |x| (x, y)))
.map(|(x, y)| c.cell(Point::new(x, y)).unwrap())
.collect::<Vec<_>>()
};
let base = dump(
Viewport3D::new(cube_model())
.background(t.surface)
.element(&t),
);
let fogged = dump(
Viewport3D::new(cube_model())
.background(t.surface)
.fog(0.9)
.element(&t),
);
assert_ne!(base, fogged, "fog must recolor depth");
let relit = dump(
Viewport3D::new(cube_model())
.background(t.surface)
.light_angles(2.4, 0.1)
.element(&t),
);
assert_ne!(base, relit, "light angles must change shading");
}
#[test]
fn default_orbit_is_the_reset_target() {
let t = default_theme().tokens;
let size = Size::new(20, 10);
let fresh = draw_into(Viewport3D::new(cube_model()).element(&t), size);
let reset = draw_into(
Viewport3D::new(cube_model())
.orbit(DEFAULT_ORBIT.0, DEFAULT_ORBIT.1, DEFAULT_ORBIT.2)
.element(&t),
size,
);
for y in 0..size.h {
assert_eq!(fresh.row_text(y), reset.row_text(y));
}
}
#[test]
fn animation_playback_moves_pixels_and_loops() {
let t = default_theme().tokens;
let size = Size::new(30, 15);
let (json, bin) = crate::three::skin_tests::skinned_bar_glb();
let model = Arc::new(
Model::load(&crate::testing::glb_mutate::assemble(
json.as_bytes(),
Some(&bin),
))
.unwrap(),
);
let frame = |el: Element| {
let c = draw_into(el, size);
(0..size.h)
.flat_map(|y| (0..size.w).map(move |x| (x, y)))
.map(|(x, y)| c.cell(Point::new(x, y)).unwrap())
.collect::<Vec<_>>()
};
let rest = frame(Viewport3D::new(model.clone()).element(&t));
let bent = frame(Viewport3D::new(model.clone()).animate(0, 0.5).element(&t));
assert_ne!(rest, bent, "playback must move pixels");
let wrapped = frame(Viewport3D::new(model.clone()).animate(0, 1.5).element(&t));
assert_eq!(bent, wrapped, "time must loop over the clip duration");
let bad = frame(Viewport3D::new(model.clone()).animate(9, 0.5).element(&t));
assert_eq!(rest, bad);
}
#[test]
fn all_four_mosaic_modes_render_the_scene() {
let t = default_theme().tokens;
let size = Size::new(30, 15);
let mut frames = Vec::new();
for mode in [
MosaicMode::HalfBlock,
MosaicMode::Quadrant,
MosaicMode::Sextant,
MosaicMode::Braille,
] {
let el = Viewport3D::new(cube_model()).mode(mode).element(&t);
let c = draw_into(el, size);
let n = coverage(&c, size);
assert!(n > 10, "{mode:?} coverage {n}");
frames.push(
(0..size.h)
.flat_map(|y| (0..size.w).map(move |x| (x, y)))
.map(|(x, y)| c.cell(Point::new(x, y)).unwrap())
.collect::<Vec<_>>(),
);
}
for i in 0..frames.len() {
for j in (i + 1)..frames.len() {
assert_ne!(frames[i], frames[j], "modes {i} and {j} identical");
}
}
}
#[test]
fn degenerate_rects_and_empty_models_never_panic() {
let t = default_theme().tokens;
for size in [Size::new(0, 0), Size::new(1, 1)] {
let el = Viewport3D::new(cube_model()).element(&t);
let _ = draw_into(el, size);
}
let empty = Arc::new(Model::default());
let el = Viewport3D::new(empty).element(&t);
let c = draw_into(el, Size::new(8, 4));
assert_eq!(coverage(&c, Size::new(8, 4)), 0);
}
}