use crate::filesystem::FileSystem;
use crate::gltf::GltfMaterialBuilder;
use crate::vpx;
use crate::vpx::gamedata::GameDataJson;
use crate::vpx::gameitem::GameItemEnum;
use crate::vpx::gameitem::light::Light;
use crate::vpx::gameitem::primitive::VertexWrapper;
use crate::vpx::gameitem::select::HasSharedAttributes;
use crate::vpx::gltf::{
GLB_BIN_CHUNK_TYPE, GLB_CHUNK_HEADER_BYTES, GLB_HEADER_BYTES, GLB_JSON_CHUNK_TYPE,
GLTF_COMPONENT_TYPE_FLOAT, GLTF_COMPONENT_TYPE_UNSIGNED_INT,
GLTF_COMPONENT_TYPE_UNSIGNED_SHORT, GLTF_FILTER_LINEAR, GLTF_FILTER_LINEAR_MIPMAP_LINEAR,
GLTF_MAGIC, GLTF_PRIMITIVE_MODE_TRIANGLES, GLTF_TARGET_ARRAY_BUFFER,
GLTF_TARGET_ELEMENT_ARRAY_BUFFER, GLTF_VERSION, GLTF_WRAP_REPEAT,
};
use crate::vpx::image::{ImageData, image_has_transparency};
use crate::vpx::material::MaterialType;
use crate::vpx::math::Vec3;
use crate::vpx::mesh::balls::build_ball_mesh;
use crate::vpx::mesh::bumpers::build_bumper_meshes;
use crate::vpx::mesh::flashers::build_flasher_mesh;
use crate::vpx::mesh::flippers::build_flipper_meshes;
use crate::vpx::mesh::gates::build_gate_meshes;
use crate::vpx::mesh::hittargets::build_hit_target_mesh;
use crate::vpx::mesh::kickers::build_kicker_meshes;
use crate::vpx::mesh::lights::{build_light_insert_mesh, build_light_meshes};
use crate::vpx::mesh::playfields::build_playfield_mesh;
use crate::vpx::mesh::plungers::build_plunger_meshes;
use crate::vpx::mesh::ramps::build_ramp_mesh;
use crate::vpx::mesh::rubbers::build_rubber_mesh;
use crate::vpx::mesh::spinners::build_spinner_meshes;
use crate::vpx::mesh::triggers::build_trigger_mesh;
use crate::vpx::mesh::walls::build_wall_meshes;
use crate::vpx::obj::VpxFace;
use crate::vpx::units::{mm_to_vpu, vpu_to_m};
use crate::vpx::{TableDimensions, VPX};
use byteorder::{LittleEndian, WriteBytesExt};
use log::{info, warn};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::io;
use std::path::Path;
use vpx::mesh::decals::build_decal_mesh;
const PLAYFIELD_MATERIAL_NAME: &str = "__playfield__";
fn mime_type_for_image(image: &ImageData) -> &'static str {
if image.bits.is_some() {
return "image/png";
}
let path_lower = image.path.to_lowercase();
if path_lower.ends_with(".png") {
"image/png"
} else if path_lower.ends_with(".jpg") || path_lower.ends_with(".jpeg") {
"image/jpeg"
} else if path_lower.ends_with(".webp") {
"image/webp"
} else if path_lower.ends_with(".gif") {
"image/gif"
} else if path_lower.ends_with(".hdr") {
"image/vnd.radiance"
} else if path_lower.ends_with(".exr") {
"image/x-exr"
} else {
"image/jpeg"
}
}
struct NamedMesh {
name: String,
vertices: Vec<VertexWrapper>,
indices: Vec<VpxFace>,
material_name: Option<String>,
texture_name: Option<String>,
color_tint: Option<[f32; 4]>,
layer_name: Option<String>,
transmission_factor: Option<f32>,
is_ball: bool,
roughness_texture_name: Option<String>,
translation: Option<Vec3>,
visible: bool,
group_name: Option<String>,
}
struct ItemGroupInfo {
name: String,
layer_name: Option<String>,
extras: serde_json::Value,
}
fn item_group_info_for(item: &(impl HasSharedAttributes + serde::Serialize)) -> ItemGroupInfo {
let layer_name = get_layer_name(
&item.editor_layer_name().map(String::from),
item.editor_layer(),
);
let extras = serde_json::to_value(item).unwrap_or(json!({}));
ItemGroupInfo {
name: item.name().to_string(),
layer_name,
extras,
}
}
impl Default for NamedMesh {
fn default() -> Self {
Self {
name: String::new(),
vertices: Vec::new(),
indices: Vec::new(),
material_name: None,
texture_name: None,
color_tint: None,
layer_name: None,
transmission_factor: None,
is_ball: false,
roughness_texture_name: None,
translation: None,
visible: true,
group_name: None,
}
}
}
impl NamedMesh {
fn is_playfield(&self) -> bool {
self.name.eq_ignore_ascii_case("playfield_mesh")
}
}
use super::camera::GltfCamera;
fn transform_primitive_vertices(
vertices: Vec<VertexWrapper>,
primitive: &crate::vpx::gameitem::primitive::Primitive,
) -> (Vec<VertexWrapper>, Vec3) {
use crate::vpx::math::{Matrix3D, Vertex3D};
let pos = &primitive.position;
let size = &primitive.size;
let rot = &primitive.rot_and_tra;
let rt_matrix = Matrix3D::translate(rot[3], rot[4], rot[5])
* Matrix3D::rotate_z(rot[2].to_radians())
* Matrix3D::rotate_y(rot[1].to_radians())
* Matrix3D::rotate_x(rot[0].to_radians());
let rt_matrix = rt_matrix
* Matrix3D::rotate_z(rot[8].to_radians())
* Matrix3D::rotate_y(rot[7].to_radians())
* Matrix3D::rotate_x(rot[6].to_radians());
let local_matrix = Matrix3D::scale(size.x, size.y, size.z) * rt_matrix;
let transformed_vertices = vertices
.into_iter()
.map(|mut vw| {
let v = Vertex3D::new(vw.vertex.x, vw.vertex.y, vw.vertex.z);
let transformed = local_matrix.transform_vertex(v);
vw.vertex.x = transformed.x;
vw.vertex.y = transformed.y;
vw.vertex.z = transformed.z;
let nx = vw.vertex.nx;
let ny = vw.vertex.ny;
let nz = vw.vertex.nz;
if !nx.is_nan() && !ny.is_nan() && !nz.is_nan() {
let normal = local_matrix.transform_normal(nx, ny, nz);
let len = normal.length();
if len > 0.0 {
vw.vertex.nx = normal.x / len;
vw.vertex.ny = normal.y / len;
vw.vertex.nz = normal.z / len;
}
}
vw
})
.collect();
let translation = Vec3::new(vpu_to_m(pos.x), vpu_to_m(pos.z), vpu_to_m(pos.y));
(transformed_vertices, translation)
}
struct GltfMaterial {
name: String,
base_color: [f32; 4], metallic: f32,
roughness: f32,
opacity_active: bool,
}
fn collect_materials(vpx: &VPX) -> HashMap<String, GltfMaterial> {
let mut materials = HashMap::new();
if let Some(ref mats) = vpx.gamedata.materials {
for mat in mats {
let gltf_mat = GltfMaterial {
name: mat.name.clone(),
base_color: [
mat.base_color.r as f32 / 255.0,
mat.base_color.g as f32 / 255.0,
mat.base_color.b as f32 / 255.0,
if mat.opacity_active { mat.opacity } else { 1.0 },
],
metallic: if mat.type_ == MaterialType::Metal {
1.0
} else {
0.0
},
roughness: 1.0 - mat.roughness,
opacity_active: mat.opacity_active,
};
materials.insert(mat.name.clone(), gltf_mat);
}
} else {
for mat in &vpx.gamedata.materials_old {
let opacity_active = (mat.opacity_active_edge_alpha & 1) != 0;
let gltf_mat = GltfMaterial {
name: mat.name.clone(),
base_color: [
mat.base_color.r as f32 / 255.0,
mat.base_color.g as f32 / 255.0,
mat.base_color.b as f32 / 255.0,
if opacity_active { mat.opacity } else { 1.0 },
],
metallic: if mat.is_metal { 1.0 } else { 0.0 },
roughness: 1.0 - mat.roughness,
opacity_active,
};
materials.insert(mat.name.clone(), gltf_mat);
}
}
materials
}
fn find_image_by_name<'a>(images: &'a [ImageData], name: &str) -> Option<&'a ImageData> {
if name.is_empty() {
return None;
}
images
.iter()
.find(|img| img.name.eq_ignore_ascii_case(name))
}
fn get_surface_height(vpx: &VPX, surface_name: &str, x: f32, y: f32) -> f32 {
if surface_name.is_empty() {
return 0.0;
}
for item in &vpx.gameitems {
match item {
GameItemEnum::Wall(wall) => {
if wall.name.eq_ignore_ascii_case(surface_name) {
return wall.height_top;
}
}
GameItemEnum::Ramp(ramp) => {
if ramp.name.eq_ignore_ascii_case(surface_name) {
return crate::vpx::mesh::ramps::get_ramp_surface_height(ramp, x, y);
}
}
_ => {}
}
}
info!(
"Surface '{}' not found, using playfield height (0.0)",
surface_name
);
0.0
}
fn find_playfield_image(vpx: &VPX) -> Option<&ImageData> {
find_image_by_name(&vpx.images, &vpx.gamedata.image)
}
fn get_image_bytes(image: &ImageData) -> Option<Vec<u8>> {
if let Some(ref jpeg) = image.jpeg {
Some(jpeg.data.clone())
} else if let Some(ref bits) = image.bits {
use crate::vpx::image::vpx_image_to_dynamic_image;
use std::io::Cursor;
let dynamic_image =
vpx_image_to_dynamic_image(&bits.lzw_compressed_data, image.width, image.height);
let mut png_bytes = Vec::new();
let mut cursor = Cursor::new(&mut png_bytes);
if dynamic_image
.write_to(&mut cursor, image::ImageFormat::Png)
.is_ok()
{
Some(png_bytes)
} else {
None
}
} else {
None
}
}
fn build_implicit_playfield_mesh(vpx: &VPX, playfield_material_name: &str) -> NamedMesh {
let (vertices, indices) = build_playfield_mesh(
vpx.gamedata.left,
vpx.gamedata.top,
vpx.gamedata.right,
vpx.gamedata.bottom,
);
NamedMesh {
name: "playfield_mesh".to_string(),
vertices,
indices,
material_name: Some(playfield_material_name.to_string()),
texture_name: if vpx.gamedata.image.is_empty() {
None
} else {
Some(vpx.gamedata.image.clone())
},
..Default::default()
}
}
fn get_layer_name(editor_layer_name: &Option<String>, editor_layer: Option<u32>) -> Option<String> {
if let Some(name) = editor_layer_name
&& !name.is_empty()
{
return Some(format!("Layer_{}", name));
}
editor_layer.map(|layer| format!("Layer_{}", layer + 1))
}
fn calculate_transmission_factor(disable_lighting_below: Option<f32>) -> Option<f32> {
const MAX_TRANSMISSION: f32 = 0.3;
disable_lighting_below
.filter(|&v| v < 1.0)
.map(|v| (1.0 - v) * MAX_TRANSMISSION)
}
fn calculate_playfield_roughness(reflection_strength: f32, fallback_roughness: f32) -> f32 {
const REFLECTION_THRESHOLD: f32 = 0.1;
const MIN_ROUGHNESS: f32 = 0.03;
const MAX_ROUGHNESS: f32 = 0.20;
if reflection_strength > REFLECTION_THRESHOLD {
(MAX_ROUGHNESS - reflection_strength * 0.17).clamp(MIN_ROUGHNESS, MAX_ROUGHNESS)
} else {
fallback_roughness
}
}
#[allow(clippy::too_many_arguments)]
fn playfield_material(
vpx: &VPX,
playfield_image: &ImageData,
playfield_material_name: &str,
materials: &HashMap<String, GltfMaterial>,
gltf_samplers: &mut Vec<serde_json::Value>,
gltf_images: &mut Vec<serde_json::Value>,
gltf_textures: &mut Vec<serde_json::Value>,
buffer_views: &mut Vec<serde_json::Value>,
bin_data: &mut Vec<u8>,
) -> Value {
let image_bytes = get_image_bytes(playfield_image).unwrap();
let sampler_idx = gltf_samplers.len();
gltf_samplers.push(json!({
"magFilter": GLTF_FILTER_LINEAR,
"minFilter": GLTF_FILTER_LINEAR_MIPMAP_LINEAR,
"wrapS": GLTF_WRAP_REPEAT,
"wrapT": GLTF_WRAP_REPEAT
}));
let image_offset = bin_data.len();
bin_data.extend_from_slice(&image_bytes);
let image_length = image_bytes.len();
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0);
}
let image_buffer_view_idx = buffer_views.len();
buffer_views.push(json!({
"buffer": 0,
"byteOffset": image_offset,
"byteLength": image_length
}));
let image_idx = gltf_images.len();
let mime_type = mime_type_for_image(playfield_image);
gltf_images.push(json!({
"bufferView": image_buffer_view_idx,
"mimeType": mime_type,
"name": playfield_image.name
}));
let texture_idx = gltf_textures.len();
gltf_textures.push(json!({
"sampler": sampler_idx,
"source": image_idx,
"name": format!("{}_texture", playfield_image.name)
}));
let material_roughness = materials.get(playfield_material_name).map(|m| m.roughness);
let fallback_roughness = material_roughness.unwrap_or(0.5);
let reflection_strength = vpx.gamedata.playfield_reflection_strength;
let playfield_roughness =
calculate_playfield_roughness(reflection_strength, fallback_roughness);
if let Some(mat_roughness) = material_roughness
&& reflection_strength > 0.1
&& (mat_roughness - playfield_roughness).abs() > 0.05
{
warn!(
"Playfield material '{}' has glTF roughness {:.2} (VPinball shininess {:.2}), \
but playfield_reflection_strength {:.2} suggests it should be more reflective. \
Using glTF roughness {:.2} instead.",
playfield_material_name,
mat_roughness,
1.0 - mat_roughness, reflection_strength,
playfield_roughness
);
}
GltfMaterialBuilder::new(playfield_material_name, 0.0, playfield_roughness)
.texture(texture_idx)
.build()
}
#[allow(unused)]
fn calculate_light_range(light: &Light) -> f32 {
if !light.drag_points.is_empty() {
let max_dist_sq = light
.drag_points
.iter()
.map(|dp| {
let dx = dp.x - light.center.x;
let dy = dp.y - light.center.y;
dx * dx + dy * dy
})
.fold(0.0f32, |a, b| a.max(b));
vpu_to_m(max_dist_sq.sqrt())
} else {
vpu_to_m(light.falloff_radius)
}
}
fn collect_meshes(vpx: &VPX, options: &GltfExportOptions) -> (Vec<NamedMesh>, Vec<ItemGroupInfo>) {
let mut meshes = Vec::new();
let mut item_groups = Vec::new();
let mut has_explicit_playfield = false;
let playfield_material_name = if vpx.gamedata.playfield_material.is_empty() {
PLAYFIELD_MATERIAL_NAME.to_string()
} else {
vpx.gamedata.playfield_material.clone()
};
let table_dims = TableDimensions::new(
vpx.gamedata.left,
vpx.gamedata.top,
vpx.gamedata.right,
vpx.gamedata.bottom,
);
for gameitem in &vpx.gameitems {
match gameitem {
GameItemEnum::Primitive(primitive) => {
if !options.export_invisible_items && !primitive.is_visible {
continue;
}
if let Ok(Some(read_mesh)) = primitive.read_mesh() {
let (transformed, translation) =
transform_primitive_vertices(read_mesh.vertices, primitive);
let is_playfield = primitive.is_playfield();
if is_playfield {
has_explicit_playfield = true;
}
let group_info = item_group_info_for(primitive);
let prim_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let (material_name, texture_name) = if is_playfield {
let playfield_texture = if vpx.gamedata.image.is_empty() {
None
} else {
Some(vpx.gamedata.image.clone())
};
(Some(playfield_material_name.clone()), playfield_texture)
} else {
let texture = if !primitive.image.is_empty() {
Some(primitive.image.clone())
} else {
None
};
let material = if !primitive.material.is_empty() {
Some(primitive.material.clone())
} else {
None
};
(material, texture)
};
let transmission_factor =
calculate_transmission_factor(primitive.disable_lighting_below);
meshes.push(NamedMesh {
name: primitive.name.clone(),
vertices: transformed,
indices: read_mesh.indices,
material_name,
texture_name,
layer_name: prim_layer_name,
transmission_factor,
translation: Some(translation),
visible: primitive.is_visible,
group_name: Some(primitive.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Wall(wall) => {
if !options.export_invisible_items
&& !wall.is_top_bottom_visible
&& !wall.is_side_visible
{
continue;
}
if let Some(wall_meshes) = build_wall_meshes(wall, &table_dims) {
let group_info = item_group_info_for(wall);
let wall_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
if (options.export_invisible_items || wall.is_top_bottom_visible)
&& let Some((vertices, indices)) = wall_meshes.top
{
let material_name = if !wall.top_material.is_empty() {
Some(wall.top_material.clone())
} else {
None
};
let texture_name = if !wall.image.is_empty() {
Some(wall.image.clone())
} else {
None
};
let transmission_factor =
calculate_transmission_factor(wall.disable_lighting_below);
meshes.push(NamedMesh {
name: format!("{}Top", wall.name),
vertices,
indices,
material_name,
texture_name,
layer_name: wall_layer_name.clone(),
transmission_factor,
visible: wall.is_top_bottom_visible,
group_name: Some(wall.name.clone()),
..Default::default()
});
}
if (options.export_invisible_items || wall.is_side_visible)
&& let Some((vertices, indices)) = wall_meshes.side
{
let material_name = if !wall.side_material.is_empty() {
Some(wall.side_material.clone())
} else {
None
};
let texture_name = if !wall.side_image.is_empty() {
Some(wall.side_image.clone())
} else {
None
};
let transmission_factor =
calculate_transmission_factor(wall.disable_lighting_below);
meshes.push(NamedMesh {
name: format!("{}Side", wall.name),
vertices,
indices,
material_name,
texture_name,
layer_name: wall_layer_name.clone(),
transmission_factor,
visible: wall.is_side_visible,
group_name: Some(wall.name.clone()),
..Default::default()
});
}
}
}
GameItemEnum::Ramp(ramp) => {
if !options.export_invisible_items && !ramp.is_visible {
continue;
}
if let Some((vertices, indices)) = build_ramp_mesh(ramp, &table_dims) {
let group_info = item_group_info_for(ramp);
let ramp_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let material_name = if !ramp.material.is_empty() {
Some(ramp.material.clone())
} else {
None
};
let texture_name = if !ramp.image.is_empty() {
Some(ramp.image.clone())
} else {
None
};
meshes.push(NamedMesh {
name: ramp.name.clone(),
vertices,
indices,
material_name,
texture_name,
layer_name: ramp_layer_name,
visible: ramp.is_visible,
group_name: Some(ramp.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Rubber(rubber) => {
if !options.export_invisible_items && !rubber.is_visible {
continue;
}
if let Some((vertices, indices, center)) = build_rubber_mesh(rubber) {
let group_info = item_group_info_for(rubber);
let rubber_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let material_name = if rubber.material.is_empty() {
None
} else {
Some(rubber.material.clone())
};
let translation = Some(Vec3::new(
vpu_to_m(center.x),
vpu_to_m(center.z),
vpu_to_m(center.y),
));
meshes.push(NamedMesh {
name: rubber.name.clone(),
vertices,
indices,
material_name,
texture_name: None,
layer_name: rubber_layer_name,
translation,
visible: rubber.is_visible,
group_name: Some(rubber.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Flasher(flasher) => {
if !options.export_invisible_items && !flasher.is_visible {
continue;
}
if let Some((vertices, indices, center)) = build_flasher_mesh(flasher, &table_dims)
{
let group_info = item_group_info_for(flasher);
let flasher_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let texture_name = if flasher.image_a.is_empty() {
None
} else {
Some(flasher.image_a.clone())
};
let color_tint = Some([
flasher.color.r as f32 / 255.0,
flasher.color.g as f32 / 255.0,
flasher.color.b as f32 / 255.0,
flasher.alpha as f32 / 100.0,
]);
let translation = Some(Vec3::new(
vpu_to_m(center.x),
vpu_to_m(center.z),
vpu_to_m(center.y),
));
meshes.push(NamedMesh {
name: flasher.name.clone(),
vertices,
indices,
texture_name,
color_tint,
layer_name: flasher_layer_name,
translation,
visible: flasher.is_visible,
group_name: Some(flasher.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Flipper(flipper) => {
if !options.export_invisible_items && !flipper.is_visible {
continue;
}
if let Some(flipper_meshes) = build_flipper_meshes(flipper, 0.0) {
let group_info = item_group_info_for(flipper);
let flipper_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let translation = Some(Vec3::new(
vpu_to_m(flipper_meshes.center.x),
vpu_to_m(flipper_meshes.center.z),
vpu_to_m(flipper_meshes.center.y),
));
let (base_vertices, base_indices) = flipper_meshes.base;
let base_material = if flipper.material.is_empty() {
None
} else {
Some(flipper.material.clone())
};
let base_texture = flipper.image.as_ref().filter(|s| !s.is_empty()).cloned();
meshes.push(NamedMesh {
name: format!("{}Base", flipper.name),
vertices: base_vertices,
indices: base_indices,
material_name: base_material,
texture_name: base_texture,
layer_name: flipper_layer_name.clone(),
translation,
visible: flipper.is_visible,
group_name: Some(flipper.name.clone()),
..Default::default()
});
if let Some((rubber_vertices, rubber_indices)) = flipper_meshes.rubber {
let rubber_material = if flipper.rubber_material.is_empty() {
None
} else {
Some(flipper.rubber_material.clone())
};
meshes.push(NamedMesh {
name: format!("{}Rubber", flipper.name),
vertices: rubber_vertices,
indices: rubber_indices,
material_name: rubber_material,
layer_name: flipper_layer_name.clone(),
translation,
visible: flipper.is_visible,
group_name: Some(flipper.name.clone()),
..Default::default()
});
}
}
}
GameItemEnum::Bumper(bumper) => {
let surface_height =
get_surface_height(vpx, &bumper.surface, bumper.center.x, bumper.center.y);
let bumper_meshes = build_bumper_meshes(bumper);
let group_info = item_group_info_for(bumper);
let bumper_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let translation = Some(Vec3::new(
vpu_to_m(bumper.center.x),
vpu_to_m(surface_height),
vpu_to_m(bumper.center.y),
));
if let Some((base_vertices, base_indices)) = bumper_meshes.base {
let base_material = if bumper.base_material.is_empty() {
None
} else {
Some(bumper.base_material.clone())
};
meshes.push(NamedMesh {
name: format!("{}Base", bumper.name),
vertices: base_vertices,
indices: base_indices,
material_name: base_material,
layer_name: bumper_layer_name.clone(),
translation,
group_name: Some(bumper.name.clone()),
..Default::default()
});
}
if let Some((socket_vertices, socket_indices)) = bumper_meshes.socket {
let socket_material = if bumper.socket_material.is_empty() {
None
} else {
Some(bumper.socket_material.clone())
};
meshes.push(NamedMesh {
name: format!("{}Socket", bumper.name),
vertices: socket_vertices,
indices: socket_indices,
material_name: socket_material,
layer_name: bumper_layer_name.clone(),
translation,
group_name: Some(bumper.name.clone()),
..Default::default()
});
}
if let Some((ring_vertices, ring_indices)) = bumper_meshes.ring {
let ring_material = bumper
.ring_material
.as_ref()
.and_then(|m| if m.is_empty() { None } else { Some(m.clone()) });
meshes.push(NamedMesh {
name: format!("{}Ring", bumper.name),
vertices: ring_vertices,
indices: ring_indices,
material_name: ring_material,
layer_name: bumper_layer_name.clone(),
translation,
group_name: Some(bumper.name.clone()),
..Default::default()
});
}
if let Some((cap_vertices, cap_indices)) = bumper_meshes.cap {
let cap_material = if bumper.cap_material.is_empty() {
None
} else {
Some(bumper.cap_material.clone())
};
meshes.push(NamedMesh {
name: format!("{}Cap", bumper.name),
vertices: cap_vertices,
indices: cap_indices,
material_name: cap_material,
layer_name: bumper_layer_name.clone(),
translation,
group_name: Some(bumper.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Spinner(spinner) => {
if !options.export_invisible_items && !spinner.is_visible {
continue;
}
let surface_height =
get_surface_height(vpx, &spinner.surface, spinner.center.x, spinner.center.y);
let spinner_meshes = build_spinner_meshes(spinner);
let group_info = item_group_info_for(spinner);
let spinner_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let translation = Some(Vec3::new(
vpu_to_m(spinner.center.x),
vpu_to_m(surface_height + spinner.height),
vpu_to_m(spinner.center.y),
));
if let Some((bracket_vertices, bracket_indices)) = spinner_meshes.bracket {
meshes.push(NamedMesh {
name: format!("{}Bracket", spinner.name),
vertices: bracket_vertices,
indices: bracket_indices,
layer_name: spinner_layer_name.clone(),
translation,
visible: spinner.is_visible,
group_name: Some(spinner.name.clone()),
..Default::default()
});
}
let (plate_vertices, plate_indices) = spinner_meshes.plate;
let plate_material = if spinner.material.is_empty() {
None
} else {
Some(spinner.material.clone())
};
let plate_texture = if spinner.image.is_empty() {
None
} else {
Some(spinner.image.clone())
};
meshes.push(NamedMesh {
name: format!("{}Plate", spinner.name),
vertices: plate_vertices,
indices: plate_indices,
material_name: plate_material,
texture_name: plate_texture,
layer_name: spinner_layer_name.clone(),
translation,
visible: spinner.is_visible,
group_name: Some(spinner.name.clone()),
..Default::default()
});
}
GameItemEnum::HitTarget(hit_target) => {
if !options.export_invisible_items && !hit_target.is_visible {
continue;
}
if let Some((vertices, indices)) = build_hit_target_mesh(hit_target) {
let group_info = item_group_info_for(hit_target);
let hit_target_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let material_name = if hit_target.material.is_empty() {
None
} else {
Some(hit_target.material.clone())
};
let texture_name = if hit_target.image.is_empty() {
None
} else {
Some(hit_target.image.clone())
};
let translation = Some(Vec3::new(
vpu_to_m(hit_target.position.x),
vpu_to_m(hit_target.position.z),
vpu_to_m(hit_target.position.y),
));
meshes.push(NamedMesh {
name: hit_target.name.clone(),
vertices,
indices,
material_name,
texture_name,
layer_name: hit_target_layer_name.clone(),
translation,
visible: hit_target.is_visible,
group_name: Some(hit_target.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Gate(gate) => {
if !options.export_invisible_items && !gate.is_visible {
continue;
}
let surface_height =
get_surface_height(vpx, &gate.surface, gate.center.x, gate.center.y);
if let Some(gate_meshes) = build_gate_meshes(gate) {
let group_info = item_group_info_for(gate);
let gate_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let material_name = if gate.material.is_empty() {
None
} else {
Some(gate.material.clone())
};
let translation = Some(Vec3::new(
vpu_to_m(gate.center.x),
vpu_to_m(surface_height + gate.height),
vpu_to_m(gate.center.y),
));
if let Some((bracket_vertices, bracket_indices)) = gate_meshes.bracket {
meshes.push(NamedMesh {
name: format!("{}Bracket", gate.name),
vertices: bracket_vertices,
indices: bracket_indices,
material_name: material_name.clone(),
layer_name: gate_layer_name.clone(),
translation,
visible: gate.is_visible,
group_name: Some(gate.name.clone()),
..Default::default()
});
}
let (wire_vertices, wire_indices) = gate_meshes.wire;
meshes.push(NamedMesh {
name: format!("{}Wire", gate.name),
vertices: wire_vertices,
indices: wire_indices,
material_name,
layer_name: gate_layer_name.clone(),
translation,
visible: gate.is_visible,
group_name: Some(gate.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Trigger(trigger) => {
if !options.export_invisible_items && !trigger.is_visible {
continue;
}
let surface_height =
get_surface_height(vpx, &trigger.surface, trigger.center.x, trigger.center.y);
if let Some((vertices, indices)) = build_trigger_mesh(trigger) {
let group_info = item_group_info_for(trigger);
let trigger_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let material_name = if trigger.material.is_empty() {
None
} else {
Some(trigger.material.clone())
};
let translation = Some(Vec3::new(
vpu_to_m(trigger.center.x),
vpu_to_m(surface_height),
vpu_to_m(trigger.center.y),
));
meshes.push(NamedMesh {
name: trigger.name.clone(),
vertices,
indices,
material_name,
layer_name: trigger_layer_name.clone(),
translation,
visible: trigger.is_visible,
group_name: Some(trigger.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Light(light) => {
if light.is_backglass {
continue;
}
let surface_height =
get_surface_height(vpx, &light.surface, light.center.x, light.center.y);
let is_visible = light.visible.unwrap_or(true);
let group_info = item_group_info_for(light);
let light_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
if light.show_bulb_mesh
&& let Some(light_meshes) = build_light_meshes(light)
{
let translation = Some(Vec3::new(
vpu_to_m(light.center.x),
vpu_to_m(surface_height),
vpu_to_m(light.center.y),
));
if let Some((vertices, indices)) = light_meshes.bulb {
meshes.push(NamedMesh {
name: format!("{}_bulb", light.name),
vertices,
indices,
color_tint: Some([1.0, 1.0, 1.0, 0.2]),
layer_name: light_layer_name.clone(),
translation,
visible: is_visible,
group_name: Some(light.name.clone()),
..Default::default()
});
}
if let Some((vertices, indices)) = light_meshes.socket {
meshes.push(NamedMesh {
name: format!("{}_socket", light.name),
vertices,
indices,
color_tint: Some([0.094, 0.094, 0.094, 1.0]), layer_name: light_layer_name.clone(),
translation,
visible: is_visible,
group_name: Some(light.name.clone()),
..Default::default()
});
}
}
if let Some((vertices, indices, center)) =
build_light_insert_mesh(light, &table_dims)
{
let translation = Some(Vec3::new(
vpu_to_m(center.x),
vpu_to_m(surface_height + 0.1),
vpu_to_m(center.y),
));
let texture_name = if !light.is_bulb_light && !light.image.is_empty() {
Some(light.image.clone())
} else {
None
};
let color_tint = Some([
light.color.r as f32 / 255.0,
light.color.g as f32 / 255.0,
light.color.b as f32 / 255.0,
0.3,
]);
meshes.push(NamedMesh {
name: format!("{}_insert", light.name),
vertices,
indices,
texture_name,
color_tint,
layer_name: light_layer_name.clone(),
translation,
visible: is_visible,
group_name: Some(light.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Plunger(plunger) => {
if !options.export_invisible_items && !plunger.is_visible {
continue;
}
let surface_height =
get_surface_height(vpx, &plunger.surface, plunger.center.x, plunger.center.y);
let plunger_meshes = build_plunger_meshes(plunger);
let material_name = if plunger.material.is_empty() {
None
} else {
Some(plunger.material.clone())
};
let texture_name = if plunger.image.is_empty() {
None
} else {
Some(plunger.image.clone())
};
let group_info = item_group_info_for(plunger);
let plunger_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let translation = Some(Vec3::new(
vpu_to_m(plunger.center.x),
vpu_to_m(surface_height + plunger.z_adjust),
vpu_to_m(plunger.center.y),
));
if let Some((vertices, indices)) = plunger_meshes.flat_rod {
meshes.push(NamedMesh {
name: format!("{}Flat", plunger.name),
vertices,
indices,
material_name: material_name.clone(),
texture_name: texture_name.clone(),
layer_name: plunger_layer_name.clone(),
translation,
visible: plunger.is_visible,
group_name: Some(plunger.name.clone()),
..Default::default()
});
}
if let Some((vertices, indices)) = plunger_meshes.rod {
meshes.push(NamedMesh {
name: format!("{}Rod", plunger.name),
vertices,
indices,
material_name: material_name.clone(),
texture_name: texture_name.clone(),
layer_name: plunger_layer_name.clone(),
translation,
visible: plunger.is_visible,
group_name: Some(plunger.name.clone()),
..Default::default()
});
}
if let Some((vertices, indices)) = plunger_meshes.spring {
meshes.push(NamedMesh {
name: format!("{}Spring", plunger.name),
vertices,
indices,
material_name: material_name.clone(),
texture_name: texture_name.clone(),
layer_name: plunger_layer_name.clone(),
translation,
visible: plunger.is_visible,
group_name: Some(plunger.name.clone()),
..Default::default()
});
}
if let Some((vertices, indices)) = plunger_meshes.ring {
meshes.push(NamedMesh {
name: format!("{}Ring", plunger.name),
vertices,
indices,
material_name: material_name.clone(),
texture_name: texture_name.clone(),
layer_name: plunger_layer_name.clone(),
translation,
visible: plunger.is_visible,
group_name: Some(plunger.name.clone()),
..Default::default()
});
}
if let Some((vertices, indices)) = plunger_meshes.tip {
meshes.push(NamedMesh {
name: format!("{}Tip", plunger.name),
vertices,
indices,
material_name,
texture_name,
layer_name: plunger_layer_name.clone(),
translation,
visible: plunger.is_visible,
group_name: Some(plunger.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Kicker(kicker) => {
if matches!(
kicker.kicker_type,
crate::vpx::gameitem::kicker::KickerType::Invisible
) {
continue;
}
let surface_height =
get_surface_height(vpx, &kicker.surface, kicker.center.x, kicker.center.y);
let kicker_meshes = build_kicker_meshes(kicker);
let material_name = if kicker.material.is_empty() {
None
} else {
Some(kicker.material.clone())
};
let group_info = item_group_info_for(kicker);
let kicker_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let translation = Some(Vec3::new(
vpu_to_m(kicker.center.x),
vpu_to_m(surface_height),
vpu_to_m(kicker.center.y),
));
use crate::vpx::gameitem::kicker::KickerType;
let kicker_color: Option<[f32; 4]> = match kicker.kicker_type {
KickerType::Cup | KickerType::Cup2 => {
Some([0.75, 0.75, 0.78, 1.0])
}
KickerType::Williams => {
Some([0.72, 0.53, 0.25, 1.0])
}
KickerType::Gottlieb => {
Some([0.45, 0.42, 0.40, 1.0])
}
KickerType::Hole | KickerType::HoleSimple => {
Some([0.36, 0.25, 0.15, 1.0])
}
KickerType::Invisible => None,
};
if let Some((vertices, indices)) = kicker_meshes.plate {
meshes.push(NamedMesh {
name: format!("{}Plate", kicker.name),
vertices,
indices,
material_name: material_name.clone(),
color_tint: Some([0.02, 0.02, 0.02, 1.0]), layer_name: kicker_layer_name.clone(),
translation,
group_name: Some(kicker.name.clone()),
..Default::default()
});
}
if let Some((vertices, indices)) = kicker_meshes.kicker {
meshes.push(NamedMesh {
name: format!("{}Kicker", kicker.name),
vertices,
indices,
material_name,
color_tint: kicker_color,
layer_name: kicker_layer_name.clone(),
translation,
group_name: Some(kicker.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Decal(decal) => {
let surface_height =
get_surface_height(vpx, &decal.surface, decal.center.x, decal.center.y);
if let Some((vertices, indices)) = build_decal_mesh(decal) {
let group_info = item_group_info_for(decal);
let decal_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let texture_name = if !decal.image.is_empty() {
Some(decal.image.clone())
} else {
None
};
let material_name = if !decal.material.is_empty() {
Some(decal.material.clone())
} else {
None
};
let translation = Some(Vec3::new(
vpu_to_m(decal.center.x),
vpu_to_m(surface_height + 0.2),
vpu_to_m(decal.center.y),
));
meshes.push(NamedMesh {
name: decal.name.clone(),
vertices,
indices,
material_name,
texture_name,
layer_name: decal_layer_name,
translation,
group_name: Some(decal.name.clone()),
..Default::default()
});
}
}
GameItemEnum::Ball(ball) => {
let (vertices, indices) = build_ball_mesh(ball);
let group_info = item_group_info_for(ball);
let ball_layer_name = group_info.layer_name.clone();
item_groups.push(group_info);
let is_decal_mode = ball.decal_mode || vpx.gamedata.ball_decal_mode;
let (texture_name, roughness_texture_name) = if is_decal_mode {
let decal = if !ball.image_decal.is_empty() {
Some(ball.image_decal.clone())
} else if !vpx.gamedata.ball_image_front.is_empty() {
Some(vpx.gamedata.ball_image_front.clone())
} else {
None
};
(decal, None)
} else {
let scratches = if !ball.image_decal.is_empty() {
Some(ball.image_decal.clone())
} else if !vpx.gamedata.ball_image_front.is_empty() {
Some(vpx.gamedata.ball_image_front.clone())
} else {
None
};
(None, scratches)
};
let color = ball.color;
let color_tint = if color.r == 255 && color.g == 255 && color.b == 255 {
None } else {
Some([
color.r as f32 / 255.0,
color.g as f32 / 255.0,
color.b as f32 / 255.0,
1.0,
])
};
let translation = Some(Vec3::new(
vpu_to_m(ball.pos.x),
vpu_to_m(ball.pos.z),
vpu_to_m(ball.pos.y),
));
meshes.push(NamedMesh {
name: ball.name.clone(),
vertices,
indices,
material_name: None, texture_name,
color_tint,
layer_name: ball_layer_name,
is_ball: true, roughness_texture_name,
translation,
group_name: Some(ball.name.clone()),
..Default::default()
});
}
_ => {}
}
}
if !has_explicit_playfield {
meshes.push(build_implicit_playfield_mesh(vpx, &playfield_material_name));
}
(meshes, item_groups)
}
fn build_combined_gltf_payload(
vpx: &VPX,
meshes: &[NamedMesh],
item_groups: Vec<ItemGroupInfo>,
materials: &HashMap<String, GltfMaterial>,
images: &[ImageData],
playfield_image: Option<&ImageData>,
playfield_material_name: &str,
) -> io::Result<(serde_json::Value, Vec<u8>)> {
if meshes.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No meshes to export",
));
}
let mut material_index_map: HashMap<String, usize> = HashMap::new();
let mut gltf_materials: Vec<serde_json::Value> = Vec::new();
let mut gltf_textures: Vec<serde_json::Value> = Vec::new();
let mut gltf_images: Vec<serde_json::Value> = Vec::new();
let mut gltf_samplers: Vec<serde_json::Value> = Vec::new();
let mut bin_data = Vec::new();
let mut buffer_views = Vec::new();
if let Some(image) = playfield_image {
let pf_material = playfield_material(
vpx,
image,
playfield_material_name,
materials,
&mut gltf_samplers,
&mut gltf_images,
&mut gltf_textures,
&mut buffer_views,
&mut bin_data,
);
material_index_map.insert(playfield_material_name.to_string(), gltf_materials.len());
gltf_materials.push(pf_material);
}
for (name, mat) in materials {
if material_index_map.contains_key(name) {
continue;
}
material_index_map.insert(name.clone(), gltf_materials.len());
let needs_alpha_blend = mat.opacity_active && mat.base_color[3] < 0.999;
let material = GltfMaterialBuilder::new(&mat.name, mat.metallic, mat.roughness)
.base_color(mat.base_color)
.alpha_blend_if(needs_alpha_blend)
.build();
gltf_materials.push(material);
}
let image_map: HashMap<String, &ImageData> = images
.iter()
.map(|img| (img.name.to_lowercase(), img))
.collect();
let mut texture_index_map: HashMap<String, usize> = HashMap::new();
let mut texture_material_map: HashMap<String, usize> = HashMap::new();
let mut uses_transmission_extension = false;
let uses_node_visibility_extension = meshes.iter().any(|m| !m.visible);
let mut mesh_material_map: HashMap<usize, usize> = HashMap::new();
for mesh in meshes.iter() {
let texture_names: Vec<&String> = [
mesh.texture_name.as_ref(),
mesh.roughness_texture_name.as_ref(),
]
.into_iter()
.flatten()
.collect();
for texture_name in texture_names {
let texture_key = texture_name.to_lowercase();
if texture_index_map.contains_key(&texture_key) {
continue;
}
if let Some(image) = image_map.get(&texture_key)
&& let Some(image_bytes) = get_image_bytes(image)
{
let sampler_idx = if gltf_samplers.is_empty() {
gltf_samplers.push(json!({
"magFilter": GLTF_FILTER_LINEAR,
"minFilter": GLTF_FILTER_LINEAR_MIPMAP_LINEAR,
"wrapS": GLTF_WRAP_REPEAT,
"wrapT": GLTF_WRAP_REPEAT
}));
0
} else {
0 };
let image_offset = bin_data.len();
bin_data.extend_from_slice(&image_bytes);
let image_length = image_bytes.len();
while bin_data.len() % 4 != 0 {
bin_data.push(0);
}
let image_buffer_view_idx = buffer_views.len();
buffer_views.push(json!({
"buffer": 0,
"byteOffset": image_offset,
"byteLength": image_length
}));
let image_idx = gltf_images.len();
let mime_type = mime_type_for_image(image);
gltf_images.push(json!({
"bufferView": image_buffer_view_idx,
"mimeType": mime_type,
"name": image.name
}));
let texture_idx = gltf_textures.len();
gltf_textures.push(json!({
"sampler": sampler_idx,
"source": image_idx,
"name": format!("{}_texture", image.name)
}));
texture_index_map.insert(texture_key, texture_idx);
} else {
warn!(
"Image '{}' not found for mesh '{}', texture will not be applied",
texture_name, mesh.name
);
}
}
}
for (mesh_idx, mesh) in meshes.iter().enumerate() {
let vpx_material = mesh
.material_name
.as_ref()
.and_then(|mat_name| materials.get(mat_name));
let metallic = vpx_material.map(|m| m.metallic).unwrap_or(0.0);
let roughness = if mesh.is_playfield() {
let material_roughness = vpx_material.map(|m| m.roughness).unwrap_or(0.5);
let reflection_strength = vpx.gamedata.playfield_reflection_strength;
calculate_playfield_roughness(reflection_strength, material_roughness)
} else {
vpx_material.map(|m| m.roughness).unwrap_or(0.5)
};
if mesh.is_ball {
let material_idx = gltf_materials.len();
mesh_material_map.insert(mesh_idx, material_idx);
let base_color = mesh.color_tint.unwrap_or([0.8, 0.8, 0.8, 1.0]);
let base_texture_idx = mesh.texture_name.as_ref().and_then(|name| {
let key = name.to_lowercase();
texture_index_map.get(&key).copied()
});
let material_name = format!("{}_ball", mesh.name);
let material_json = GltfMaterialBuilder::new(material_name, 1.0, 0.15)
.base_color(base_color)
.texture_opt(base_texture_idx)
.build();
gltf_materials.push(material_json);
}
else if let Some(color_tint) = mesh.color_tint {
let material_idx = gltf_materials.len();
mesh_material_map.insert(mesh_idx, material_idx);
let texture_idx = mesh
.texture_name
.as_ref()
.and_then(|name| texture_index_map.get(&name.to_lowercase()).copied());
let material_name = match &mesh.texture_name {
Some(tex) if texture_idx.is_some() => format!("{}_{}", mesh.name, tex),
_ => format!("{}_color", mesh.name),
};
gltf_materials.push(
GltfMaterialBuilder::new(&material_name, metallic, roughness)
.base_color(color_tint)
.texture_opt(texture_idx)
.alpha_blend()
.double_sided()
.build(),
);
}
else if let Some(transmission) = mesh.transmission_factor {
if transmission > 0.0 {
let material_name = format!("{}_transmission", mesh.name);
let material_idx = gltf_materials.len();
mesh_material_map.insert(mesh_idx, material_idx);
uses_transmission_extension = true;
let (base_color, needs_alpha_blend) = if let Some(ref mat_name) = mesh.material_name
&& let Some(mat) = materials.get(mat_name)
{
let needs_blend = mat.opacity_active && mat.base_color[3] < 0.999;
(mat.base_color, needs_blend)
} else {
([1.0, 1.0, 1.0, 1.0], false)
};
let texture_info = mesh.texture_name.as_ref().and_then(|name| {
let key = name.to_lowercase();
texture_index_map.get(&key).copied().map(|idx| {
let img = image_map.get(&key);
let has_alpha = img.is_some_and(|i| image_has_transparency(i));
let alpha_test_value = img.map(|i| i.alpha_test_value).unwrap_or(-1.0);
(idx, has_alpha, alpha_test_value)
})
});
let effective_transmission = if texture_info.is_some() {
transmission * 0.3
} else {
transmission
};
let mut builder = GltfMaterialBuilder::new(material_name, metallic, roughness)
.base_color(base_color)
.transmission(effective_transmission);
if let Some((texture_idx, image_has_alpha, _alpha_test_value)) = texture_info {
builder = builder.texture(texture_idx).double_sided();
if needs_alpha_blend || image_has_alpha {
builder = builder.alpha_blend();
}
} else {
builder = builder.alpha_blend_if(needs_alpha_blend);
}
gltf_materials.push(builder.build());
}
}
else if let Some(ref texture_name) = mesh.texture_name {
let texture_key = texture_name.to_lowercase();
let mat_info = mesh
.material_name
.as_ref()
.and_then(|mat_name| materials.get(mat_name))
.map(|mat| {
(
mat.base_color,
mat.opacity_active,
mat.metallic,
mat.roughness,
)
});
if let Some((color, opacity_active, metallic, roughness)) = mat_info {
let is_non_white = color[0] < 0.99 || color[1] < 0.99 || color[2] < 0.99;
if is_non_white {
if let Some(&texture_idx) = texture_index_map.get(&texture_key) {
let material_idx = gltf_materials.len();
mesh_material_map.insert(mesh_idx, material_idx);
let img = image_map.get(&texture_key);
let image_has_alpha = img.is_some_and(|i| image_has_transparency(i));
let material_has_alpha = opacity_active && color[3] < 0.999;
let material_name =
format!("{}_{}", mesh.material_name.as_ref().unwrap(), texture_name);
let mut builder =
GltfMaterialBuilder::new(&material_name, metallic, roughness)
.base_color(color)
.texture(texture_idx)
.double_sided();
if material_has_alpha || image_has_alpha {
builder = builder.alpha_blend();
}
gltf_materials.push(builder.build());
}
continue;
}
}
let mat_properties = mesh
.material_name
.as_ref()
.and_then(|mat_name| materials.get(mat_name))
.map(|mat| {
(
mat.metallic,
mat.roughness,
mat.opacity_active,
mat.base_color[3],
)
});
if let Some((metallic, roughness, opacity_active, opacity)) = mat_properties
&& metallic > 0.0
{
if let Some(&texture_idx) = texture_index_map.get(&texture_key) {
let material_idx = gltf_materials.len();
mesh_material_map.insert(mesh_idx, material_idx);
let img = image_map.get(&texture_key);
let image_has_alpha = img.is_some_and(|i| image_has_transparency(i));
let material_has_alpha = opacity_active && opacity < 0.999;
let material_name =
format!("{}_{}", mesh.material_name.as_ref().unwrap(), texture_name);
let mut builder = GltfMaterialBuilder::new(material_name, metallic, roughness)
.texture(texture_idx)
.double_sided();
if material_has_alpha || image_has_alpha {
builder = builder.alpha_blend();
}
gltf_materials.push(builder.build());
}
continue;
}
if texture_material_map.contains_key(&texture_key) {
continue;
}
if let Some(&texture_idx) = texture_index_map.get(&texture_key) {
let material_idx = gltf_materials.len();
texture_material_map.insert(texture_key.clone(), material_idx);
let img = image_map.get(&texture_key);
let image_has_alpha = img.is_some_and(|i| image_has_transparency(i));
let material_has_alpha = mesh
.material_name
.as_ref()
.and_then(|mat_name| materials.get(mat_name))
.is_some_and(|mat| mat.opacity_active && mat.base_color[3] < 0.999);
let material_name = format!("__texture__{}", texture_name);
let mut builder = GltfMaterialBuilder::new(&material_name, metallic, roughness)
.texture(texture_idx)
.double_sided();
if material_has_alpha || image_has_alpha {
builder = builder.alpha_blend();
}
gltf_materials.push(builder.build());
}
}
}
let mut nodes: Vec<serde_json::Value> = Vec::new();
let mut mesh_json = Vec::new();
let mut accessors = Vec::new();
let mut layer_groups: HashMap<String, (usize, Vec<usize>)> = HashMap::new();
let mut root_node_indices: Vec<usize> = Vec::new();
let mut item_group_children: HashMap<String, Vec<usize>> = HashMap::new();
for (mesh_idx, mesh) in meshes.iter().enumerate() {
let accessor_base = accessors.len();
let buffer_view_base = buffer_views.len();
let positions_offset = bin_data.len();
for VertexWrapper { vertex, .. } in &mesh.vertices {
bin_data.write_f32::<LittleEndian>(vpu_to_m(vertex.x))?;
bin_data.write_f32::<LittleEndian>(vpu_to_m(vertex.z))?;
bin_data.write_f32::<LittleEndian>(vpu_to_m(vertex.y))?;
}
let positions_length = bin_data.len() - positions_offset;
let normals_offset = bin_data.len();
for VertexWrapper { vertex, .. } in &mesh.vertices {
let nx = if vertex.nx.is_nan() { 0.0 } else { vertex.nx };
let ny = if vertex.ny.is_nan() { 0.0 } else { vertex.ny };
let nz = if vertex.nz.is_nan() { 0.0 } else { vertex.nz };
bin_data.write_f32::<LittleEndian>(nx)?;
bin_data.write_f32::<LittleEndian>(nz)?;
bin_data.write_f32::<LittleEndian>(ny)?;
}
let normals_length = bin_data.len() - normals_offset;
let texcoords_offset = bin_data.len();
for VertexWrapper { vertex, .. } in &mesh.vertices {
bin_data.write_f32::<LittleEndian>(vertex.tu)?;
bin_data.write_f32::<LittleEndian>(vertex.tv)?;
}
let texcoords_length = bin_data.len() - texcoords_offset;
let indices_offset = bin_data.len();
let use_u32 = mesh.vertices.len() > 65535;
for face in &mesh.indices {
if use_u32 {
bin_data.write_u32::<LittleEndian>(face.i0 as u32)?;
bin_data.write_u32::<LittleEndian>(face.i2 as u32)?;
bin_data.write_u32::<LittleEndian>(face.i1 as u32)?;
} else {
bin_data.write_u16::<LittleEndian>(face.i0 as u16)?;
bin_data.write_u16::<LittleEndian>(face.i2 as u16)?;
bin_data.write_u16::<LittleEndian>(face.i1 as u16)?;
}
}
let indices_length = bin_data.len() - indices_offset;
let (min_x, max_x, min_y, max_y, min_z, max_z) = mesh.vertices.iter().fold(
(
f32::INFINITY,
f32::NEG_INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
),
|(min_x, max_x, min_y, max_y, min_z, max_z), v| {
let gltf_x = vpu_to_m(v.vertex.x);
let gltf_y = vpu_to_m(v.vertex.z);
let gltf_z = vpu_to_m(v.vertex.y);
(
min_x.min(gltf_x),
max_x.max(gltf_x),
min_y.min(gltf_y),
max_y.max(gltf_y),
min_z.min(gltf_z),
max_z.max(gltf_z),
)
},
);
buffer_views.push(json!({
"buffer": 0,
"byteOffset": positions_offset,
"byteLength": positions_length,
"target": GLTF_TARGET_ARRAY_BUFFER
}));
buffer_views.push(json!({
"buffer": 0,
"byteOffset": normals_offset,
"byteLength": normals_length,
"target": GLTF_TARGET_ARRAY_BUFFER
}));
buffer_views.push(json!({
"buffer": 0,
"byteOffset": texcoords_offset,
"byteLength": texcoords_length,
"target": GLTF_TARGET_ARRAY_BUFFER
}));
buffer_views.push(json!({
"buffer": 0,
"byteOffset": indices_offset,
"byteLength": indices_length,
"target": GLTF_TARGET_ELEMENT_ARRAY_BUFFER
}));
accessors.push(json!({
"bufferView": buffer_view_base,
"componentType": GLTF_COMPONENT_TYPE_FLOAT,
"count": mesh.vertices.len(),
"type": "VEC3",
"min": [min_x, min_y, min_z],
"max": [max_x, max_y, max_z]
}));
accessors.push(json!({
"bufferView": buffer_view_base + 1,
"componentType": GLTF_COMPONENT_TYPE_FLOAT,
"count": mesh.vertices.len(),
"type": "VEC3"
}));
accessors.push(json!({
"bufferView": buffer_view_base + 2,
"componentType": GLTF_COMPONENT_TYPE_FLOAT,
"count": mesh.vertices.len(),
"type": "VEC2"
}));
accessors.push(json!({
"bufferView": buffer_view_base + 3,
"componentType": if use_u32 {
GLTF_COMPONENT_TYPE_UNSIGNED_INT
} else {
GLTF_COMPONENT_TYPE_UNSIGNED_SHORT
},
"count": mesh.indices.len() * 3,
"type": "SCALAR"
}));
let mut primitive = json!({
"attributes": {
"POSITION": accessor_base,
"NORMAL": accessor_base + 1,
"TEXCOORD_0": accessor_base + 2
},
"indices": accessor_base + 3,
"mode": GLTF_PRIMITIVE_MODE_TRIANGLES
});
if let Some(&mat_idx) = mesh_material_map.get(&mesh_idx) {
primitive["material"] = json!(mat_idx);
} else if let Some(ref texture_name) = mesh.texture_name {
let texture_key = texture_name.to_lowercase();
if let Some(&mat_idx) = texture_material_map.get(&texture_key) {
primitive["material"] = json!(mat_idx);
} else {
warn!(
"Texture material for '{}' not found for mesh '{}', no material will be applied",
texture_name, mesh.name
);
}
} else if let Some(ref mat_name) = mesh.material_name {
if let Some(&mat_idx) = material_index_map.get(mat_name) {
primitive["material"] = json!(mat_idx);
} else {
warn!(
"Material '{}' not found for mesh '{}', no material will be applied",
mat_name, mesh.name
);
}
}
mesh_json.push(json!({
"name": mesh.name,
"primitives": [primitive]
}));
let node_idx = nodes.len();
let mut node = json!({
"mesh": mesh_idx,
"name": mesh.name
});
if let Some(translation) = mesh.translation {
node["translation"] = json!([translation.x, translation.y, translation.z]);
}
if !mesh.visible {
node["extensions"] = json!({
"KHR_node_visibility": {
"visible": false
}
});
}
nodes.push(node);
if let Some(ref group_name) = mesh.group_name {
item_group_children
.entry(group_name.clone())
.or_default()
.push(node_idx);
} else if let Some(ref layer_name) = mesh.layer_name {
if let Some((_, children)) = layer_groups.get_mut(layer_name) {
children.push(node_idx);
} else {
layer_groups.insert(layer_name.clone(), (usize::MAX, vec![node_idx]));
}
} else {
root_node_indices.push(node_idx);
}
}
while bin_data.len() % 4 != 0 {
bin_data.push(0);
}
let light_height = vpu_to_m(vpx.gamedata.light_height);
let table_center_x = vpu_to_m((vpx.gamedata.left + vpx.gamedata.right) / 2.0);
let light0_z = vpu_to_m(vpx.gamedata.bottom * (1.0 / 3.0)); let light1_z = vpu_to_m(vpx.gamedata.bottom * (2.0 / 3.0));
let light_color = [
vpx.gamedata.light0_emission.r as f32 / 255.0,
vpx.gamedata.light0_emission.g as f32 / 255.0,
vpx.gamedata.light0_emission.b as f32 / 255.0,
];
let combined_emission_scale =
vpx.gamedata.light_emission_scale * vpx.gamedata.global_emission_scale;
let color_brightness = (light_color[0] + light_color[1] + light_color[2]) / 3.0;
let light_intensity = combined_emission_scale * 0.001 * color_brightness;
let light_range = vpu_to_m(vpx.gamedata.light_range).min(100.0);
let mut gltf_lights = vec![
json!({
"name": "TableLight0",
"type": "point",
"color": light_color,
"intensity": light_intensity,
"range": light_range
}),
json!({
"name": "TableLight1",
"type": "point",
"color": light_color,
"intensity": light_intensity,
"range": light_range
}),
];
let mut game_lights: Vec<(String, f32, f32, f32, Option<String>)> = Vec::new();
for gameitem in &vpx.gameitems {
if let GameItemEnum::Light(light) = gameitem {
if light.is_backglass {
continue;
}
if !light.name.to_lowercase().starts_with("gi") {
continue;
}
let surface_height =
get_surface_height(vpx, &light.surface, light.center.x, light.center.y);
let mut light_height_offset = light.height.unwrap_or(0.0);
if light_height_offset.abs() < 0.001 {
light_height_offset = mm_to_vpu(10.0);
}
let light_z = surface_height + light_height_offset;
let color = [
light.color.r as f32 / 255.0,
light.color.g as f32 / 255.0,
light.color.b as f32 / 255.0,
];
let intensity = (light.intensity * 0.1).clamp(0.01, 10.0);
let range = calculate_light_range(light);
gltf_lights.push(json!({
"name": light.name,
"type": "point",
"color": color,
"intensity": intensity,
"range": range
}));
game_lights.push((
light.name.clone(),
light.center.x,
light.center.y,
light_z,
get_layer_name(&light.editor_layer_name, light.editor_layer),
));
}
}
let mut scene_root_nodes: Vec<usize> = root_node_indices.clone();
let light_node_0 = json!({
"name": "TableLight0",
"translation": [table_center_x, light_height, light0_z],
"extensions": {
"KHR_lights_punctual": {
"light": 0
}
}
});
let light_node_1 = json!({
"name": "TableLight1",
"translation": [table_center_x, light_height, light1_z],
"extensions": {
"KHR_lights_punctual": {
"light": 1
}
}
});
let light_node_0_idx = nodes.len();
nodes.push(light_node_0);
scene_root_nodes.push(light_node_0_idx);
let light_node_1_idx = nodes.len();
nodes.push(light_node_1);
scene_root_nodes.push(light_node_1_idx);
for (i, (name, x, y, z, _layer_name)) in game_lights.into_iter().enumerate() {
let light_idx = i + 2;
let gltf_x = vpu_to_m(x);
let gltf_y = vpu_to_m(z); let gltf_z = vpu_to_m(y);
let light_node = json!({
"name": format!("{}_light", name),
"translation": [gltf_x, gltf_y, gltf_z],
"extensions": {
"KHR_lights_punctual": {
"light": light_idx
}
}
});
let node_idx = nodes.len();
nodes.push(light_node);
item_group_children
.entry(name.clone())
.or_default()
.push(node_idx);
}
for group_info in item_groups {
let children = item_group_children.remove(&group_info.name);
if let Some(children) = children {
if children.is_empty() {
continue;
}
if children.len() == 1 {
let child_idx = children[0];
nodes[child_idx]["extras"] = group_info.extras;
if let Some(ref layer_name) = group_info.layer_name {
if let Some((_, layer_children)) = layer_groups.get_mut(layer_name) {
layer_children.push(child_idx);
} else {
layer_groups.insert(layer_name.clone(), (usize::MAX, vec![child_idx]));
}
} else {
scene_root_nodes.push(child_idx);
}
} else {
let group_node = json!({
"name": group_info.name,
"children": children,
"extras": group_info.extras
});
let group_node_idx = nodes.len();
nodes.push(group_node);
if let Some(ref layer_name) = group_info.layer_name {
if let Some((_, layer_children)) = layer_groups.get_mut(layer_name) {
layer_children.push(group_node_idx);
} else {
layer_groups.insert(layer_name.clone(), (usize::MAX, vec![group_node_idx]));
}
} else {
scene_root_nodes.push(group_node_idx);
}
}
}
}
for (layer_name, (layer_node_idx, children)) in layer_groups.iter_mut() {
*layer_node_idx = nodes.len();
nodes.push(json!({
"name": layer_name,
"children": children
}));
scene_root_nodes.push(*layer_node_idx);
}
let cameras = GltfCamera::all_from_vpx(vpx);
let gltf_cameras: Vec<_> = cameras.iter().map(|c| c.to_gltf_camera_json()).collect();
for (i, camera) in cameras.iter().enumerate() {
let camera_node_idx = nodes.len();
nodes.push(camera.to_gltf_node_json(i));
scene_root_nodes.push(camera_node_idx);
}
let mut extensions_used = vec!["KHR_lights_punctual"];
if uses_transmission_extension {
extensions_used.push("KHR_materials_transmission");
}
if uses_node_visibility_extension {
extensions_used.push("KHR_node_visibility");
}
let gamedata_json = GameDataJson::from_game_data(&vpx.gamedata);
let gamedata_extras = serde_json::to_value(&gamedata_json).unwrap_or(json!({}));
let root_name = if vpx.gamedata.name.is_empty() {
"Table".to_string()
} else {
vpx.gamedata.name.clone()
};
let root_node_idx = nodes.len();
nodes.push(json!({
"name": root_name,
"children": scene_root_nodes,
"extras": gamedata_extras
}));
let mut gltf_json = json!({
"asset": {
"version": "2.0",
"generator": "vpin"
},
"extensionsUsed": extensions_used,
"extensions": {
"KHR_lights_punctual": {
"lights": gltf_lights
}
},
"scene": 0,
"scenes": [{
"nodes": [root_node_idx]
}],
"nodes": nodes,
"meshes": mesh_json,
"cameras": gltf_cameras,
"accessors": accessors,
"bufferViews": buffer_views,
"buffers": [{
"byteLength": bin_data.len()
}]
});
if !gltf_materials.is_empty() {
gltf_json["materials"] = json!(gltf_materials);
}
if !gltf_textures.is_empty() {
gltf_json["textures"] = json!(gltf_textures);
}
if !gltf_images.is_empty() {
gltf_json["images"] = json!(gltf_images);
}
if !gltf_samplers.is_empty() {
gltf_json["samplers"] = json!(gltf_samplers);
}
Ok((gltf_json, bin_data))
}
fn write_glb<W: io::Write>(
json: &serde_json::Value,
bin_data: &[u8],
writer: &mut W,
) -> io::Result<()> {
let json_string = serde_json::to_string(json)
.map_err(|e| io::Error::other(format!("JSON serialization error: {}", e)))?;
let json_bytes = json_string.as_bytes();
let json_padding = (4 - (json_bytes.len() % 4)) % 4;
let json_padded_length = json_bytes.len() + json_padding;
writer.write_all(GLTF_MAGIC)?;
writer.write_u32::<LittleEndian>(GLTF_VERSION)?;
let total_length = GLB_HEADER_BYTES
+ GLB_CHUNK_HEADER_BYTES
+ json_padded_length as u32
+ GLB_CHUNK_HEADER_BYTES
+ bin_data.len() as u32;
writer.write_u32::<LittleEndian>(total_length)?;
writer.write_u32::<LittleEndian>(json_padded_length as u32)?;
writer.write_all(GLB_JSON_CHUNK_TYPE)?;
writer.write_all(json_bytes)?;
for _ in 0..json_padding {
writer.write_all(b" ")?;
}
writer.write_u32::<LittleEndian>(bin_data.len() as u32)?;
writer.write_all(GLB_BIN_CHUNK_TYPE)?;
writer.write_all(bin_data)?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GltfFormat {
#[default]
Glb,
Gltf,
}
#[derive(Debug, Clone, Default)]
pub struct GltfExportOptions {
pub format: GltfFormat,
pub export_invisible_items: bool,
}
impl GltfExportOptions {
pub fn glb() -> Self {
Self {
format: GltfFormat::Glb,
..Default::default()
}
}
pub fn gltf() -> Self {
Self {
format: GltfFormat::Gltf,
..Default::default()
}
}
pub fn with_export_invisible_items(mut self, export_invisible: bool) -> Self {
self.export_invisible_items = export_invisible;
self
}
}
pub fn export_glb(vpx: &VPX, path: &Path, fs: &dyn FileSystem) -> io::Result<()> {
export_with_options(vpx, path, fs, &GltfExportOptions::glb())
}
pub fn export_gltf(vpx: &VPX, path: &Path, fs: &dyn FileSystem) -> io::Result<()> {
export_with_options(vpx, path, fs, &GltfExportOptions::gltf())
}
pub fn export(vpx: &VPX, path: &Path, fs: &dyn FileSystem, format: GltfFormat) -> io::Result<()> {
export_with_options(
vpx,
path,
fs,
&GltfExportOptions {
format,
..Default::default()
},
)
}
pub fn export_with_options(
vpx: &VPX,
path: &Path,
fs: &dyn FileSystem,
options: &GltfExportOptions,
) -> io::Result<()> {
let (meshes, item_groups) = collect_meshes(vpx, options);
if meshes.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No meshes found in table",
));
}
let materials = collect_materials(vpx);
let playfield_image = find_playfield_image(vpx);
let playfield_material_name = if vpx.gamedata.playfield_material.is_empty() {
PLAYFIELD_MATERIAL_NAME
} else {
&vpx.gamedata.playfield_material
};
let (mut json, bin_data) = build_combined_gltf_payload(
vpx,
&meshes,
item_groups,
&materials,
&vpx.images,
playfield_image,
playfield_material_name,
)?;
match options.format {
GltfFormat::Glb => {
let mut buffer = Vec::new();
write_glb(&json, &bin_data, &mut buffer)?;
fs.write_file(path, &buffer)?;
}
GltfFormat::Gltf => {
let bin_filename = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| format!("{}.bin", s))
.unwrap_or_else(|| "buffer.bin".to_string());
let bin_path = path
.parent()
.unwrap_or_else(|| Path::new(""))
.join(&bin_filename);
if let Some(buffers) = json.get_mut("buffers").and_then(|b| b.as_array_mut())
&& let Some(buffer) = buffers.first_mut()
{
buffer["uri"] = json!(bin_filename);
}
fs.write_file(&bin_path, &bin_data)?;
let json_string = serde_json::to_string_pretty(&json)
.map_err(|e| io::Error::other(format!("JSON serialization error: {}", e)))?;
fs.write_file(path, json_string.as_bytes())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vpx::gameitem::primitive::Primitive;
use crate::vpx::mesh::test_utils::create_minimal_mesh_data;
#[test]
fn test_collect_meshes_empty_vpx_has_implicit_playfield() {
let vpx = VPX::default();
let (meshes, _item_groups) = collect_meshes(&vpx, &GltfExportOptions::default());
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].name, "playfield_mesh");
}
#[test]
fn test_invisible_primitive_skipped_by_default() {
let mut vpx = VPX::default();
let (compressed_vertices, compressed_indices, num_vertices, num_indices) =
create_minimal_mesh_data();
let primitive = Primitive {
name: "invisible_prim".to_string(),
is_visible: false,
compressed_vertices_data: Some(compressed_vertices),
compressed_vertices_len: Some(0),
compressed_indices_data: Some(compressed_indices),
compressed_indices_len: Some(0),
num_vertices: Some(num_vertices),
num_indices: Some(num_indices),
..Default::default()
};
vpx.gameitems.push(GameItemEnum::Primitive(primitive));
let (meshes, _item_groups) = collect_meshes(&vpx, &GltfExportOptions::default());
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].name, "playfield_mesh");
}
#[test]
fn test_invisible_primitive_included_with_option() {
let mut vpx = VPX::default();
let (compressed_vertices, compressed_indices, num_vertices, num_indices) =
create_minimal_mesh_data();
let primitive = Primitive {
name: "invisible_prim".to_string(),
is_visible: false,
compressed_vertices_data: Some(compressed_vertices),
compressed_vertices_len: Some(0),
compressed_indices_data: Some(compressed_indices),
compressed_indices_len: Some(0),
num_vertices: Some(num_vertices),
num_indices: Some(num_indices),
..Default::default()
};
vpx.gameitems.push(GameItemEnum::Primitive(primitive));
let options = GltfExportOptions::default().with_export_invisible_items(true);
let (meshes, _item_groups) = collect_meshes(&vpx, &options);
assert_eq!(meshes.len(), 2);
let invisible_mesh = meshes.iter().find(|m| m.name == "invisible_prim").unwrap();
assert!(
!invisible_mesh.visible,
"Mesh should be marked as not visible"
);
}
#[test]
fn test_export_glb_empty_vpx_succeeds_with_playfield() {
let vpx = VPX::default();
let fs = crate::filesystem::MemoryFileSystem::default();
let result = export_glb(&vpx, Path::new("test.glb"), &fs);
assert!(result.is_ok());
}
#[test]
fn test_export_gltf_empty_vpx_succeeds_with_playfield() {
let vpx = VPX::default();
let fs = crate::filesystem::MemoryFileSystem::default();
let result = export_gltf(&vpx, Path::new("test.gltf"), &fs);
assert!(result.is_ok());
assert!(
fs.read_file(Path::new("test.gltf")).is_ok(),
".gltf file should exist"
);
assert!(
fs.read_file(Path::new("test.bin")).is_ok(),
".bin file should exist"
);
let gltf_content = fs.read_file(Path::new("test.gltf")).unwrap();
let json: serde_json::Value = serde_json::from_slice(&gltf_content).unwrap();
let buffer_uri = json["buffers"][0]["uri"].as_str();
assert_eq!(
buffer_uri,
Some("test.bin"),
"Buffer should reference external .bin file"
);
}
#[test]
fn test_primitive_with_image_and_material_preserves_both() {
let mut vpx = VPX::default();
let (compressed_vertices, compressed_indices, num_vertices, num_indices) =
create_minimal_mesh_data();
let primitive = Primitive {
name: "test_screw".to_string(),
image: "metal_texture".to_string(),
material: "MetalMaterial".to_string(),
is_visible: true,
compressed_vertices_data: Some(compressed_vertices),
compressed_vertices_len: Some(0), compressed_indices_data: Some(compressed_indices),
compressed_indices_len: Some(0), num_vertices: Some(num_vertices),
num_indices: Some(num_indices),
..Default::default()
};
vpx.gameitems.push(GameItemEnum::Primitive(primitive));
let (meshes, _item_groups) = collect_meshes(&vpx, &GltfExportOptions::default());
let test_mesh = meshes.iter().find(|m| m.name == "test_screw");
assert!(test_mesh.is_some(), "test_screw mesh should exist");
let test_mesh = test_mesh.unwrap();
assert_eq!(
test_mesh.material_name,
Some("MetalMaterial".to_string()),
"material_name should be preserved when primitive has both image and material"
);
assert_eq!(
test_mesh.texture_name,
Some("metal_texture".to_string()),
"texture_name should be preserved"
);
}
#[test]
fn test_calculate_playfield_roughness_high_reflection() {
let roughness = calculate_playfield_roughness(1.0, 0.5);
assert!(
(roughness - 0.03).abs() < 0.01,
"reflection 1.0 should give roughness ~0.03, got {}",
roughness
);
let roughness = calculate_playfield_roughness(0.5, 0.5);
assert!(
(roughness - 0.115).abs() < 0.02,
"reflection 0.5 should give roughness ~0.115, got {}",
roughness
);
}
#[test]
fn test_calculate_playfield_roughness_default_vpinball() {
let roughness = calculate_playfield_roughness(0.2, 0.5);
assert!(
(roughness - 0.166).abs() < 0.02,
"reflection 0.2 should give roughness ~0.166, got {}",
roughness
);
}
#[test]
fn test_calculate_playfield_roughness_typical_table() {
let roughness = calculate_playfield_roughness(0.3, 0.5);
assert!(
(roughness - 0.149).abs() < 0.02,
"reflection 0.3 should give roughness ~0.149, got {}",
roughness
);
}
#[test]
fn test_calculate_playfield_roughness_low_reflection_uses_fallback() {
let roughness = calculate_playfield_roughness(0.05, 0.8);
assert_eq!(
roughness, 0.8,
"low reflection should use fallback roughness"
);
let roughness = calculate_playfield_roughness(0.0, 0.7);
assert_eq!(
roughness, 0.7,
"zero reflection should use fallback roughness"
);
}
#[test]
fn test_calculate_playfield_roughness_minimum_clamp() {
let roughness = calculate_playfield_roughness(2.0, 0.5);
assert!(
roughness >= 0.03,
"roughness should be clamped to minimum 0.03, got {}",
roughness
);
}
}