vpin 0.23.5

Rust library for working with Visual Pinball VPX files
Documentation
//! Hit target mesh generation for expanded VPX export
//!
//! This module ports the hit target mesh generation from Visual Pinball's hittarget.cpp.
//! Hit targets use pre-defined meshes that are scaled and transformed based on
//! the target's parameters (position, size, rotation).
//!
//! There are 9 target types:
//! - DropTargetBeveled (T2)
//! - DropTargetSimple (T3)
//! - DropTargetFlatSimple (T4)
//! - HitTargetRound
//! - HitTargetRectangle
//! - HitFatTargetRectangle
//! - HitFatTargetSquare
//! - HitTargetSlim (T1)
//! - HitFatTargetSlim (T2 slim)
//!
//! Ported from: VPinball/src/parts/hittarget.cpp

mod drop_target_t2_mesh;
mod drop_target_t3_mesh;
mod drop_target_t4_mesh;
mod hit_target_fat_rectangle_mesh;
mod hit_target_fat_square_mesh;
mod hit_target_rectangle_mesh;
mod hit_target_round_mesh;
mod hit_target_t1_slim_mesh;
mod hit_target_t2_slim_mesh;

use crate::vpx::gameitem::hittarget::{HitTarget, TargetType};
use crate::vpx::gameitem::primitive::VertexWrapper;
use crate::vpx::model::Vertex3dNoTex2;
use crate::vpx::obj::VpxFace;

use crate::vpx::math::{Mat3, Vec3};
use drop_target_t2_mesh::{DROP_TARGET_T2_INDICES, DROP_TARGET_T2_MESH};
use drop_target_t3_mesh::{DROP_TARGET_T3_INDICES, DROP_TARGET_T3_MESH};
use drop_target_t4_mesh::{DROP_TARGET_T4_INDICES, DROP_TARGET_T4_MESH};
use hit_target_fat_rectangle_mesh::{
    HIT_TARGET_FAT_RECTANGLE_INDICES, HIT_TARGET_FAT_RECTANGLE_MESH,
};
use hit_target_fat_square_mesh::{HIT_TARGET_FAT_SQUARE_INDICES, HIT_TARGET_FAT_SQUARE_MESH};
use hit_target_rectangle_mesh::{HIT_TARGET_RECTANGLE_INDICES, HIT_TARGET_RECTANGLE_MESH};
use hit_target_round_mesh::{HIT_TARGET_ROUND_INDICES, HIT_TARGET_ROUND_MESH};
use hit_target_t1_slim_mesh::{HIT_TARGET_T1_SLIM_INDICES, HIT_TARGET_T1_SLIM_MESH};
use hit_target_t2_slim_mesh::{HIT_TARGET_T2_SLIM_INDICES, HIT_TARGET_T2_SLIM_MESH};

/// Get the mesh data for a target type
fn get_mesh_for_type(target_type: &TargetType) -> (&'static [Vertex3dNoTex2], &'static [u16]) {
    match target_type {
        TargetType::DropTargetBeveled => (&DROP_TARGET_T2_MESH, &DROP_TARGET_T2_INDICES),
        TargetType::DropTargetSimple => (&DROP_TARGET_T3_MESH, &DROP_TARGET_T3_INDICES),
        TargetType::DropTargetFlatSimple => (&DROP_TARGET_T4_MESH, &DROP_TARGET_T4_INDICES),
        TargetType::HitTargetRound => (&HIT_TARGET_ROUND_MESH, &HIT_TARGET_ROUND_INDICES),
        TargetType::HitTargetRectangle => {
            (&HIT_TARGET_RECTANGLE_MESH, &HIT_TARGET_RECTANGLE_INDICES)
        }
        TargetType::HitFatTargetRectangle => (
            &HIT_TARGET_FAT_RECTANGLE_MESH,
            &HIT_TARGET_FAT_RECTANGLE_INDICES,
        ),
        TargetType::HitFatTargetSquare => {
            (&HIT_TARGET_FAT_SQUARE_MESH, &HIT_TARGET_FAT_SQUARE_INDICES)
        }
        TargetType::HitTargetSlim => (&HIT_TARGET_T1_SLIM_MESH, &HIT_TARGET_T1_SLIM_INDICES),
        TargetType::HitFatTargetSlim => (&HIT_TARGET_T2_SLIM_MESH, &HIT_TARGET_T2_SLIM_INDICES),
    }
}

