use crate::vpx::VPX;
use crate::vpx::gamedata::ViewLayoutMode;
use crate::vpx::units::vpu_to_m;
use serde_json::json;
const FIT_CAMERA_DISTANCE_SCALE: f32 = 0.47;
#[derive(Debug, Clone, Copy)]
struct FittedCamera {
#[allow(dead_code)]
x: f32,
#[allow(dead_code)]
y: f32,
z: f32,
}
#[allow(clippy::too_many_arguments)]
fn fit_camera_to_vertices(
bounds: &TableBounds,
aspect: f32,
rotation: f32,
inclination: f32,
fov: f32,
xlatez: f32,
layback: f32,
table_height_z: f32,
) -> FittedCamera {
let rrotsin = rotation.sin();
let rrotcos = rotation.cos();
let rincsin = inclination.sin();
let rinccos = inclination.cos();
let slopey = (0.5 * fov.to_radians()).tan();
let slopex = slopey * aspect;
let mut maxyintercept = f32::NEG_INFINITY;
let mut minyintercept = f32::INFINITY;
let mut maxxintercept = f32::NEG_INFINITY;
let mut minxintercept = f32::INFINITY;
let layback_tan = -(0.5 * layback.to_radians()).tan();
let corners = [
(bounds.left, bounds.top, 0.0_f32),
(bounds.right, bounds.top, 0.0),
(bounds.left, bounds.bottom, 0.0),
(bounds.right, bounds.bottom, 0.0),
(bounds.left, bounds.top, table_height_z),
(bounds.right, bounds.top, table_height_z),
(bounds.left, bounds.bottom, table_height_z),
(bounds.right, bounds.bottom, table_height_z),
];
for (vx, vy, vz) in corners {
let vy = vy + layback_tan * vz;
let temp = vy;
let vy = rinccos * temp - rincsin * vz;
let vz = rincsin * temp + rinccos * vz;
let temp = vx;
let vx = rrotcos * temp - rrotsin * vy;
let vy = rrotsin * temp + rrotcos * vy;
maxyintercept = maxyintercept.max(vy + slopey * vz);
minyintercept = minyintercept.min(vy - slopey * vz);
maxxintercept = maxxintercept.max(vx + slopex * vz);
minxintercept = minxintercept.min(vx - slopex * vz);
}
let ydist = (maxyintercept - minyintercept) / (slopey * 2.0);
let xdist = (maxxintercept - minxintercept) / (slopex * 2.0);
FittedCamera {
x: (maxxintercept + minxintercept) * 0.5,
y: (maxyintercept + minyintercept) * 0.5,
z: ydist.max(xdist) + xlatez,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewMode {
Desktop,
Fullscreen,
Fss,
}
impl ViewMode {
pub fn camera_name(&self) -> &'static str {
match self {
ViewMode::Desktop => "DesktopCamera",
ViewMode::Fullscreen => "FullscreenCamera",
ViewMode::Fss => "FssCamera",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ViewSettings {
pub mode: ViewMode,
pub layout_mode: ViewLayoutMode,
pub fov: f32,
pub inclination: f32,
pub offset_x: f32,
pub offset_y: f32,
pub offset_z: f32,
#[allow(dead_code)]
pub scale_x: f32,
#[allow(dead_code)]
pub scale_y: f32,
#[allow(dead_code)]
pub scale_z: f32,
}
impl ViewSettings {
pub fn desktop_from_vpx(vpx: &VPX) -> Self {
Self {
mode: ViewMode::Desktop,
layout_mode: vpx
.gamedata
.bg_view_mode_desktop
.unwrap_or(ViewLayoutMode::Legacy),
fov: vpx.gamedata.bg_fov_desktop.max(1.0),
inclination: vpx.gamedata.bg_inclination_desktop,
offset_x: vpx.gamedata.bg_offset_x_desktop,
offset_y: vpx.gamedata.bg_offset_y_desktop,
offset_z: vpx.gamedata.bg_offset_z_desktop,
scale_x: vpx.gamedata.bg_scale_x_desktop,
scale_y: vpx.gamedata.bg_scale_y_desktop,
scale_z: vpx.gamedata.bg_scale_z_desktop,
}
}
pub fn fullscreen_from_vpx(vpx: &VPX) -> Self {
Self {
mode: ViewMode::Fullscreen,
layout_mode: vpx
.gamedata
.bg_view_mode_fullscreen
.unwrap_or(ViewLayoutMode::Legacy),
fov: vpx.gamedata.bg_fov_fullscreen.max(1.0),
inclination: vpx.gamedata.bg_inclination_fullscreen,
offset_x: vpx.gamedata.bg_offset_x_fullscreen,
offset_y: vpx.gamedata.bg_offset_y_fullscreen,
offset_z: vpx.gamedata.bg_offset_z_fullscreen,
scale_x: vpx.gamedata.bg_scale_x_fullscreen,
scale_y: vpx.gamedata.bg_scale_y_fullscreen,
scale_z: vpx.gamedata.bg_scale_z_fullscreen,
}
}
pub fn fss_from_vpx(vpx: &VPX) -> Self {
Self {
mode: ViewMode::Fss,
layout_mode: vpx
.gamedata
.bg_view_mode_full_single_screen
.unwrap_or(ViewLayoutMode::Legacy),
fov: vpx
.gamedata
.bg_fov_full_single_screen
.unwrap_or(45.0)
.max(1.0),
inclination: vpx
.gamedata
.bg_inclination_full_single_screen
.unwrap_or(52.0),
offset_x: vpx.gamedata.bg_offset_x_full_single_screen.unwrap_or(0.0),
offset_y: vpx.gamedata.bg_offset_y_full_single_screen.unwrap_or(30.0),
offset_z: vpx.gamedata.bg_offset_z_full_single_screen.unwrap_or(-50.0),
scale_x: vpx.gamedata.bg_scale_x_full_single_screen.unwrap_or(1.2),
scale_y: vpx.gamedata.bg_scale_y_full_single_screen.unwrap_or(1.1),
scale_z: vpx.gamedata.bg_scale_z_full_single_screen.unwrap_or(1.0),
}
}
pub fn all_from_vpx(vpx: &VPX) -> [Self; 3] {
[
Self::desktop_from_vpx(vpx),
Self::fullscreen_from_vpx(vpx),
Self::fss_from_vpx(vpx),
]
}
}
pub(crate) type FssViewSettings = ViewSettings;
#[derive(Debug, Clone, Copy)]
pub(crate) struct TableBounds {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
pub glass_height: f32,
}
impl TableBounds {
pub fn from_vpx(vpx: &VPX) -> Self {
Self {
left: vpx.gamedata.left,
top: vpx.gamedata.top,
right: vpx.gamedata.right,
bottom: vpx.gamedata.bottom,
glass_height: vpx.gamedata.glass_top_height,
}
}
#[allow(dead_code)]
pub fn width(&self) -> f32 {
self.right - self.left
}
#[allow(dead_code)]
pub fn height(&self) -> f32 {
self.bottom - self.top
}
pub fn center_x(&self) -> f32 {
(self.left + self.right) / 2.0
}
#[allow(dead_code)]
pub fn center_y(&self) -> f32 {
(self.top + self.bottom) / 2.0
}
}
#[derive(Debug, Clone)]
pub(crate) struct GltfCamera {
pub mode: ViewMode,
pub position: [f32; 3],
pub rotation: [f32; 4],
pub yfov: f32,
pub znear: f32,
pub zfar: f32,
}
impl GltfCamera {
pub fn from_view_settings(settings: &ViewSettings, bounds: &TableBounds) -> Self {
let fov_rad = settings.fov.to_radians();
let (camera_position, rotation) = match settings.layout_mode {
ViewLayoutMode::Legacy => {
let look_at_fraction = settings.inclination / 100.0;
let pitch_degrees = 90.0 * (1.0 - look_at_fraction);
let pitch_rad = pitch_degrees.to_radians();
let aspect = 16.0 / 9.0;
let rotation_rad = 0.0_f32;
let layback = 0.0;
let fit = fit_camera_to_vertices(
bounds,
aspect,
rotation_rad,
pitch_rad,
settings.fov,
0.0, layback,
bounds.glass_height,
);
let scene_scale = (settings.scale_x + settings.scale_y) / 2.0;
let base_distance = fit.z * FIT_CAMERA_DISTANCE_SCALE * scene_scale;
let look_at_x = bounds.center_x();
let look_at_y = bounds.center_y();
let look_at_z = 0.0;
let cam_base_x = look_at_x;
let cam_base_y = look_at_y + base_distance * pitch_rad.cos();
let cam_base_z = look_at_z + base_distance * pitch_rad.sin();
let world_offset_x = settings.offset_x;
let screen_up_y = pitch_rad.sin();
let screen_up_z = pitch_rad.cos();
let world_offset_y_from_screen_y = settings.offset_y * screen_up_y;
let world_offset_z_from_screen_y = settings.offset_y * screen_up_z;
let view_dir_y = pitch_rad.cos();
let view_dir_z = pitch_rad.sin();
let world_offset_y_from_view = settings.offset_z * view_dir_y;
let world_offset_z_from_view = settings.offset_z * view_dir_z;
let vpx_x = cam_base_x + world_offset_x;
let vpx_y = cam_base_y + world_offset_y_from_screen_y + world_offset_y_from_view;
let vpx_z = cam_base_z + world_offset_z_from_screen_y + world_offset_z_from_view;
#[cfg(test)]
{
println!("Legacy mode calculation:");
println!(
" inclination: {}% -> pitch: {:.1}°",
settings.inclination, pitch_degrees
);
println!(" fit.z: {}, base_distance: {}", fit.z, base_distance);
println!(
" cam_base: ({}, {}, {})",
cam_base_x, cam_base_y, cam_base_z
);
println!(
" offsets (screen): x={}, y={}, z={}",
settings.offset_x, settings.offset_y, settings.offset_z
);
println!(" final vpx: ({}, {}, {})", vpx_x, vpx_y, vpx_z);
println!(
" final meters: x={:.3}, y(height)={:.3}, z(depth)={:.3}",
vpu_to_m(vpx_x),
vpu_to_m(vpx_z),
vpu_to_m(vpx_y)
);
}
let camera_x = vpu_to_m(vpx_x);
let camera_y = vpu_to_m(vpx_z); let camera_z = vpu_to_m(vpx_y);
let position = [camera_x, camera_y, camera_z];
let rotation = Self::euler_to_quaternion_yxz(0.0, -pitch_rad);
(position, rotation)
}
ViewLayoutMode::Camera | ViewLayoutMode::Window => {
let look_at_fraction = settings.inclination / 100.0;
let pitch_degrees = 90.0 * (1.0 - look_at_fraction);
let pitch_rad = pitch_degrees.to_radians();
let aspect = 16.0 / 9.0;
let fit = fit_camera_to_vertices(
bounds,
aspect,
0.0, pitch_rad, settings.fov,
0.0, 0.0, bounds.glass_height,
);
let base_distance = fit.z * FIT_CAMERA_DISTANCE_SCALE;
let look_at_x = bounds.center_x();
let look_at_y = bounds.center_y();
let look_at_z = 0.0;
let cam_base_x = look_at_x;
let cam_base_y = look_at_y + base_distance * pitch_rad.cos();
let cam_base_z = look_at_z + base_distance * pitch_rad.sin();
let offset_along_view_y = settings.offset_y * pitch_rad.cos();
let offset_along_view_z = settings.offset_y * pitch_rad.sin();
let vpx_x = cam_base_x + settings.offset_x;
let vpx_y = cam_base_y + offset_along_view_y;
let vpx_z = cam_base_z + offset_along_view_z + settings.offset_z;
#[cfg(test)]
{
println!("Camera mode calculation:");
println!(
" look_at_fraction: {}, pitch_degrees: {}",
look_at_fraction, pitch_degrees
);
println!(" fit.z: {}, base_distance: {}", fit.z, base_distance);
println!(" look_at: ({}, {}, {})", look_at_x, look_at_y, look_at_z);
println!(
" cam_base: ({}, {}, {})",
cam_base_x, cam_base_y, cam_base_z
);
println!(
" offsets: x={}, y={}, z={}",
settings.offset_x, settings.offset_y, settings.offset_z
);
println!(
" offset_along_view: y={}, z={}",
offset_along_view_y, offset_along_view_z
);
println!(" final vpx: ({}, {}, {})", vpx_x, vpx_y, vpx_z);
}
let camera_x = vpu_to_m(vpx_x);
let camera_y = vpu_to_m(vpx_z); let camera_z = vpu_to_m(vpx_y);
let position = [camera_x, camera_y, camera_z];
let rotation = Self::euler_to_quaternion_yxz(0.0, -pitch_rad);
(position, rotation)
}
};
Self {
mode: settings.mode,
position: camera_position,
rotation,
yfov: fov_rad,
znear: 0.01,
zfar: 100.0,
}
}
#[allow(dead_code)]
pub fn from_fss_settings(settings: &FssViewSettings, bounds: &TableBounds) -> Self {
Self::from_view_settings(settings, bounds)
}
pub fn all_from_vpx(vpx: &VPX) -> [Self; 3] {
let bounds = TableBounds::from_vpx(vpx);
let settings = ViewSettings::all_from_vpx(vpx);
[
Self::from_view_settings(&settings[0], &bounds),
Self::from_view_settings(&settings[1], &bounds),
Self::from_view_settings(&settings[2], &bounds),
]
}
fn euler_to_quaternion_yxz(yaw: f32, pitch: f32) -> [f32; 4] {
let half_yaw = yaw / 2.0;
let half_pitch = pitch / 2.0;
let cy = half_yaw.cos();
let sy = half_yaw.sin();
let cp = half_pitch.cos();
let sp = half_pitch.sin();
[
cy * sp, sy * cp, -sy * sp, cy * cp, ]
}
pub fn to_gltf_camera_json(&self) -> serde_json::Value {
json!({
"name": self.mode.camera_name(),
"type": "perspective",
"perspective": {
"yfov": self.yfov,
"znear": self.znear,
"zfar": self.zfar
}
})
}
pub fn to_gltf_node_json(&self, camera_index: usize) -> serde_json::Value {
json!({
"name": self.mode.camera_name(),
"camera": camera_index,
"translation": self.position,
"rotation": self.rotation
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_default_bounds() -> TableBounds {
TableBounds {
left: 0.0,
top: 0.0,
right: 952.0,
bottom: 2162.0,
glass_height: 300.0, }
}
fn create_fss_settings() -> ViewSettings {
ViewSettings {
mode: ViewMode::Fss,
layout_mode: ViewLayoutMode::Legacy,
fov: 45.0,
inclination: 52.0,
offset_x: 0.0,
offset_y: 30.0,
offset_z: -50.0,
scale_x: 1.2,
scale_y: 1.1,
scale_z: 1.0,
}
}
fn create_desktop_settings() -> ViewSettings {
ViewSettings {
mode: ViewMode::Desktop,
layout_mode: ViewLayoutMode::Legacy,
fov: 45.0,
inclination: 0.0,
offset_x: 0.0,
offset_y: 30.0,
offset_z: -200.0,
scale_x: 1.0,
scale_y: 1.0,
scale_z: 1.0,
}
}
fn create_fullscreen_settings() -> ViewSettings {
ViewSettings {
mode: ViewMode::Fullscreen,
layout_mode: ViewLayoutMode::Legacy,
fov: 45.0,
inclination: 0.0,
offset_x: 110.0,
offset_y: -86.0,
offset_z: 400.0,
scale_x: 1.3,
scale_y: 1.41,
scale_z: 1.0,
}
}
#[test]
fn test_camera_creation_does_not_panic() {
let bounds = create_default_bounds();
let _desktop = GltfCamera::from_view_settings(&create_desktop_settings(), &bounds);
let _fullscreen = GltfCamera::from_view_settings(&create_fullscreen_settings(), &bounds);
let _fss = GltfCamera::from_view_settings(&create_fss_settings(), &bounds);
}
#[test]
fn test_camera_quaternion_is_normalized() {
let bounds = create_default_bounds();
let settings = create_fss_settings();
let camera = GltfCamera::from_view_settings(&settings, &bounds);
let qx = camera.rotation[0];
let qy = camera.rotation[1];
let qz = camera.rotation[2];
let qw = camera.rotation[3];
let qlen = (qx * qx + qy * qy + qz * qz + qw * qw).sqrt();
assert!(
(qlen - 1.0).abs() < 0.001,
"Quaternion should be normalized. Length = {}",
qlen
);
}
#[test]
fn test_three_cameras_have_different_modes() {
let bounds = create_default_bounds();
let desktop = GltfCamera::from_view_settings(&create_desktop_settings(), &bounds);
let fullscreen = GltfCamera::from_view_settings(&create_fullscreen_settings(), &bounds);
let fss = GltfCamera::from_view_settings(&create_fss_settings(), &bounds);
assert_eq!(desktop.mode, ViewMode::Desktop);
assert_eq!(fullscreen.mode, ViewMode::Fullscreen);
assert_eq!(fss.mode, ViewMode::Fss);
}
#[test]
fn test_camera_names() {
assert_eq!(ViewMode::Desktop.camera_name(), "DesktopCamera");
assert_eq!(ViewMode::Fullscreen.camera_name(), "FullscreenCamera");
assert_eq!(ViewMode::Fss.camera_name(), "FssCamera");
}
#[test]
fn test_fit_camera_to_vertices_no_inclination() {
let bounds = create_default_bounds();
let aspect = 16.0 / 9.0;
let rotation = 0.0;
let inclination = 0.0; let fov = 45.0; let xlatez = 0.0;
let layback = 0.0;
let table_height_z = bounds.glass_height;
let fit = fit_camera_to_vertices(
&bounds,
aspect,
rotation,
inclination,
fov,
xlatez,
layback,
table_height_z,
);
assert!(
fit.z > 0.0,
"Camera distance should be positive. Got z={}",
fit.z
);
assert!(
(fit.x - 476.0).abs() < 1.0,
"Camera X should be near table center (476). Got x={}",
fit.x
);
assert!(
(fit.y - 1081.0).abs() < 1.0,
"Camera Y should be near table center (1081). Got y={}",
fit.y
);
}
#[test]
fn test_fit_camera_to_vertices_with_inclination() {
let bounds = create_default_bounds();
let aspect = 16.0 / 9.0;
let rotation = 0.0;
let inclination = 56.0_f32.to_radians();
let fov = 39.0; let xlatez = 0.0;
let layback = 0.0;
let table_height_z = bounds.glass_height;
let fit = fit_camera_to_vertices(
&bounds,
aspect,
rotation,
inclination,
fov,
xlatez,
layback,
table_height_z,
);
assert!(
fit.z > 0.0,
"Camera distance should be positive. Got z={}",
fit.z
);
let table_height = bounds.bottom - bounds.top; assert!(
fit.z > table_height * 0.5,
"Camera distance should be significant. Got z={}, table_height={}",
fit.z,
table_height
);
}
#[test]
fn test_fit_camera_to_vertices_xlatez_offset() {
let bounds = create_default_bounds();
let aspect = 16.0 / 9.0;
let rotation = 0.0;
let inclination = 0.0;
let fov = 45.0;
let layback = 0.0;
let table_height_z = bounds.glass_height;
let fit_no_offset = fit_camera_to_vertices(
&bounds,
aspect,
rotation,
inclination,
fov,
0.0,
layback,
table_height_z,
);
let fit_with_offset = fit_camera_to_vertices(
&bounds,
aspect,
rotation,
inclination,
fov,
100.0,
layback,
table_height_z,
);
let z_diff = fit_with_offset.z - fit_no_offset.z;
assert!(
(z_diff - 100.0).abs() < 0.001,
"xlatez should be added to z. Expected diff=100, got diff={}",
z_diff
);
}
#[test]
fn test_fit_camera_to_vertices_different_fov() {
let bounds = create_default_bounds();
let aspect = 16.0 / 9.0;
let rotation = 0.0;
let inclination = 0.0;
let xlatez = 0.0;
let layback = 0.0;
let table_height_z = bounds.glass_height;
let fit_narrow = fit_camera_to_vertices(
&bounds,
aspect,
rotation,
inclination,
30.0,
xlatez,
layback,
table_height_z,
);
let fit_wide = fit_camera_to_vertices(
&bounds,
aspect,
rotation,
inclination,
60.0,
xlatez,
layback,
table_height_z,
);
assert!(
fit_wide.z < fit_narrow.z,
"Wider FOV should result in closer camera. narrow_z={}, wide_z={}",
fit_narrow.z,
fit_wide.z
);
}
#[test]
fn test_desktop_camera_position_matches_vpinball() {
let bounds = TableBounds {
left: 0.0,
top: 0.0,
right: 952.0,
bottom: 2162.0,
glass_height: 300.0,
};
let settings = ViewSettings {
mode: ViewMode::Desktop,
layout_mode: ViewLayoutMode::Legacy,
fov: 39.0,
inclination: 56.0, offset_x: 0.0,
offset_y: 99.0, offset_z: 0.0, scale_x: 1.24,
scale_y: 1.24,
scale_z: 1.0,
};
let camera = GltfCamera::from_view_settings(&settings, &bounds);
println!(
"Camera position: x={}, y={}, z={}",
camera.position[0], camera.position[1], camera.position[2]
);
println!(
"Camera rotation: x={}, y={}, z={}, w={}",
camera.rotation[0], camera.rotation[1], camera.rotation[2], camera.rotation[3]
);
let expected_x = 0.257;
let expected_y = 0.72; let expected_z = 1.52;
assert!(
(camera.position[0] - expected_x).abs() < 0.05,
"Camera X should be ~{}m. Got {}m",
expected_x,
camera.position[0]
);
assert!(
(camera.position[1] - expected_y).abs() < 0.2,
"Camera Y (height) should be ~{}m. Got {}m",
expected_y,
camera.position[1]
);
assert!(
(camera.position[2] - expected_z).abs() < 0.35,
"Camera Z (depth) should be ~{}m. Got {}m",
expected_z,
camera.position[2]
);
}
}