use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_centered};
pub const MIN_PERSPECTIVE_SCALE: f32 = 0.05;
pub const MAX_PERSPECTIVE_SCALE: f32 = 20.0;
pub const CARD_FONT_SIZE_VMIN: f32 = 4.0;
pub const CARD_FONT_WEIGHT: u16 = 700;
pub const EDGE_ON_EPSILON: f32 = 1e-4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CardFacing {
#[default]
Outward,
Inward,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct CarouselPose {
pub x: f32,
pub y: f32,
pub z: f32,
pub rotate_x: f32,
pub rotate_y: f32,
pub rotate_z: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraTransform {
rotation: [[f32; 3]; 3],
translation: [f32; 3],
}
impl CameraTransform {
pub fn apply(&self, point: [f32; 3]) -> [f32; 3] {
let mut out = [0.0_f32; 3];
for (row, value) in out.iter_mut().enumerate() {
*value = self.rotation[row][0] * point[0]
+ self.rotation[row][1] * point[1]
+ self.rotation[row][2] * point[2]
+ self.translation[row];
}
out
}
pub fn rotation(&self) -> [[f32; 3]; 3] {
self.rotation
}
pub fn translation(&self) -> [f32; 3] {
self.translation
}
}
pub fn pose_camera_inverse(pose: &CarouselPose) -> CameraTransform {
let (sin_x, cos_x) = pose.rotate_x.to_radians().sin_cos();
let (sin_y, cos_y) = pose.rotate_y.to_radians().sin_cos();
let (sin_z, cos_z) = pose.rotate_z.to_radians().sin_cos();
let forward = [
[cos_y * cos_z, -cos_y * sin_z, sin_y],
[
cos_x * sin_z + sin_x * sin_y * cos_z,
cos_x * cos_z - sin_x * sin_y * sin_z,
-sin_x * cos_y,
],
[
sin_x * sin_z - cos_x * sin_y * cos_z,
sin_x * cos_z + cos_x * sin_y * sin_z,
cos_x * cos_y,
],
];
let rotation = [
[forward[0][0], forward[1][0], forward[2][0]],
[forward[0][1], forward[1][1], forward[2][1]],
[forward[0][2], forward[1][2], forward[2][2]],
];
let position = [pose.x, pose.y, pose.z];
let mut translation = [0.0_f32; 3];
for (row, value) in translation.iter_mut().enumerate() {
*value = -(rotation[row][0] * position[0]
+ rotation[row][1] * position[1]
+ rotation[row][2] * position[2]);
}
CameraTransform {
rotation,
translation,
}
}
pub fn spin_deg(frame: f32, spin_offset: f32, revolution_frames: usize) -> f32 {
spin_offset + frame * (360.0 / revolution_frames.max(1) as f32)
}
pub fn card_theta_deg(index: usize, count: usize, spin: f32) -> f32 {
if count == 0 {
return spin;
}
index as f32 * (360.0 / count as f32) + spin
}
pub fn card_offset(theta_deg: f32, vmin: f32, radius: f32) -> (f32, f32) {
let theta = theta_deg.to_radians();
(theta.sin() * vmin * radius, theta.cos() * vmin * radius)
}
pub fn perspective_scale(depth: f32, perspective: f32) -> Option<f32> {
let denominator = perspective - depth;
if denominator <= 0.0 {
return None;
}
Some((perspective / denominator).clamp(MIN_PERSPECTIVE_SCALE, MAX_PERSPECTIVE_SCALE))
}
pub fn card_facing_flip(facing: CardFacing) -> f32 {
match facing {
CardFacing::Outward => 0.0,
CardFacing::Inward => 180.0,
}
}
pub fn card_squash(theta_deg: f32, facing: CardFacing) -> Option<f32> {
let front = (theta_deg + card_facing_flip(facing)).to_radians().cos();
if front > EDGE_ON_EPSILON {
Some(front)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Carousel3DConfig {
pub cards: Vec<String>,
pub radius: f32,
pub card_width: f32,
pub card_height: Option<f32>,
pub revolution_frames: usize,
pub spin_offset: f32,
pub pose: CarouselPose,
pub perspective: f32,
pub card_facing: CardFacing,
pub card_fill: String,
pub card_text: String,
}
impl Default for Carousel3DConfig {
fn default() -> Self {
Self {
cards: (1..=8).map(|n| n.to_string()).collect(),
radius: 60.0,
card_width: 40.0,
card_height: None,
revolution_frames: 300,
spin_offset: 0.0,
pose: CarouselPose::default(),
perspective: 1000.0,
card_facing: CardFacing::default(),
card_fill: "#22303f".to_owned(),
card_text: "#e6edf3".to_owned(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Carousel3D {
pub config: Carousel3DConfig,
}
impl Carousel3D {
pub fn new(config: Carousel3DConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "carousel-3d",
name: "Carousel 3D",
summary: "A ring of cards turns through a perspective camera, projected to 2.5D.",
feature: "carousel-3d",
}
}
}
fn em_center_to_baseline(ctx: &RenderCtx<'_>, family: &str, size: f32) -> f32 {
match ctx.meter(family) {
Some(meter) => (meter.ascent(size) - meter.descent(size)) / 2.0,
None => size * 0.25,
}
}
impl Component for Carousel3D {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.revolution_frames.max(1)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let count = config.cards.len();
if count == 0 {
return Svgr::empty();
}
let family = ctx.sans();
let vmin = ctx.vmin();
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let spin = spin_deg(ctx.frame(), config.spin_offset, config.revolution_frames);
let camera = pose_camera_inverse(&config.pose);
let width = vmin * config.card_width;
let height = vmin * config.card_height.unwrap_or(config.card_width);
let label_size = vmin * CARD_FONT_SIZE_VMIN;
let label_mid = em_center_to_baseline(ctx, &family, label_size);
let mut projected: Vec<(f32, usize, f32, [f32; 3])> = Vec::with_capacity(count);
for index in 0..count {
let theta = card_theta_deg(index, count, spin);
let (px, pz) = card_offset(theta, vmin, config.radius);
let cam = camera.apply([px, 0.0, pz]);
projected.push((pz, index, theta, cam));
}
projected.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut nodes: Vec<Svgr<'_>> = Vec::with_capacity(count);
for (_, index, theta, cam) in projected {
let Some(scale) = perspective_scale(cam[2], config.perspective) else {
continue;
};
let Some(squash) = card_squash(theta, config.card_facing) else {
continue;
};
let x = center_x + cam[0] * scale;
let y = center_y + cam[1] * scale;
let transform = format!(
"translate({x:.4} {y:.4}) scale({:.6} {scale:.6})",
scale * squash,
);
let rect = fframes::svgr!(
<rect x={-width / 2.0} y={-height / 2.0} width={width} height={height}
rx={vmin} fill={config.card_fill.clone()} />
);
let label = text_node_centered(
config.cards.get(index).map_or("", String::as_str),
0.0,
label_mid,
family.clone(),
label_size,
CARD_FONT_WEIGHT,
0.0,
config.card_text.clone(),
1.0,
);
nodes.push(fframes::svgr!(<g transform={transform}>{rect}{label}</g>));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f32, b: f32, tolerance: f32) -> bool {
(a - b).abs() <= tolerance
}
#[test]
fn spin_turns_a_full_revolution_per_period() {
assert!(close(spin_deg(0.0, 0.0, 300), 0.0, 1e-6));
assert!(close(spin_deg(75.0, 0.0, 300), 90.0, 1e-5));
assert!(close(spin_deg(300.0, 0.0, 300), 360.0, 1e-4));
assert!(close(spin_deg(0.0, 45.0, 300), 45.0, 1e-6));
assert!(close(spin_deg(1.0, 0.0, 0), 360.0, 1e-4));
}
#[test]
fn ring_places_cards_evenly_with_x_from_sin_and_z_from_cos() {
let count = 8;
let theta = card_theta_deg(0, count, 0.0);
assert!(close(theta, 0.0, 1e-6));
let (x, z) = card_offset(theta, 10.0, 60.0);
assert!(close(x, 0.0, 1e-4));
assert!(close(z, 600.0, 1e-3));
let theta = card_theta_deg(2, count, 0.0);
assert!(close(theta, 90.0, 1e-5));
let (x, z) = card_offset(theta, 10.0, 60.0);
assert!(close(x, 600.0, 1e-3), "x was {x}");
assert!(close(z, 0.0, 1e-3));
let theta = card_theta_deg(4, count, 0.0);
let (_, z) = card_offset(theta, 10.0, 60.0);
assert!(close(z, -600.0, 1e-3));
assert!(close(card_theta_deg(0, 0, 12.0), 12.0, 1e-6));
}
#[test]
fn camera_inverse_is_the_identity_for_a_zero_pose() {
let camera = pose_camera_inverse(&CarouselPose::default());
assert_eq!(
camera.rotation(),
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
);
let point = camera.apply([3.0, -4.0, 5.0]);
assert!(close(point[0], 3.0, 1e-6));
assert!(close(point[1], -4.0, 1e-6));
assert!(close(point[2], 5.0, 1e-6));
}
#[test]
fn camera_inverse_rolls_and_translates_the_world() {
let camera = pose_camera_inverse(&CarouselPose {
rotate_z: 180.0,
..CarouselPose::default()
});
let point = camera.apply([100.0, 0.0, 0.0]);
assert!(close(point[0], -100.0, 1e-3), "x was {}", point[0]);
assert!(close(point[2], 0.0, 1e-3));
let camera = pose_camera_inverse(&CarouselPose {
rotate_y: 90.0,
..CarouselPose::default()
});
let point = camera.apply([100.0, 0.0, 0.0]);
assert!(close(point[2], 100.0, 1e-3), "z was {}", point[2]);
let camera = pose_camera_inverse(&CarouselPose {
z: 100.0,
..CarouselPose::default()
});
let point = camera.apply([0.0, 0.0, 0.0]);
assert!(close(point[2], -100.0, 1e-4), "z was {}", point[2]);
}
#[test]
fn perspective_divide_scales_by_depth() {
assert!(close(
perspective_scale(0.0, 1000.0).expect("in front"),
1.0,
1e-6
));
assert!(close(
perspective_scale(500.0, 1000.0).expect("in front"),
2.0,
1e-6
));
assert!(close(
perspective_scale(-1000.0, 1000.0).expect("in front"),
0.5,
1e-6
));
assert!(perspective_scale(1000.0, 1000.0).is_none());
assert!(perspective_scale(1500.0, 1000.0).is_none());
assert!(close(
perspective_scale(999.999, 1000.0).expect("in front"),
MAX_PERSPECTIVE_SCALE,
1e-4
));
assert!(close(
perspective_scale(-1.0e9, 1000.0).expect("in front"),
MIN_PERSPECTIVE_SCALE,
1e-6
));
}
#[test]
fn facing_decides_which_half_of_the_ring_shows_a_face() {
assert!(close(
card_squash(0.0, CardFacing::Outward).expect("visible"),
1.0,
1e-6
));
assert!(close(
card_squash(60.0, CardFacing::Outward).expect("visible"),
0.5,
1e-5
));
assert!(card_squash(90.0, CardFacing::Outward).is_none());
assert!(card_squash(270.0, CardFacing::Outward).is_none());
assert!(card_squash(180.0, CardFacing::Outward).is_none());
assert!(card_squash(-120.0, CardFacing::Outward).is_none());
assert!(card_squash(0.0, CardFacing::Inward).is_none());
assert!(card_squash(180.0, CardFacing::Inward).is_some());
assert!(close(
card_squash(120.0, CardFacing::Inward).expect("visible"),
0.5,
1e-5
));
assert!(close(card_facing_flip(CardFacing::Inward), 180.0, 0.0));
assert!(close(card_facing_flip(CardFacing::Outward), 0.0, 0.0));
}
#[test]
fn card_size_comes_from_vmin() {
let vmin = 1080.0_f32.min(1920.0) / 100.0;
assert!(close(vmin * 40.0, 432.0, 1e-4));
assert!(close(vmin * 60.0, 648.0, 1e-4));
let config = Carousel3DConfig {
card_height: Some(20.0),
..Carousel3DConfig::default()
};
assert!(close(
vmin * config.card_height.unwrap_or(config.card_width),
216.0,
1e-4
));
}
#[test]
fn component_duration_is_one_revolution() {
let component = Carousel3D::default();
assert_eq!(component.duration(), 300);
assert_eq!(component.meta().id, "carousel-3d");
assert_eq!(component.config.cards.len(), 8);
assert_eq!(component.config.cards[0], "1");
assert_eq!(component.config.cards[7], "8");
let short = Carousel3D::new(Carousel3DConfig {
revolution_frames: 0,
..Carousel3DConfig::default()
});
assert_eq!(short.duration(), 1);
}
}