/// Generate hit target mesh based on the target parameters
///
/// From VPinball HitTarget::GenerateMesh
///
/// The transformation is:
/// 1. Scale vertex by size (x, y, z)
/// 2. Rotate by rot_z around Z axis
///
/// Note: Position translation is NOT applied to vertices. Use the target's
/// `position` field for glTF node transform.
///
/// # Arguments
/// * `target` - The hit target definition
///
/// # Returns
/// A tuple of (vertices, faces) for the target mesh, or None if not visible.
pub fn build_hit_target_mesh(target: &HitTarget) -> Option<(Vec<VertexWrapper>, Vec<VpxFace>)> {
    if !target.is_visible {
        return None;
    }

    let (mesh, indices) = get_mesh_for_type(&target.target_type);
    let full_matrix = Mat3::rotate_z(target.rot_z.to_radians());

    let vertices: Vec<VertexWrapper> = mesh
        .iter()
        .map(|v| {
            // Scale by size
            let mut vert = Vec3 {
                x: v.x * target.size.x,
                y: v.y * target.size.y,
                z: v.z * target.size.z,
            };

            // Rotate by rot_z
            vert = full_matrix.multiply_vector(vert);

            // Note: Position translation is NOT applied here - use target.position
            // for glTF node transform

            // Transform normal (rotation only, no translation)
            let norm = full_matrix.multiply_vector_no_translate(Vec3 {
                x: v.nx,
                y: v.ny,
                z: v.nz,
            });

            VertexWrapper {
                vpx_encoded_vertex: [0u8; 32], // Not used for generated meshes
                vertex: Vertex3dNoTex2 {
                    x: vert.x,
                    y: vert.y,
                    z: vert.z,
                    nx: norm.x,
                    ny: norm.y,
                    nz: norm.z,
                    tu: v.tu,
                    tv: v.tv,
                },
            }
        })
        .collect();

    // Convert indices to faces (triangles)
    let faces: Vec<VpxFace> = indices
        .chunks(3)
        .map(|chunk| VpxFace {
            i0: chunk[0] as i64,
            i1: chunk[1] as i64,
            i2: chunk[2] as i64,
        })
        .collect();

    Some((vertices, faces))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vpx::gameitem::vertex3d::Vertex3D;

    fn make_test_target(target_type: TargetType, is_visible: bool) -> HitTarget {
        HitTarget {
            position: Vertex3D::new(100.0, 200.0, 0.0),
            size: Vertex3D::new(32.0, 32.0, 32.0),
            rot_z: 0.0,
            target_type,
            is_visible,
            ..Default::default()
        }
    }

    #[test]
    fn test_build_hit_target_mesh_drop_target_beveled() {
        let target = make_test_target(TargetType::DropTargetBeveled, true);

        let result = build_hit_target_mesh(&target);
        assert!(result.is_some());

        let (vertices, faces) = result.unwrap();
        assert!(!vertices.is_empty());
        assert!(!faces.is_empty());

        // Vertices are now centered at origin (position not baked in)
        // They should be within the scaled mesh bounds around origin
        for v in &vertices {
            // Vertices should be around origin, scaled by size
            assert!(v.vertex.x > -50.0 && v.vertex.x < 50.0);
            assert!(v.vertex.y > -50.0 && v.vertex.y < 50.0);
        }
    }

    #[test]
    fn test_build_hit_target_mesh_invisible() {
        let target = make_test_target(TargetType::DropTargetBeveled, false);

        let result = build_hit_target_mesh(&target);
        assert!(result.is_none());
    }

    #[test]
    fn test_build_hit_target_mesh_all_types() {
        let types = [
            TargetType::DropTargetBeveled,
            TargetType::DropTargetSimple,
            TargetType::DropTargetFlatSimple,
            TargetType::HitTargetRound,
            TargetType::HitTargetRectangle,
            TargetType::HitFatTargetRectangle,
            TargetType::HitFatTargetSquare,
            TargetType::HitTargetSlim,
            TargetType::HitFatTargetSlim,
        ];

        for target_type in types {
            let target = make_test_target(target_type.clone(), true);

            let result = build_hit_target_mesh(&target);
            assert!(
                result.is_some(),
                "Failed to generate mesh for {:?}",
                target_type
            );
        }
    }
}