use crate::print_export::{node_to_mesh, ExportError, Mesh, PrintConfig};
use crate::SdfNode;
use alice_sdf::io::{export_fbx, export_obj, FbxConfig, FbxUpAxis, ObjConfig};
use glam::Vec3;
use std::path::Path;
const ROBLOX_ACCESSORY_MAX_TRIS: usize = 4_000;
const ROBLOX_MESHPART_MAX_TRIS: usize = 10_000;
const ROBLOX_DEFAULT_MAX_SIZE: Vec3 = Vec3::new(10.0, 10.0, 10.0);
const DEGENERATE_EPSILON: f32 = 1e-8;
const MIN_RESOLUTION: usize = 16;
const PROBE_RESOLUTION: usize = 32;
#[derive(Debug, Clone)]
pub struct RobloxConfig {
pub resolution: usize,
pub bounds_min: Vec3,
pub bounds_max: Vec3,
pub scale_studs: f32,
pub max_triangles: usize,
pub max_size_studs: Vec3,
}
impl Default for RobloxConfig {
fn default() -> Self {
Self {
resolution: 128,
bounds_min: Vec3::splat(-2.0),
bounds_max: Vec3::splat(2.0),
scale_studs: 2.0,
max_triangles: ROBLOX_ACCESSORY_MAX_TRIS,
max_size_studs: ROBLOX_DEFAULT_MAX_SIZE,
}
}
}
impl RobloxConfig {
#[must_use]
pub const fn accessory() -> Self {
Self {
resolution: 128,
bounds_min: Vec3::splat(-2.0),
bounds_max: Vec3::splat(2.0),
scale_studs: 2.0,
max_triangles: ROBLOX_ACCESSORY_MAX_TRIS,
max_size_studs: ROBLOX_DEFAULT_MAX_SIZE,
}
}
#[must_use]
pub const fn meshpart() -> Self {
Self {
resolution: 192,
bounds_min: Vec3::splat(-2.0),
bounds_max: Vec3::splat(2.0),
scale_studs: 2.0,
max_triangles: ROBLOX_MESHPART_MAX_TRIS,
max_size_studs: ROBLOX_DEFAULT_MAX_SIZE,
}
}
#[must_use]
pub const fn preview() -> Self {
Self {
resolution: 32,
bounds_min: Vec3::splat(-2.0),
bounds_max: Vec3::splat(2.0),
scale_studs: 2.0,
max_triangles: ROBLOX_ACCESSORY_MAX_TRIS,
max_size_studs: ROBLOX_DEFAULT_MAX_SIZE,
}
}
#[must_use]
pub const fn with_bounds(mut self, min: Vec3, max: Vec3) -> Self {
self.bounds_min = min;
self.bounds_max = max;
self
}
#[must_use]
pub const fn with_scale_studs(mut self, scale: f32) -> Self {
self.scale_studs = scale;
self
}
#[must_use]
pub const fn with_max_triangles(mut self, max: usize) -> Self {
self.max_triangles = max;
self
}
#[must_use]
pub const fn with_max_size_studs(mut self, max: Vec3) -> Self {
self.max_size_studs = max;
self
}
}
#[derive(Debug, Clone)]
pub struct RobloxValidation {
pub triangle_count: usize,
pub vertex_count: usize,
pub bounds_studs: Vec3,
pub is_within_triangle_limit: bool,
pub is_within_size_limit: bool,
pub has_degenerate_faces: bool,
}
impl RobloxValidation {
#[must_use]
pub const fn is_valid(&self) -> bool {
self.is_within_triangle_limit && self.is_within_size_limit && !self.has_degenerate_faces
}
}
impl std::fmt::Display for RobloxValidation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let status = if self.is_valid() { "PASS" } else { "FAIL" };
write!(
f,
"[{}] {} tris, {} verts, bounds: {:.1} x {:.1} x {:.1} studs",
status,
self.triangle_count,
self.vertex_count,
self.bounds_studs.x,
self.bounds_studs.y,
self.bounds_studs.z,
)?;
if !self.is_within_triangle_limit {
write!(f, " [OVER TRI LIMIT]")?;
}
if !self.is_within_size_limit {
write!(f, " [OVER SIZE LIMIT]")?;
}
if self.has_degenerate_faces {
write!(f, " [DEGENERATE FACES]")?;
}
Ok(())
}
}
#[must_use]
pub fn validate_for_roblox(mesh: &Mesh, config: &RobloxConfig) -> RobloxValidation {
let tri_count = mesh.indices.len() / 3;
let vert_count = mesh.vertices.len();
let bounds = compute_bounds_studs(mesh);
let has_degenerate = check_degenerate_faces(mesh);
RobloxValidation {
triangle_count: tri_count,
vertex_count: vert_count,
bounds_studs: bounds,
is_within_triangle_limit: tri_count <= config.max_triangles,
is_within_size_limit: bounds.x <= config.max_size_studs.x
&& bounds.y <= config.max_size_studs.y
&& bounds.z <= config.max_size_studs.z,
has_degenerate_faces: has_degenerate,
}
}
fn compute_bounds_studs(mesh: &Mesh) -> Vec3 {
if mesh.vertices.is_empty() {
return Vec3::ZERO;
}
let mut min = Vec3::splat(f32::MAX);
let mut max = Vec3::splat(f32::MIN);
for v in &mesh.vertices {
min = min.min(v.position);
max = max.max(v.position);
}
max - min
}
fn check_degenerate_faces(mesh: &Mesh) -> bool {
let indices = &mesh.indices;
let verts = &mesh.vertices;
let tri_count = indices.len() / 3;
for i in 0..tri_count {
let a = verts[indices[i * 3] as usize].position;
let b = verts[indices[i * 3 + 1] as usize].position;
let c = verts[indices[i * 3 + 2] as usize].position;
let cross = (b - a).cross(c - a);
if cross.length_squared() < DEGENERATE_EPSILON {
return true;
}
}
false
}
#[must_use]
pub fn node_to_mesh_roblox(node: &SdfNode, config: &RobloxConfig) -> Mesh {
let mesh_at = |resolution: usize| {
node_to_mesh(
node,
&PrintConfig {
resolution,
bounds_min: config.bounds_min,
bounds_max: config.bounds_max,
scale_mm: config.scale_studs,
},
)
};
let cap = config.resolution.max(MIN_RESOLUTION);
let mut resolution = cap.min(PROBE_RESOLUTION);
let mut mesh = mesh_at(resolution);
let tris = mesh.indices.len() / 3;
if tris == 0 {
return mesh;
}
resolution =
refine_resolution(resolution, tris, config.max_triangles).clamp(MIN_RESOLUTION, cap);
if resolution != cap.min(PROBE_RESOLUTION) {
mesh = mesh_at(resolution);
}
while mesh.indices.len() / 3 > config.max_triangles && resolution > MIN_RESOLUTION {
let next = refine_resolution(resolution, mesh.indices.len() / 3, config.max_triangles);
resolution = next.clamp(MIN_RESOLUTION, resolution - 1);
mesh = mesh_at(resolution);
}
mesh
}
fn refine_resolution(resolution: usize, triangles: usize, max_triangles: usize) -> usize {
#[allow(clippy::cast_precision_loss)]
let ratio = (max_triangles as f32 / triangles.max(1) as f32).sqrt();
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
let next = (resolution as f32 * ratio * 0.97) as usize;
next
}
#[derive(Debug, Clone)]
pub struct RobloxExportStats {
pub vertex_count: usize,
pub triangle_count: usize,
pub bounds_studs: Vec3,
pub path: String,
pub validation: RobloxValidation,
}
impl std::fmt::Display for RobloxExportStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} vertices, {} triangles (bounds: {:.1} x {:.1} x {:.1} studs) {}",
self.path,
self.vertex_count,
self.triangle_count,
self.bounds_studs.x,
self.bounds_studs.y,
self.bounds_studs.z,
self.validation,
)
}
}
pub fn node_to_obj_roblox(
node: &SdfNode,
path: impl AsRef<Path>,
config: &RobloxConfig,
) -> Result<RobloxExportStats, ExportError> {
let mesh = node_to_mesh_roblox(node, config);
if mesh.indices.is_empty() {
return Err(ExportError::EmptyMesh);
}
let validation = validate_for_roblox(&mesh, config);
let obj_config = ObjConfig {
export_normals: true,
export_uvs: true,
export_materials: false,
flip_uv_v: false,
};
export_obj(&mesh, &path, &obj_config, None)?;
Ok(build_stats(&mesh, &path, validation))
}
pub fn node_to_fbx_roblox(
node: &SdfNode,
path: impl AsRef<Path>,
config: &RobloxConfig,
) -> Result<RobloxExportStats, ExportError> {
let mesh = node_to_mesh_roblox(node, config);
if mesh.indices.is_empty() {
return Err(ExportError::EmptyMesh);
}
let validation = validate_for_roblox(&mesh, config);
let fbx_config = FbxConfig {
export_normals: true,
export_uvs: true,
export_materials: false,
up_axis: FbxUpAxis::Y,
..FbxConfig::default()
};
export_fbx(&mesh, &path, &fbx_config, None)?;
Ok(build_stats(&mesh, &path, validation))
}
pub fn lol_to_obj_roblox(
lol_text: &str,
path: impl AsRef<Path>,
config: &RobloxConfig,
) -> Result<RobloxExportStats, ExportError> {
let node = crate::runtime_parser::parse_lol(lol_text)?;
node_to_obj_roblox(&node, path, config)
}
pub fn lol_to_fbx_roblox(
lol_text: &str,
path: impl AsRef<Path>,
config: &RobloxConfig,
) -> Result<RobloxExportStats, ExportError> {
let node = crate::runtime_parser::parse_lol(lol_text)?;
node_to_fbx_roblox(&node, path, config)
}
fn build_stats(
mesh: &Mesh,
path: &impl AsRef<Path>,
validation: RobloxValidation,
) -> RobloxExportStats {
RobloxExportStats {
vertex_count: mesh.vertices.len(),
triangle_count: mesh.indices.len() / 3,
bounds_studs: validation.bounds_studs,
path: path.as_ref().display().to_string(),
validation,
}
}