mod bulb_light_mesh;
mod bulb_socket_mesh;
use crate::vpx::TableDimensions;
use crate::vpx::gameitem::light::Light;
use crate::vpx::gameitem::primitive::VertexWrapper;
use crate::vpx::math::Vec3;
use crate::vpx::mesh::{detail_level_to_accuracy, get_rg_vertex_2d, polygon_to_triangles};
use crate::vpx::model::Vertex3dNoTex2;
use crate::vpx::obj::VpxFace;
use bulb_light_mesh::{BULB_LIGHT_INDICES, BULB_LIGHT_VERTICES};
use bulb_socket_mesh::{BULB_SOCKET_INDICES, BULB_SOCKET_VERTICES};
pub struct LightMeshes {
pub bulb: Option<(Vec<VertexWrapper>, Vec<VpxFace>)>,
pub socket: Option<(Vec<VertexWrapper>, Vec<VpxFace>)>,
}
pub fn build_light_meshes(light: &Light) -> Option<LightMeshes> {
if light.is_backglass {
return None;
}
if !light.show_bulb_mesh {
return None;
}
let mesh_radius = light.mesh_radius;
let bulb = build_bulb_mesh(mesh_radius);
let socket = build_socket_mesh(mesh_radius);
Some(LightMeshes {
bulb: Some(bulb),
socket: Some(socket),
})
}
pub fn build_light_insert_mesh(
light: &Light,
table_dims: &TableDimensions,
) -> Option<(Vec<VertexWrapper>, Vec<VpxFace>, Vec3)> {
if light.is_backglass || light.drag_points.len() < 3 {
return None;
}
let accuracy = detail_level_to_accuracy(10.0);
let vvertex = get_rg_vertex_2d(&light.drag_points, accuracy, true);
if vvertex.len() < 3 {
return None;
}
let mut minx = f32::MAX;
let mut miny = f32::MAX;
let mut maxx = f32::MIN;
let mut maxy = f32::MIN;
for v in &vvertex {
minx = minx.min(v.x);
maxx = maxx.max(v.x);
miny = miny.min(v.y);
maxy = maxy.max(v.y);
}
let center_x = (minx + maxx) * 0.5;
let center_y = (miny + maxy) * 0.5;
let has_image = !light.image.is_empty();
let inv_tablewidth = {
let w = table_dims.right - table_dims.left;
if w.abs() > 1e-6 { 1.0 / w } else { 0.0 }
};
let inv_tableheight = {
let h = table_dims.bottom - table_dims.top;
if h.abs() > 1e-6 { 1.0 / h } else { 0.0 }
};
let max_dist = vvertex.iter().fold(0.0_f32, |acc, v| {
let dx = v.x - light.center.x;
let dy = v.y - light.center.y;
acc.max(dx * dx + dy * dy)
});
let inv_maxdist = if max_dist > 0.0 {
0.5 / max_dist.sqrt()
} else {
0.0
};
let vertices: Vec<VertexWrapper> = vvertex
.iter()
.map(|v| {
let (tu, tv) = if has_image {
(v.x * inv_tablewidth, v.y * inv_tableheight)
} else {
(
0.5 + (v.x - light.center.x) * inv_maxdist,
0.5 + (v.y - light.center.y) * inv_maxdist,
)
};
let vertex = Vertex3dNoTex2 {
x: v.x - center_x,
y: v.y - center_y,
z: 0.0,
nx: 0.0,
ny: 0.0,
nz: 1.0, tu,
tv,
};
VertexWrapper::new(vertex.to_vpx_bytes(), vertex)
})
.collect();
let indices = polygon_to_triangles(&vvertex);
if indices.is_empty() {
return None;
}
let faces: Vec<VpxFace> = indices
.chunks_exact(3)
.map(|tri| VpxFace::new(tri[0] as i64, tri[1] as i64, tri[2] as i64))
.collect();
Some((vertices, faces, Vec3::new(center_x, center_y, 0.0)))
}
fn build_bulb_mesh(mesh_radius: f32) -> (Vec<VertexWrapper>, Vec<VpxFace>) {
let vertices: Vec<VertexWrapper> = BULB_LIGHT_VERTICES
.iter()
.map(|v| {
let vertex = Vertex3dNoTex2 {
x: v.x * mesh_radius,
y: v.y * mesh_radius,
z: v.z * mesh_radius,
nx: v.nx,
ny: v.ny,
nz: v.nz,
tu: v.tu,
tv: v.tv,
};
VertexWrapper::new([0u8; 32], vertex)
})
.collect();
let indices: Vec<VpxFace> = BULB_LIGHT_INDICES
.chunks(3)
.map(|chunk| VpxFace::new(chunk[0] as i64, chunk[1] as i64, chunk[2] as i64))
.collect();
(vertices, indices)
}
fn build_socket_mesh(mesh_radius: f32) -> (Vec<VertexWrapper>, Vec<VpxFace>) {
let vertices: Vec<VertexWrapper> = BULB_SOCKET_VERTICES
.iter()
.map(|v| {
let vertex = Vertex3dNoTex2 {
x: v.x * mesh_radius,
y: v.y * mesh_radius,
z: v.z * mesh_radius,
nx: v.nx,
ny: v.ny,
nz: v.nz,
tu: v.tu,
tv: v.tv,
};
VertexWrapper::new([0u8; 32], vertex)
})
.collect();
let indices: Vec<VpxFace> = BULB_SOCKET_INDICES
.chunks(3)
.map(|chunk| VpxFace::new(chunk[0] as i64, chunk[1] as i64, chunk[2] as i64))
.collect();
(vertices, indices)
}
pub(crate) fn write_light_meshes(
gameitems_dir: &std::path::Path,
light: &Light,
json_file_name: &str,
mesh_format: crate::vpx::expanded::PrimitiveMeshFormat,
fs: &dyn crate::filesystem::FileSystem,
) -> Result<(), crate::vpx::expanded::WriteError> {
use crate::vpx::expanded::WriteError;
use crate::vpx::gltf::{GltfContainer, write_gltf};
use crate::vpx::obj::write_obj;
use std::io;
let Some(meshes) = build_light_meshes(light) else {
return Ok(());
};
let file_name_without_ext = json_file_name.trim_end_matches(".json");
if let Some((vertices, indices)) = meshes.bulb {
let mesh_name = format!("{}-bulb", file_name_without_ext);
match mesh_format {
crate::vpx::expanded::PrimitiveMeshFormat::Obj => {
let path = gameitems_dir.join(format!("{}.obj", mesh_name));
write_obj(&mesh_name, &vertices, &indices, &path, fs)
.map_err(|e| WriteError::Io(io::Error::other(format!("{e}"))))?;
}
crate::vpx::expanded::PrimitiveMeshFormat::Glb => {
let path = gameitems_dir.join(format!("{}.glb", mesh_name));
write_gltf(
&mesh_name,
&vertices,
&indices,
&path,
GltfContainer::Glb,
fs,
)
.map_err(|e| WriteError::Io(io::Error::other(format!("{e}"))))?;
}
crate::vpx::expanded::PrimitiveMeshFormat::Gltf => {
let path = gameitems_dir.join(format!("{}.gltf", mesh_name));
write_gltf(
&mesh_name,
&vertices,
&indices,
&path,
GltfContainer::Gltf,
fs,
)
.map_err(|e| WriteError::Io(io::Error::other(format!("{e}"))))?;
}
}
}
if let Some((vertices, indices)) = meshes.socket {
let mesh_name = format!("{}-socket", file_name_without_ext);
match mesh_format {
crate::vpx::expanded::PrimitiveMeshFormat::Obj => {
let path = gameitems_dir.join(format!("{}.obj", mesh_name));
write_obj(&mesh_name, &vertices, &indices, &path, fs)
.map_err(|e| WriteError::Io(io::Error::other(format!("{e}"))))?;
}
crate::vpx::expanded::PrimitiveMeshFormat::Glb => {
let path = gameitems_dir.join(format!("{}.glb", mesh_name));
write_gltf(
&mesh_name,
&vertices,
&indices,
&path,
GltfContainer::Glb,
fs,
)
.map_err(|e| WriteError::Io(io::Error::other(format!("{e}"))))?;
}
crate::vpx::expanded::PrimitiveMeshFormat::Gltf => {
let path = gameitems_dir.join(format!("{}.gltf", mesh_name));
write_gltf(
&mesh_name,
&vertices,
&indices,
&path,
GltfContainer::Gltf,
fs,
)
.map_err(|e| WriteError::Io(io::Error::other(format!("{e}"))))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vpx::gameitem::light::Light;
fn create_test_light(show_bulb_mesh: bool) -> Light {
let mut light = Light::default();
light.center.x = 100.0;
light.center.y = 200.0;
light.height = Some(50.0);
light.name = "TestLight".to_string();
light.is_backglass = false;
light.show_bulb_mesh = show_bulb_mesh;
light.mesh_radius = 20.0;
light
}
fn default_table_dims() -> TableDimensions {
TableDimensions::new(0.0, 0.0, 952.0, 2162.0)
}
#[test]
fn test_build_light_meshes_with_bulb() {
let light = create_test_light(true);
let meshes = build_light_meshes(&light);
assert!(meshes.is_some());
let meshes = meshes.unwrap();
assert!(meshes.bulb.is_some());
let (vertices, indices) = meshes.bulb.unwrap();
assert_eq!(vertices.len(), 67);
assert_eq!(indices.len(), 120);
assert!(meshes.socket.is_some());
let (vertices, indices) = meshes.socket.unwrap();
assert_eq!(vertices.len(), 592);
assert_eq!(indices.len(), 1128); }
#[test]
fn test_build_light_meshes_without_bulb() {
let light = create_test_light(false);
let meshes = build_light_meshes(&light);
assert!(meshes.is_none());
}
#[test]
fn test_build_light_meshes_backglass() {
let mut light = create_test_light(true);
light.is_backglass = true;
let meshes = build_light_meshes(&light);
assert!(meshes.is_none());
}
#[test]
fn test_mesh_transformation() {
let light = create_test_light(true);
let meshes = build_light_meshes(&light).unwrap();
let (vertices, _) = meshes.bulb.unwrap();
for v in &vertices {
assert!(
v.vertex.x.abs() < light.mesh_radius * 2.0,
"x should be centered at origin"
);
assert!(
v.vertex.y.abs() < light.mesh_radius * 2.0,
"y should be centered at origin"
);
}
}
#[test]
fn test_build_light_insert_mesh_with_drag_points() {
use crate::vpx::gameitem::dragpoint::DragPoint;
let mut light = create_test_light(false);
light.drag_points = vec![
DragPoint {
x: 90.0,
y: 190.0,
..Default::default()
},
DragPoint {
x: 110.0,
y: 190.0,
..Default::default()
},
DragPoint {
x: 110.0,
y: 210.0,
..Default::default()
},
DragPoint {
x: 90.0,
y: 210.0,
..Default::default()
},
];
let result = build_light_insert_mesh(&light, &default_table_dims());
assert!(result.is_some());
let (vertices, indices, center) = result.unwrap();
assert_eq!(vertices.len(), 4);
assert_eq!(indices.len(), 2);
assert!((center.x - 100.0).abs() < 0.01);
assert!((center.y - 200.0).abs() < 0.01);
assert!((center.z - 0.0).abs() < 0.01);
for v in &vertices {
assert!(
v.vertex.x.abs() <= 10.01,
"x should be within +-10 of origin, got {}",
v.vertex.x
);
assert!(
v.vertex.y.abs() <= 10.01,
"y should be within +-10 of origin, got {}",
v.vertex.y
);
assert_eq!(v.vertex.z, 0.0, "z should be 0 for flat insert mesh");
}
}
#[test]
fn test_build_light_insert_mesh_no_drag_points() {
let light = create_test_light(false);
let result = build_light_insert_mesh(&light, &default_table_dims());
assert!(result.is_none());
}
#[test]
fn test_build_light_insert_mesh_backglass() {
use crate::vpx::gameitem::dragpoint::DragPoint;
let mut light = create_test_light(false);
light.is_backglass = true;
light.drag_points = vec![
DragPoint {
x: 0.0,
y: 0.0,
..Default::default()
},
DragPoint {
x: 10.0,
y: 0.0,
..Default::default()
},
DragPoint {
x: 10.0,
y: 10.0,
..Default::default()
},
];
let result = build_light_insert_mesh(&light, &default_table_dims());
assert!(result.is_none());
}
#[test]
fn test_light_insert_vertex_normals_match_geometric_face_normals_after_gltf_transform() {
use crate::vpx::gameitem::dragpoint::DragPoint;
use crate::vpx::mesh::mesh_validation::check_normal_consistency_gltf;
let mut light = create_test_light(false);
light.drag_points = vec![
DragPoint {
x: 90.0,
y: 190.0,
..Default::default()
},
DragPoint {
x: 110.0,
y: 190.0,
..Default::default()
},
DragPoint {
x: 110.0,
y: 210.0,
..Default::default()
},
DragPoint {
x: 90.0,
y: 210.0,
..Default::default()
},
];
let (vertices, faces, _center) =
build_light_insert_mesh(&light, &default_table_dims()).unwrap();
let inconsistent = check_normal_consistency_gltf(&vertices, &faces);
assert!(
inconsistent.is_empty(),
"Light insert has {} faces where vertex normals disagree with geometric face normals after glTF transform (causes Blender Cycles black rendering): {:?}",
inconsistent.len(),
inconsistent
);
}
}