use crate::error::{Error, Result};
#[cfg(feature = "mesh-ops")]
use crate::mesh_ops;
use crate::model::{Extension, Model};
use std::collections::HashSet;
pub fn validate_mesh_geometry(model: &Model) -> Result<()> {
for object in &model.resources.objects {
if let Some(ref mesh) = object.mesh {
if !mesh.triangles.is_empty() && mesh.vertices.is_empty() {
return Err(Error::InvalidModel(format!(
"Object {}: Mesh has {} triangle(s) but no vertices. \
A mesh with triangles must also have vertex data. \
Check that the <vertices> element contains <vertex> elements.",
object.id,
mesh.triangles.len()
)));
}
let num_vertices = mesh.vertices.len();
for (tri_idx, triangle) in mesh.triangles.iter().enumerate() {
if triangle.v1 >= num_vertices {
return Err(Error::InvalidModel(format!(
"Object {}: Triangle {} vertex v1={} is out of bounds (mesh has {} vertices, valid indices: 0-{}). \
Vertex indices must reference valid vertices in the mesh. \
Check that all triangle vertex indices are less than the vertex count.",
object.id,
tri_idx,
triangle.v1,
num_vertices,
num_vertices.saturating_sub(1)
)));
}
if triangle.v2 >= num_vertices {
return Err(Error::InvalidModel(format!(
"Object {}: Triangle {} vertex v2={} is out of bounds (mesh has {} vertices, valid indices: 0-{}). \
Vertex indices must reference valid vertices in the mesh. \
Check that all triangle vertex indices are less than the vertex count.",
object.id,
tri_idx,
triangle.v2,
num_vertices,
num_vertices.saturating_sub(1)
)));
}
if triangle.v3 >= num_vertices {
return Err(Error::InvalidModel(format!(
"Object {}: Triangle {} vertex v3={} is out of bounds (mesh has {} vertices, valid indices: 0-{}). \
Vertex indices must reference valid vertices in the mesh. \
Check that all triangle vertex indices are less than the vertex count.",
object.id,
tri_idx,
triangle.v3,
num_vertices,
num_vertices.saturating_sub(1)
)));
}
if triangle.v1 == triangle.v2
|| triangle.v2 == triangle.v3
|| triangle.v1 == triangle.v3
{
return Err(Error::InvalidModel(format!(
"Object {}: Triangle {} is degenerate (v1={}, v2={}, v3={}). \
All three vertices of a triangle must be distinct. \
Degenerate triangles with repeated vertices are not allowed in 3MF models.",
object.id, tri_idx, triangle.v1, triangle.v2, triangle.v3
)));
}
}
if mesh.triangles.len() >= 2 {
validate_mesh_manifold(object.id, mesh)?;
}
}
}
Ok(())
}
pub fn validate_mesh_manifold(object_id: usize, mesh: &crate::model::Mesh) -> Result<()> {
use std::collections::HashMap;
let mut edge_count: HashMap<(usize, usize), usize> =
HashMap::with_capacity(mesh.triangles.len() * 2);
for triangle in &mesh.triangles {
let edges = [
(triangle.v1.min(triangle.v2), triangle.v1.max(triangle.v2)),
(triangle.v2.min(triangle.v3), triangle.v2.max(triangle.v3)),
(triangle.v3.min(triangle.v1), triangle.v3.max(triangle.v1)),
];
for edge in &edges {
*edge_count.entry(*edge).or_insert(0) += 1;
}
}
for (edge, count) in edge_count {
if count > 2 {
return Err(Error::InvalidModel(format!(
"Object {}: Non-manifold edge (vertices {}-{}) is shared by {} triangles (maximum 2 allowed). \
Manifold meshes require each edge to be shared by at most 2 triangles. \
This is often caused by T-junctions or overlapping faces. \
Use mesh repair tools to fix non-manifold geometry.",
object_id, edge.0, edge.1, count
)));
}
}
Ok(())
}
pub fn validate_build_references(model: &Model) -> Result<()> {
let valid_object_ids: HashSet<usize> =
model.resources.objects.iter().map(|obj| obj.id).collect();
for (item_idx, item) in model.build.items.iter().enumerate() {
if item.production_path.is_some() {
continue;
}
if !valid_object_ids.contains(&item.objectid) {
return Err(Error::InvalidModel(format!(
"Build item {} references non-existent object ID: {}. \
All build items must reference objects defined in the resources section. \
Available object IDs: {:?}",
item_idx, item.objectid, valid_object_ids
)));
}
}
Ok(())
}
pub fn validate_component_references(model: &Model) -> Result<()> {
let valid_object_ids: HashSet<usize> = model.resources.objects.iter().map(|o| o.id).collect();
let encrypted_paths: HashSet<&str> = if let Some(ref sc_info) = model.secure_content {
sc_info.encrypted_files.iter().map(|s| s.as_str()).collect()
} else {
HashSet::new()
};
for object in &model.resources.objects {
for component in &object.components {
let is_encrypted_reference = if let Some(ref path) = component.path {
encrypted_paths.contains(path.as_str())
} else {
false
};
if is_encrypted_reference {
continue;
}
if component
.production
.as_ref()
.is_some_and(|p| p.path.is_some())
{
continue;
}
if !valid_object_ids.contains(&component.objectid) {
let available_ids = sorted_ids_from_set(&valid_object_ids);
return Err(Error::InvalidModel(format!(
"Object {}: Component references non-existent object ID {}.\n\
Available object IDs: {:?}\n\
Hint: Ensure the referenced object exists in the <resources> section.",
object.id, component.objectid, available_ids
)));
}
}
}
for object in &model.resources.objects {
if !object.components.is_empty() {
let mut visited = HashSet::new();
let mut path = Vec::new();
if let Some(cycle_path) =
detect_circular_components(object.id, model, &mut visited, &mut path)?
{
return Err(Error::InvalidModel(format!(
"Circular component reference: {}",
cycle_path
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(" → ")
)));
}
}
}
Ok(())
}
pub fn detect_circular_components(
object_id: usize,
model: &Model,
visited: &mut HashSet<usize>,
path: &mut Vec<usize>,
) -> Result<Option<Vec<usize>>> {
if let Some(cycle_start) = path.iter().position(|&id| id == object_id) {
let mut cycle_path = path[cycle_start..].to_vec();
cycle_path.push(object_id);
return Ok(Some(cycle_path));
}
if visited.contains(&object_id) {
return Ok(None);
}
visited.insert(object_id);
path.push(object_id);
if let Some(object) = model.resources.objects.iter().find(|o| o.id == object_id) {
for component in &object.components {
let has_external_path = component
.production
.as_ref()
.is_some_and(|p| p.path.is_some());
if has_external_path {
continue;
}
if let Some(cycle) =
detect_circular_components(component.objectid, model, visited, path)?
{
return Ok(Some(cycle));
}
}
}
path.pop();
visited.remove(&object_id);
Ok(None)
}
pub fn validate_component_properties(model: &Model) -> Result<()> {
for object in &model.resources.objects {
if !object.components.is_empty() {
if object.pid.is_some() {
return Err(Error::InvalidModel(format!(
"Object {} contains components and cannot have pid attribute",
object.id
)));
}
if object.pindex.is_some() {
return Err(Error::InvalidModel(format!(
"Object {} contains components and cannot have pindex attribute",
object.id
)));
}
}
}
Ok(())
}
pub(crate) fn sorted_ids_from_set(ids: &HashSet<usize>) -> Vec<usize> {
let mut sorted: Vec<usize> = ids.iter().copied().collect();
sorted.sort();
sorted
}
pub(crate) fn validate_required_structure(model: &Model) -> Result<()> {
let has_local_objects =
!model.resources.objects.is_empty() || !model.resources.slice_stacks.is_empty();
let has_external_objects = model
.build
.items
.iter()
.any(|item| item.production_path.is_some());
if !has_local_objects && !has_external_objects {
return Err(Error::InvalidModel(
"Model must contain at least one object. \
A valid 3MF file requires either:\n\
- At least one <object> element within the <resources> section, OR\n\
- At least one build <item> with a p:path attribute (Production extension) \
referencing an external file.\n\
Check that your 3MF file has proper model content."
.to_string(),
));
}
let is_external_file = !model.resources.slice_stacks.is_empty()
&& (model.resources.objects.is_empty() || model.build.items.is_empty());
if model.build.items.is_empty() && !is_external_file {
return Err(Error::InvalidModel(
"Build section must contain at least one item. \
A valid 3MF file requires at least one <item> element within the <build> section. \
The build section specifies which objects should be printed."
.to_string(),
));
}
Ok(())
}
pub(crate) fn validate_required_extensions(model: &Model) -> Result<()> {
let mut uses_boolean_ops = false;
let mut objects_with_boolean_and_material_props = Vec::new();
for object in &model.resources.objects {
if object.boolean_shape.is_some() {
uses_boolean_ops = true;
if object.pid.is_some() || object.pindex.is_some() {
objects_with_boolean_and_material_props.push(object.id);
}
}
}
if uses_boolean_ops {
let has_bo_extension = model
.required_extensions
.contains(&Extension::BooleanOperations);
if !has_bo_extension {
return Err(Error::InvalidModel(
"Model uses boolean operations (<booleanshape>) but does not declare \
the Boolean Operations extension in requiredextensions.\n\
Per 3MF Boolean Operations spec, you must add 'bo' to the requiredextensions \
attribute in the <model> element when using boolean operations.\n\
Example: requiredextensions=\"bo\""
.to_string(),
));
}
}
if !objects_with_boolean_and_material_props.is_empty() {
return Err(Error::InvalidModel(format!(
"Objects {:?} contain both <booleanshape> and pid/pindex attributes.\n\
Per 3MF Boolean Operations spec section 2 (Object Resources):\n\
'producers MUST NOT assign pid or pindex attributes to objects that contain booleanshape.'\n\
Remove the pid/pindex attributes from these objects or remove the boolean shape.",
objects_with_boolean_and_material_props
)));
}
Ok(())
}
pub(crate) fn validate_object_ids(model: &Model) -> Result<()> {
let mut seen_ids = HashSet::new();
for object in &model.resources.objects {
if object.id == 0 {
return Err(Error::InvalidModel(
"Object ID must be a positive integer (greater than 0). \
Per the 3MF specification, object IDs must be positive integers. \
Found object with ID = 0, which is invalid."
.to_string(),
));
}
if !seen_ids.insert(object.id) {
return Err(Error::InvalidModel(format!(
"Duplicate object ID found: {}. \
Each object in the resources section must have a unique ID attribute. \
Check your model for multiple objects with the same ID value.",
object.id
)));
}
}
Ok(())
}
pub(crate) fn validate_transform_matrices(model: &Model) -> Result<()> {
let sliced_object_ids: HashSet<usize> = model
.resources
.objects
.iter()
.filter_map(|obj| obj.slicestackid.map(|_| obj.id))
.collect();
for (idx, item) in model.build.items.iter().enumerate() {
if sliced_object_ids.contains(&item.objectid) {
continue;
}
if let Some(ref transform) = item.transform {
let m00 = transform[0];
let m01 = transform[1];
let m02 = transform[2];
let m10 = transform[3];
let m11 = transform[4];
let m12 = transform[5];
let m20 = transform[6];
let m21 = transform[7];
let m22 = transform[8];
let det = m00 * (m11 * m22 - m12 * m21) - m01 * (m10 * m22 - m12 * m20)
+ m02 * (m10 * m21 - m11 * m20);
const DET_EPSILON: f64 = 1e-10;
if det.abs() < DET_EPSILON {
return Err(Error::InvalidModel(format!(
"Build item {}: Transform matrix has zero determinant ({:.6}), indicating a singular (non-invertible) transformation.\n\
Transform: [{} {} {} {} {} {} {} {} {} {} {} {}]\n\
Hint: Check that the transform matrix is valid and non-degenerate.",
idx,
det,
transform[0],
transform[1],
transform[2],
transform[3],
transform[4],
transform[5],
transform[6],
transform[7],
transform[8],
transform[9],
transform[10],
transform[11]
)));
}
if det < 0.0 {
return Err(Error::InvalidModel(format!(
"Build item {}: Transform matrix has negative determinant ({:.6}).\n\
Per 3MF spec, transforms with negative determinants (mirror transformations) \
are not allowed as they would invert the object's orientation.\n\
Transform: [{} {} {} {} {} {} {} {} {} {} {} {}]",
idx,
det,
transform[0],
transform[1],
transform[2],
transform[3],
transform[4],
transform[5],
transform[6],
transform[7],
transform[8],
transform[9],
transform[10],
transform[11]
)));
}
}
}
Ok(())
}
#[cfg(feature = "mesh-ops")]
pub(crate) fn validate_mesh_volume(model: &Model) -> Result<()> {
for object in &model.resources.objects {
if object.slicestackid.is_some() {
continue;
}
if let Some(ref mesh) = object.mesh {
let volume = mesh_ops::compute_mesh_signed_volume(mesh)?;
const EPSILON: f64 = 1e-10;
if volume < -EPSILON {
return Err(Error::InvalidModel(format!(
"Object {}: Mesh has negative volume ({}), indicating inverted or incorrectly oriented triangles",
object.id, volume
)));
}
}
}
Ok(())
}
#[cfg(not(feature = "mesh-ops"))]
pub(crate) fn validate_mesh_volume(_model: &Model) -> Result<()> {
Ok(())
}
pub(crate) fn validate_vertex_order(_model: &Model) -> Result<()> {
Ok(())
}
pub(crate) fn validate_thumbnail_jpeg_colorspace(_model: &Model) -> Result<()> {
Ok(())
}
pub(crate) fn validate_dtd_declaration(_model: &Model) -> Result<()> {
Ok(())
}
pub(crate) fn validate_thumbnail_format(_model: &Model) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
BuildItem, Component, Extension, Mesh, Model, Object, ObjectType, Triangle, Vertex,
};
fn make_simple_mesh() -> Mesh {
let mut mesh = Mesh::new();
mesh.vertices.push(Vertex::new(0.0, 0.0, 0.0));
mesh.vertices.push(Vertex::new(1.0, 0.0, 0.0));
mesh.vertices.push(Vertex::new(0.0, 1.0, 0.0));
mesh.triangles.push(Triangle::new(0, 1, 2));
mesh
}
#[test]
fn test_required_structure_no_objects_fails() {
let model = Model::new(); let result = validate_required_structure(&model);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at least one object")
);
}
#[test]
fn test_required_structure_no_build_items_fails() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.mesh = Some(make_simple_mesh());
model.resources.objects.push(obj);
let result = validate_required_structure(&model);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at least one item")
);
}
#[test]
fn test_required_structure_external_objects_ok() {
let mut model = Model::new();
let mut item = BuildItem::new(0);
item.production_path = Some("/3D/part.model".to_string());
model.build.items.push(item);
let result = validate_required_structure(&model);
assert!(result.is_ok());
}
#[test]
fn test_required_structure_valid_model_ok() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.mesh = Some(make_simple_mesh());
model.resources.objects.push(obj);
model.build.items.push(BuildItem::new(1));
assert!(validate_required_structure(&model).is_ok());
}
#[test]
fn test_required_extensions_boolean_ops_without_declaration_fails() {
use crate::model::{BooleanOpType, BooleanShape};
let mut model = Model::new();
let mut obj = Object::new(1);
obj.boolean_shape = Some(BooleanShape::new(0, BooleanOpType::Union));
model.resources.objects.push(obj);
let result = validate_required_extensions(&model);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("boolean operations")
);
}
#[test]
fn test_required_extensions_boolean_ops_with_pid_fails() {
use crate::model::{BooleanOpType, BooleanShape};
let mut model = Model::new();
model.required_extensions.push(Extension::BooleanOperations);
let mut obj = Object::new(1);
obj.boolean_shape = Some(BooleanShape::new(0, BooleanOpType::Union));
obj.pid = Some(5); model.resources.objects.push(obj);
let result = validate_required_extensions(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pid or pindex"));
}
#[test]
fn test_required_extensions_boolean_ops_with_pindex_fails() {
use crate::model::{BooleanOpType, BooleanShape};
let mut model = Model::new();
model.required_extensions.push(Extension::BooleanOperations);
let mut obj = Object::new(1);
obj.boolean_shape = Some(BooleanShape::new(0, BooleanOpType::Union));
obj.pindex = Some(0); model.resources.objects.push(obj);
let result = validate_required_extensions(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pid or pindex"));
}
#[test]
fn test_required_extensions_boolean_ops_declared_ok() {
use crate::model::{BooleanOpType, BooleanShape};
let mut model = Model::new();
model.required_extensions.push(Extension::BooleanOperations);
let mut obj = Object::new(1);
obj.boolean_shape = Some(BooleanShape::new(0, BooleanOpType::Union));
model.resources.objects.push(obj);
assert!(validate_required_extensions(&model).is_ok());
}
#[test]
fn test_required_extensions_no_boolean_ok() {
let model = Model::new();
assert!(validate_required_extensions(&model).is_ok());
}
#[test]
fn test_component_properties_pid_with_components_fails() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.components.push(Component::new(2));
obj.pid = Some(5); model.resources.objects.push(obj);
let result = validate_component_properties(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pid attribute"));
}
#[test]
fn test_component_properties_pindex_with_components_fails() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.components.push(Component::new(2));
obj.pindex = Some(0); model.resources.objects.push(obj);
let result = validate_component_properties(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pindex attribute"));
}
#[test]
fn test_component_properties_no_components_with_pid_ok() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.pid = Some(5);
obj.pindex = Some(0);
model.resources.objects.push(obj);
assert!(validate_component_properties(&model).is_ok());
}
#[test]
fn test_mesh_manifold_non_manifold_edge_fails() {
let mut mesh = Mesh::new();
mesh.vertices.push(Vertex::new(0.0, 0.0, 0.0)); mesh.vertices.push(Vertex::new(1.0, 0.0, 0.0)); mesh.vertices.push(Vertex::new(0.0, 1.0, 0.0)); mesh.vertices.push(Vertex::new(0.0, 0.0, 1.0)); mesh.vertices.push(Vertex::new(1.0, 1.0, 0.0));
mesh.triangles.push(Triangle::new(0, 1, 2));
mesh.triangles.push(Triangle::new(0, 1, 3));
mesh.triangles.push(Triangle::new(0, 1, 4));
let result = validate_mesh_manifold(1, &mesh);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Non-manifold edge")
);
}
#[test]
fn test_mesh_manifold_valid_two_triangle_edge_ok() {
let mut mesh = Mesh::new();
mesh.vertices.push(Vertex::new(0.0, 0.0, 0.0)); mesh.vertices.push(Vertex::new(1.0, 0.0, 0.0)); mesh.vertices.push(Vertex::new(0.0, 1.0, 0.0)); mesh.vertices.push(Vertex::new(1.0, 1.0, 0.0));
mesh.triangles.push(Triangle::new(0, 1, 2));
mesh.triangles.push(Triangle::new(1, 3, 2));
assert!(validate_mesh_manifold(1, &mesh).is_ok());
}
#[test]
fn test_transform_singular_matrix_fails() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.mesh = Some(make_simple_mesh());
model.resources.objects.push(obj);
let mut item = BuildItem::new(1);
item.transform = Some([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
model.build.items.push(item);
let result = validate_transform_matrices(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("zero determinant"));
}
#[test]
fn test_transform_valid_identity_ok() {
let mut model = Model::new();
let mut obj = Object::new(1);
obj.mesh = Some(make_simple_mesh());
model.resources.objects.push(obj);
let mut item = BuildItem::new(1);
item.transform = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]);
model.build.items.push(item);
assert!(validate_transform_matrices(&model).is_ok());
}
#[test]
fn test_object_ids_unique_ok() {
let mut model = Model::new();
model.resources.objects.push(Object::new(1));
model.resources.objects.push(Object::new(2));
assert!(validate_object_ids(&model).is_ok());
}
#[test]
fn test_build_references_with_production_path_skips() {
let mut model = Model::new();
let mut item = BuildItem::new(99);
item.production_path = Some("/path/to/external.model".to_string());
model.build.items.push(item);
assert!(validate_build_references(&model).is_ok());
}
#[test]
fn test_sorted_ids_from_set() {
let mut set = std::collections::HashSet::new();
set.insert(5usize);
set.insert(1usize);
set.insert(3usize);
let sorted = sorted_ids_from_set(&set);
assert_eq!(sorted, vec![1, 3, 5]);
}
#[test]
fn test_mesh_geometry_non_manifold_via_validate() {
let mut model = Model::new();
let mut obj = Object::new(1);
let mut mesh = Mesh::new();
mesh.vertices.push(Vertex::new(0.0, 0.0, 0.0)); mesh.vertices.push(Vertex::new(1.0, 0.0, 0.0)); mesh.vertices.push(Vertex::new(0.0, 1.0, 0.0)); mesh.vertices.push(Vertex::new(0.0, 0.0, 1.0)); mesh.vertices.push(Vertex::new(1.0, 1.0, 0.0)); mesh.triangles.push(Triangle::new(0, 1, 2));
mesh.triangles.push(Triangle::new(0, 1, 3));
mesh.triangles.push(Triangle::new(0, 1, 4));
obj.mesh = Some(mesh);
model.resources.objects.push(obj);
let result = validate_mesh_geometry(&model);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Non-manifold edge")
);
}
#[test]
fn test_detect_circular_no_object_is_ok() {
let model = Model::new();
let mut visited = std::collections::HashSet::new();
let mut path = Vec::new();
let result = detect_circular_components(999, &model, &mut visited, &mut path);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_boolean_shape_surface_type_fails() {
use crate::model::{BooleanOpType, BooleanShape};
let mut model = Model::new();
let mut obj = Object::new(1);
obj.object_type = ObjectType::Surface;
obj.boolean_shape = Some(BooleanShape::new(0, BooleanOpType::Difference));
model.resources.objects.push(obj);
let result = validate_required_extensions(&model);
assert!(result.is_err());
}
}