use glam::DVec3;
use indicatrix_cut_core::rough_plan::{
MAX_MESH_TRIANGLES, MeshError, RoughBase,
shape::{
RoughMesh,
hull::{self, HullError, SourceFrame, add_inclusion_mesh},
},
};
use super::{MeshUnit, parse_mesh};
pub const DEFAULT_MARGIN_MM: f64 = 0.3;
pub const MAX_MARGIN_MM: f64 = 10.0;
pub const NOT_A_MESH_ROUGH: &str = "Inclusions can only be added to a rough imported from a mesh \
file. Choose the Mesh rough and import one first.";
pub fn parse_margin_mm(text: &str) -> Result<f64, String> {
if text.trim().is_empty() {
return Ok(DEFAULT_MARGIN_MM);
}
let value: f64 = text
.trim()
.replace(',', ".")
.parse()
.map_err(|_| "The inclusion margin must be a number in mm.".to_owned())?;
if value.is_finite() && (0.0..=MAX_MARGIN_MM).contains(&value) {
Ok(value)
} else {
Err(format!(
"The inclusion margin must be between 0 and {MAX_MARGIN_MM} mm."
))
}
}
fn refusal_text(error: &HullError) -> String {
match error {
HullError::Inclusion(MeshError::InclusionOutside(_)) => format!(
"{error}. The inclusion file is read in the same coordinates and unit as the \
rough's file; check that both come from the same scene."
),
_ => error.to_string(),
}
}
pub fn inclusion_in_rough_frame(
base: &RoughBase,
points: &[DVec3],
triangles: &[[u32; 3]],
) -> Result<RoughMesh, String> {
let RoughBase::Hull { id, .. } = *base else {
return Err(NOT_A_MESH_ROUGH.to_owned());
};
let frame = hull::source_frame(id).unwrap_or(SourceFrame::IDENTITY);
let moved: Vec<DVec3> = points.iter().map(|&p| frame.apply(p)).collect();
RoughMesh::new(&moved, triangles)
.map_err(|error| format!("The inclusion's mesh cannot be used: {error}."))
}
pub fn add_inclusion(
base: &RoughBase,
inclusion: &RoughMesh,
margin_mm: f64,
) -> Result<RoughBase, String> {
add_inclusion_mesh(base, inclusion, margin_mm).map_err(|error| refusal_text(&error))
}
#[must_use]
pub fn inclusion_row_text(index: usize, extents_mm: DVec3, volume_mm3: f64) -> String {
format!(
"Inclusion {}: {:.1} x {:.1} x {:.1} mm, {:.1} mm\u{b3}",
index + 1,
extents_mm.x,
extents_mm.y,
extents_mm.z,
volume_mm3
)
}
#[must_use]
pub fn including_text(count: usize, volume_mm3: f64) -> String {
let noun = if count == 1 {
"inclusion"
} else {
"inclusions"
};
format!(
"including {count} {noun} ({} mm\u{b3})",
volume_mm3.round() as i64
)
}
pub fn inclusion_parts(
bytes: &[u8],
extension: Option<&str>,
unit: MeshUnit,
) -> Result<(Vec<DVec3>, Vec<[u32; 3]>), String> {
let (mut points, triangles, _note) = parse_mesh(bytes, extension)?;
if triangles.is_empty() {
return Err(format!(
"the file has no usable faces. An inclusion must be a closed mesh of triangles, \
at most {MAX_MESH_TRIANGLES} of them"
));
}
if unit != MeshUnit::Millimetre {
let factor = unit.factor_to_mm();
for point in &mut points {
*point *= factor;
}
}
Ok((points, triangles))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesh_io::{
fixtures::{CUBE_OBJ, ascii_stl_of, binary_stl_of, ply_of},
parse_obj_mesh,
};
#[test]
fn an_inclusion_is_read_in_the_chosen_unit() {
let (points, triangles) = parse_obj_mesh(CUBE_OBJ).expect("parses");
let cm: Vec<DVec3> = points.iter().map(|&p| p / 10.0).collect();
let faces: Vec<Vec<u32>> = triangles.iter().map(|t| Vec::from(*t)).collect();
let files: Vec<(&str, Vec<u8>)> = vec![
("a.stl", ascii_stl_of(&cm, &triangles).into_bytes()),
("b.stl", binary_stl_of("solid", &cm, &triangles)),
("c.ply", ply_of("binary_little_endian", &cm, &faces)),
("d.ply", ply_of("ascii", &cm, &faces)),
];
for (name, bytes) in files {
let extension = name.rsplit('.').next();
let (read, tris) =
inclusion_parts(&bytes, extension, MeshUnit::Centimetre).expect(name);
assert_eq!(tris.len(), 12, "{name}");
let top = read.iter().fold(0.0_f64, |m, p| m.max(p.max_element()));
assert!((top - 20.0).abs() < 1e-4, "{name}: {top}");
let (small, _) = inclusion_parts(&bytes, extension, MeshUnit::Millimetre).expect(name);
let top = small.iter().fold(0.0_f64, |m, p| m.max(p.max_element()));
assert!((top - 2.0).abs() < 1e-4, "{name}: {top}");
}
}
#[test]
fn millimetres_are_not_scaled_at_all() {
let (read, _) =
inclusion_parts(CUBE_OBJ.as_bytes(), Some("obj"), MeshUnit::Millimetre).expect("reads");
let (points, _) = parse_obj_mesh(CUBE_OBJ).expect("parses");
assert_eq!(read, points);
}
#[test]
fn a_file_without_triangles_is_not_an_inclusion() {
let cloud = "v 0 0 0\nv 9 0 0\nv 0 9 0\nv 0 0 9\n";
let message = inclusion_parts(cloud.as_bytes(), Some("obj"), MeshUnit::Millimetre)
.expect_err("a point cloud has no inside");
assert!(message.contains("closed mesh"), "{message}");
let mut many = String::from("v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n");
many.push_str(&"f 1 2 3\n".repeat(MAX_MESH_TRIANGLES + 1));
let message = inclusion_parts(many.as_bytes(), Some("obj"), MeshUnit::Millimetre)
.expect_err("too many triangles");
assert!(
message.contains(&MAX_MESH_TRIANGLES.to_string()),
"{message}"
);
}
fn place(
base: &RoughBase,
points: &[DVec3],
triangles: &[[u32; 3]],
margin_mm: f64,
) -> Result<RoughBase, String> {
let mesh = inclusion_in_rough_frame(base, points, triangles)?;
add_inclusion(base, &mesh, margin_mm)
}
fn rough_and_inclusion(
shift: DVec3,
lo: f64,
hi: f64,
) -> (RoughBase, Vec<DVec3>, Vec<[u32; 3]>) {
let (points, triangles) = parse_obj_mesh(CUBE_OBJ).expect("parses");
let rough: Vec<DVec3> = points.iter().map(|&p| p + shift).collect();
let base = indicatrix_cut_core::rough_plan::import_hull(&rough).expect("a cube");
let inner: Vec<DVec3> = points
.iter()
.map(|&p| p * ((hi - lo) / 20.0) + DVec3::splat(lo) + shift)
.collect();
(base, inner, triangles)
}
#[test]
fn an_inclusion_in_the_files_coordinates_is_placed_in_the_rough() {
for shift in [DVec3::ZERO, DVec3::new(100.0, -50.0, 7.5)] {
let (base, inner, triangles) = rough_and_inclusion(shift, 8.0, 12.0);
let placed = place(&base, &inner, &triangles, DEFAULT_MARGIN_MM)
.expect("the inclusion is in the rough");
let RoughBase::Hull { id, .. } = placed else {
panic!("a hull base");
};
let list = hull::inclusion_list(id);
assert_eq!(list.len(), 1);
assert!(
(list[0].centre_mm - DVec3::splat(10.0)).length() < 1e-6,
"{shift}"
);
}
}
#[test]
fn an_inclusion_in_other_coordinates_is_refused_with_the_likely_cause() {
let away = DVec3::new(100.0, 0.0, 0.0);
let (base, inner, triangles) = rough_and_inclusion(away, 8.0, 12.0);
let unshifted: Vec<DVec3> = inner.iter().map(|&p| p - away).collect();
let message =
place(&base, &unshifted, &triangles, 0.0).expect_err("it lies outside the rough");
assert!(
message.contains("not inside the rough's material"),
"{message}"
);
assert!(message.contains("same coordinates and unit"), "{message}");
}
#[test]
fn other_refusals_come_through_in_the_cores_words() {
let (base, inner, triangles) = rough_and_inclusion(DVec3::ZERO, 15.0, 25.0);
let message = place(&base, &inner, &triangles, 0.0).expect_err("it pokes out");
assert!(message.contains("must be cut away"), "{message}");
let block = RoughBase::Block {
x_mm: 20.0,
y_mm: 20.0,
z_mm: 20.0,
};
assert_eq!(
place(&block, &inner, &triangles, 0.0),
Err(NOT_A_MESH_ROUGH.to_owned())
);
}
#[test]
fn the_margin_field_takes_numbers_from_zero_to_ten() {
assert_eq!(parse_margin_mm("0.3"), Ok(0.3));
assert_eq!(parse_margin_mm(" "), Ok(DEFAULT_MARGIN_MM));
assert_eq!(parse_margin_mm(" 0,5 "), Ok(0.5));
assert_eq!(parse_margin_mm("0"), Ok(0.0));
assert_eq!(parse_margin_mm("10"), Ok(10.0));
for bad in ["abc", "-0.1", "10.1", "inf", "NaN"] {
assert!(parse_margin_mm(bad).is_err(), "{bad:?}");
}
assert!((DEFAULT_MARGIN_MM - 0.3).abs() < f64::EPSILON);
}
#[test]
fn the_readouts_name_the_inclusions() {
assert_eq!(
inclusion_row_text(1, DVec3::new(4.6, 4.6, 4.6), 97.336),
"Inclusion 2: 4.6 x 4.6 x 4.6 mm, 97.3 mm\u{b3}"
);
assert_eq!(
including_text(1, 64.2),
"including 1 inclusion (64 mm\u{b3})"
);
assert_eq!(
including_text(2, 128.0),
"including 2 inclusions (128 mm\u{b3})"
);
}
#[test]
fn a_broken_file_names_its_problem() {
let message = inclusion_parts(b"v 1 2\n", Some("obj"), MeshUnit::Millimetre)
.expect_err("a short vertex line");
assert!(message.contains("line 1"), "{message}");
}
}