use crate::numerical;
use crate::{AnalyticSurface, Mesh, RecognitionError, SourceMetadata, SurfaceHint, Vec3};
#[derive(Clone, Debug, PartialEq)]
pub struct KernelFaceTruth {
pub mesh_face_id: u32,
pub source_face_id: u64,
pub source_face_name: Option<String>,
pub orientation: i8,
pub surface: AnalyticSurface,
}
fn kernel_carrier_orientation_gauge(source: &brep_kernel::AnalyticSurface) -> i8 {
match source {
brep_kernel::AnalyticSurface::RuledRevolution { height, .. } if *height < 0.0 => -1,
_ => 1,
}
}
fn kernel_face_orientation(source: &brep_kernel::AnalyticSurface, same_sense: bool) -> i8 {
(if same_sense { 1 } else { -1 }) * kernel_carrier_orientation_gauge(source)
}
fn invalid_kernel_analytic(reason: impl Into<String>) -> RecognitionError {
RecognitionError::FitFailed {
surface: None,
reason: reason.into(),
}
}
pub fn surface_from_kernel_analytic(
source: &brep_kernel::AnalyticSurface,
) -> Result<Option<AnalyticSurface>, RecognitionError> {
use brep_kernel::AnalyticSurface as KernelSurface;
let converted = match source {
KernelSurface::Plane {
origin,
u_dir,
v_dir,
..
} => {
let normal = vec3_from_kernel(*u_dir)
.cross(vec3_from_kernel(*v_dir))
.normalized()
.ok_or_else(|| invalid_kernel_analytic("kernel plane has degenerate directions"))?;
AnalyticSurface::Plane(crate::PlaneSurface {
origin: vec3_from_kernel(*origin),
normal,
})
}
KernelSurface::RuledRevolution {
frame,
rho0,
rho1,
height,
} => {
if !rho0.is_finite()
|| !rho1.is_finite()
|| !height.is_finite()
|| *rho0 < 0.0
|| *rho1 < 0.0
{
return Err(invalid_kernel_analytic(
"kernel ruled revolution has invalid radius or height",
));
}
let axis = vec3_from_kernel(frame.axis)
.normalized()
.ok_or_else(|| invalid_kernel_analytic("kernel revolution axis is degenerate"))?;
let origin = vec3_from_kernel(frame.origin);
let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
if (*rho1 - *rho0).abs()
<= numerical::brep::REVOLUTION_EQUAL_RADIUS_RELATIVE * radius_scale
{
if *rho0 <= 0.0 || height.abs() <= numerical::brep::REVOLUTION_MIN_ABSOLUTE_HEIGHT {
return Err(invalid_kernel_analytic(
"kernel cylinder has non-positive radius or zero height",
));
}
AnalyticSurface::Cylinder(crate::CylinderSurface {
axis_origin: origin,
axis,
radius: *rho0,
})
} else {
if height.abs() <= numerical::brep::REVOLUTION_MIN_ABSOLUTE_HEIGHT {
return Err(invalid_kernel_analytic("kernel cone has zero height"));
}
let slope = (*rho1 - *rho0) / *height;
if !slope.is_finite() || slope == 0.0 {
return Err(invalid_kernel_analytic("kernel cone has invalid slope"));
}
let apex_z = -*rho0 / slope;
AnalyticSurface::Cone(crate::ConeSurface {
apex: origin + axis * apex_z,
axis: axis * slope.signum(),
half_angle: slope.abs().atan(),
})
}
}
KernelSurface::Sphere { frame, radius } => {
let _axis = vec3_from_kernel(frame.axis)
.normalized()
.ok_or_else(|| invalid_kernel_analytic("kernel sphere frame is degenerate"))?;
AnalyticSurface::Sphere(crate::SphereSurface {
center: vec3_from_kernel(frame.origin),
radius: *radius,
})
}
KernelSurface::Torus {
frame,
major_radius,
minor_radius,
} => AnalyticSurface::Torus(crate::TorusSurface {
center: vec3_from_kernel(frame.origin),
axis: vec3_from_kernel(frame.axis)
.normalized()
.ok_or_else(|| invalid_kernel_analytic("kernel torus axis is degenerate"))?,
major_radius: *major_radius,
minor_radius: *minor_radius,
}),
KernelSurface::Revolution { .. } => return Ok(None),
};
if !converted.is_valid() {
return Err(invalid_kernel_analytic(
"kernel analytic carrier has invalid primitive parameters",
));
}
Ok(Some(converted))
}
pub fn analytic_truth_from_face(
face: &brep_kernel::FaceRecord,
mesh_face_id: u32,
) -> Result<Option<KernelFaceTruth>, RecognitionError> {
let Some(source) = face.surface.analytic() else {
return Ok(None);
};
let Some(surface) = surface_from_kernel_analytic(source)? else {
return Ok(None);
};
Ok(Some(KernelFaceTruth {
mesh_face_id,
source_face_id: face.id,
source_face_name: face.name.clone(),
orientation: kernel_face_orientation(source, face.same_sense),
surface,
}))
}
pub fn analytic_truths_from_solid(
solid: &brep_kernel::BrepSolid,
) -> Result<Vec<KernelFaceTruth>, RecognitionError> {
let mut truths = Vec::new();
let mut mesh_face_id = 0u32;
for shell in &solid.shells {
for face in &shell.faces {
if let Some(truth) = analytic_truth_from_face(face, mesh_face_id)? {
truths.push(truth);
}
mesh_face_id = mesh_face_id.checked_add(1).ok_or_else(|| {
RecognitionError::InvalidMesh("kernel solid has more than u32::MAX faces".into())
})?;
}
}
Ok(truths)
}
pub fn attach_solid_analytic_metadata(
converted: &mut KernelMeshConversion,
solid: &brep_kernel::BrepSolid,
source_tolerance: Option<f64>,
) -> Result<usize, RecognitionError> {
if source_tolerance.is_some_and(|value| !value.is_finite() || value <= 0.0) {
return Err(RecognitionError::InvalidSelection(
"source tolerance must be finite and positive".into(),
));
}
let start = converted.mesh.source_metadata.len();
for truth in analytic_truths_from_solid(solid)? {
let triangle_indices: Vec<usize> = converted
.triangle_face_ids
.iter()
.enumerate()
.filter_map(|(triangle, &face)| (face == Some(truth.mesh_face_id)).then_some(triangle))
.collect();
if triangle_indices.is_empty() {
continue;
}
converted.mesh.source_metadata.push(SourceMetadata {
version: 1,
triangle_indices,
hint: SurfaceHint::ExactCandidate {
surface: truth.surface,
},
source_face_id: Some(truth.source_face_id),
source_face_name: truth.source_face_name,
source_surface_id: None,
orientation: Some(truth.orientation),
source_tolerance,
});
}
Ok(converted.mesh.source_metadata.len() - start)
}
#[derive(Clone, Debug, PartialEq)]
pub struct KernelMeshConversion {
pub mesh: Mesh,
pub triangle_face_ids: Vec<Option<u32>>,
}
pub fn vec3_from_kernel(value: brep_kernel::Vec3) -> Vec3 {
Vec3::new(value.x, value.y, value.z)
}
pub fn vec3_to_kernel(value: Vec3) -> brep_kernel::Vec3 {
brep_kernel::Vec3::new(value.x, value.y, value.z)
}
pub fn convert_kernel_mesh(
source: &brep_kernel::Mesh,
) -> Result<KernelMeshConversion, RecognitionError> {
if source.positions.is_empty() {
return Err(RecognitionError::InvalidMesh(
"kernel mesh has no positions".into(),
));
}
if !source.positions.len().is_multiple_of(3) {
return Err(RecognitionError::InvalidMesh(
"kernel position buffer must contain xyz triples".into(),
));
}
if source.indices.is_empty() || !source.indices.len().is_multiple_of(3) {
return Err(RecognitionError::InvalidMesh(
"kernel index buffer must contain triangles".into(),
));
}
if source
.positions
.iter()
.any(|coordinate| !coordinate.is_finite())
{
return Err(RecognitionError::InvalidMesh(
"kernel position buffer contains a non-finite coordinate".into(),
));
}
if !source.normals.is_empty() && source.normals.len() != source.positions.len() {
return Err(RecognitionError::InvalidMesh(format!(
"kernel normal buffer has {} coordinates for {} position coordinates",
source.normals.len(),
source.positions.len()
)));
}
if source
.normals
.iter()
.any(|coordinate| !coordinate.is_finite())
{
return Err(RecognitionError::InvalidMesh(
"kernel normal buffer contains a non-finite coordinate".into(),
));
}
let vertex_count = source.positions.len() / 3;
if source
.indices
.iter()
.any(|&index| index as usize >= vertex_count)
{
return Err(RecognitionError::InvalidMesh(
"kernel triangle index is outside the position buffer".into(),
));
}
let triangle_count = source.indices.len() / 3;
if !source.face_ids.is_empty() && source.face_ids.len() != triangle_count {
return Err(RecognitionError::InvalidMesh(format!(
"kernel face-id buffer has {} entries for {triangle_count} triangles",
source.face_ids.len()
)));
}
let vertices = source
.positions
.chunks_exact(3)
.map(|point| Vec3::new(point[0], point[1], point[2]))
.collect();
let vertex_normals = (!source.normals.is_empty()).then(|| {
source
.normals
.chunks_exact(3)
.map(|normal| Vec3::new(normal[0], normal[1], normal[2]))
.collect()
});
let triangles = source
.indices
.chunks_exact(3)
.map(|triangle| [triangle[0], triangle[1], triangle[2]])
.collect();
let triangle_face_ids = if source.face_ids.is_empty() {
vec![None; triangle_count]
} else {
source.face_ids.iter().copied().map(Some).collect()
};
Ok(KernelMeshConversion {
mesh: Mesh {
vertices,
triangles,
vertex_normals,
source_metadata: Vec::new(),
},
triangle_face_ids,
})
}
pub fn mesh_from_kernel(source: &brep_kernel::Mesh) -> Result<Mesh, RecognitionError> {
Ok(convert_kernel_mesh(source)?.mesh)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FiniteInterval {
start: f64,
end: f64,
}
impl FiniteInterval {
pub fn new(start: f64, end: f64) -> Result<Self, RecognitionError> {
let length = end - start;
if !start.is_finite() || !end.is_finite() || !length.is_finite() || length <= 0.0 {
return Err(RecognitionError::InvalidSelection(
"surface interval bounds must be finite and strictly increasing".into(),
));
}
Ok(Self { start, end })
}
pub fn start(self) -> f64 {
self.start
}
pub fn end(self) -> f64 {
self.end
}
pub fn length(self) -> f64 {
self.end - self.start
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FinitePatchBounds {
Plane {
u: FiniteInterval,
v: FiniteInterval,
},
Axial(FiniteInterval),
}
impl FinitePatchBounds {
pub fn plane(
u_start: f64,
u_end: f64,
v_start: f64,
v_end: f64,
) -> Result<Self, RecognitionError> {
Ok(Self::Plane {
u: FiniteInterval::new(u_start, u_end)?,
v: FiniteInterval::new(v_start, v_end)?,
})
}
pub fn axial(start: f64, end: f64) -> Result<Self, RecognitionError> {
Ok(Self::Axial(FiniteInterval::new(start, end)?))
}
}
#[derive(Clone, Debug)]
pub struct OrientedNurbsSurface {
pub surface: brep_kernel::NurbsSurface,
pub orientation: i8,
}
fn native_surface_error(surface: AnalyticSurface, reason: impl Into<String>) -> RecognitionError {
RecognitionError::FitFailed {
surface: Some(surface.surface_type().name()),
reason: format!("native NURBS construction failed: {}", reason.into()),
}
}
pub fn nurbs_surface_from_analytic(
surface: AnalyticSurface,
orientation: i8,
bounds: Option<FinitePatchBounds>,
) -> Result<OrientedNurbsSurface, RecognitionError> {
if !matches!(orientation, -1 | 1) {
return Err(RecognitionError::InvalidSelection(
"surface orientation must be -1 or +1".into(),
));
}
if !surface.is_valid() {
return Err(native_surface_error(surface, "invalid analytic parameters"));
}
let native = match (surface, bounds) {
(AnalyticSurface::Plane(plane), Some(FinitePatchBounds::Plane { u, v })) => {
let (u_direction, v_direction) = plane
.normal
.orthonormal_basis()
.ok_or_else(|| native_surface_error(surface, "invalid plane basis"))?;
let origin = plane.origin + u_direction * u.start() + v_direction * v.start();
brep_kernel::make_plane(
vec3_to_kernel(origin),
vec3_to_kernel(u_direction),
vec3_to_kernel(v_direction),
u.length(),
v.length(),
)
}
(AnalyticSurface::Cylinder(cylinder), Some(FinitePatchBounds::Axial(axial))) => {
let base = cylinder.axis_origin + cylinder.axis * axial.start();
brep_kernel::make_cylinder_surface(
vec3_to_kernel(base),
vec3_to_kernel(cylinder.axis),
cylinder.radius,
axial.length(),
)
}
(AnalyticSurface::Cone(cone), Some(FinitePatchBounds::Axial(axial))) => {
if axial.start() <= 0.0 {
return Err(RecognitionError::InvalidSelection(
"cone axial bounds must lie strictly on the positive nappe".into(),
));
}
let tangent = cone.half_angle.tan();
let base = cone.apex + cone.axis * axial.start();
brep_kernel::make_cone_surface(
vec3_to_kernel(base),
vec3_to_kernel(cone.axis),
axial.start() * tangent,
axial.end() * tangent,
axial.length(),
)
}
(AnalyticSurface::Sphere(sphere), None) => brep_kernel::make_sphere_surface(
vec3_to_kernel(sphere.center),
sphere.radius,
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
),
(AnalyticSurface::Torus(torus), None) => brep_kernel::make_torus_surface(
vec3_to_kernel(torus.center),
vec3_to_kernel(torus.axis),
torus.major_radius,
torus.minor_radius,
),
(AnalyticSurface::Plane(_), _) => {
return Err(RecognitionError::InvalidSelection(
"plane conversion requires plane u/v bounds".into(),
));
}
(AnalyticSurface::Cylinder(_), _) => {
return Err(RecognitionError::InvalidSelection(
"cylinder conversion requires axial bounds".into(),
));
}
(AnalyticSurface::Cone(_), _) => {
return Err(RecognitionError::InvalidSelection(
"cone conversion requires axial bounds".into(),
));
}
(AnalyticSurface::Sphere(_) | AnalyticSurface::Torus(_), Some(_)) => {
return Err(RecognitionError::InvalidSelection(
"sphere and torus conversion use their complete native domains and take no bounds"
.into(),
));
}
}
.map_err(|reason| native_surface_error(surface, reason))?;
Ok(OrientedNurbsSurface {
surface: native,
orientation,
})
}
pub fn tessellate_kernel_solid_with_metadata(
solid: &brep_kernel::BrepSolid,
chord_tolerance: f64,
source_tolerance: Option<f64>,
) -> Result<KernelMeshConversion, RecognitionError> {
if !chord_tolerance.is_finite() || chord_tolerance <= 0.0 {
return Err(RecognitionError::InvalidSelection(
"chord tolerance must be finite and positive".into(),
));
}
let source =
brep_kernel::tessellate_brep_watertight(solid, chord_tolerance).map_err(|error| {
RecognitionError::InvalidMesh(format!("kernel tessellation failed: {error}"))
})?;
let mut converted = convert_kernel_mesh(&source)?;
attach_solid_analytic_metadata(&mut converted, solid, source_tolerance)?;
Ok(converted)
}
#[allow(clippy::too_many_arguments)]
pub fn attach_face_metadata(
converted: &mut KernelMeshConversion,
mesh_face_id: u32,
hint: SurfaceHint,
source_face_id: Option<u64>,
source_face_name: Option<String>,
source_surface_id: Option<String>,
orientation: Option<i8>,
source_tolerance: Option<f64>,
) -> Result<(), RecognitionError> {
if orientation.is_some_and(|sense| !matches!(sense, -1 | 1)) {
return Err(RecognitionError::InvalidMesh(
"source orientation must be -1 or +1".into(),
));
}
let triangle_indices: Vec<usize> = converted
.triangle_face_ids
.iter()
.enumerate()
.filter_map(|(triangle, &face)| (face == Some(mesh_face_id)).then_some(triangle))
.collect();
if triangle_indices.is_empty() {
return Err(RecognitionError::InvalidSelection(format!(
"kernel mesh contains no triangles for sequential face {mesh_face_id}"
)));
}
converted.mesh.source_metadata.push(SourceMetadata {
version: 1,
triangle_indices,
hint,
source_face_id,
source_face_name,
source_surface_id,
orientation,
source_tolerance,
});
Ok(())
}
pub fn region_carrier_from_surface(
surface: AnalyticSurface,
orientation: i8,
) -> Result<brep_kernel::RegionCarrier, RecognitionError> {
if !matches!(orientation, -1 | 1) {
return Err(RecognitionError::InvalidSelection(
"surface orientation must be -1 or +1".into(),
));
}
if !surface.is_valid() {
return Err(RecognitionError::FitFailed {
surface: Some(surface.surface_type().name()),
reason: "cannot convert invalid analytic parameters".into(),
});
}
let sign = orientation as f64;
Ok(match surface {
AnalyticSurface::Plane(plane) => brep_kernel::RegionCarrier::Plane {
origin: vec3_to_kernel(plane.origin),
normal: vec3_to_kernel(plane.normal * sign),
},
AnalyticSurface::Cylinder(cylinder) => brep_kernel::RegionCarrier::Cylinder {
axis_point: vec3_to_kernel(cylinder.axis_origin),
axis_dir: vec3_to_kernel(cylinder.axis),
radius: cylinder.radius,
sense: orientation,
},
AnalyticSurface::Cone(cone) => brep_kernel::RegionCarrier::Cone {
apex: vec3_to_kernel(cone.apex),
axis_dir: vec3_to_kernel(cone.axis),
half_angle_rad: cone.half_angle,
sense: orientation,
},
AnalyticSurface::Sphere(sphere) => brep_kernel::RegionCarrier::Sphere {
center: vec3_to_kernel(sphere.center),
radius: sphere.radius,
sense: orientation,
},
AnalyticSurface::Torus(torus) => brep_kernel::RegionCarrier::Torus {
center: vec3_to_kernel(torus.center),
axis_dir: vec3_to_kernel(torus.axis),
major_radius: torus.major_radius,
minor_radius: torus.minor_radius,
sense: orientation,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CylinderSurface, PlaneSurface, SphereSurface, SurfaceType, TorusSurface};
fn frame(origin: brep_kernel::Vec3, axis: brep_kernel::Vec3) -> brep_kernel::RevolutionFrame {
let x_axis = axis.perpendicular().unwrap();
brep_kernel::RevolutionFrame {
origin,
axis,
x_axis,
y_axis: axis.cross(x_axis).normalized().unwrap(),
}
}
fn close(a: f64, b: f64) {
assert!((a - b).abs() <= 1.0e-12, "{a} != {b}");
}
#[test]
fn vec3_round_trip_is_lossless() {
let point = Vec3::new(1.25, -2.5, 9.0);
let kernel = vec3_to_kernel(point);
assert_eq!(vec3_from_kernel(kernel), point);
}
#[test]
fn mesh_conversion_preserves_indices_and_face_ownership() {
let source = brep_kernel::Mesh {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
normals: vec![0.0, 0.0, 2.0, 0.0, 0.0, 2.0, 0.0, 0.0, 2.0],
indices: vec![0, 1, 2],
face_ids: vec![7],
};
let converted = convert_kernel_mesh(&source).unwrap();
assert_eq!(converted.mesh.triangles, vec![[0, 1, 2]]);
assert_eq!(
converted.mesh.vertex_normals,
Some(vec![Vec3::new(0.0, 0.0, 2.0); 3])
);
assert_eq!(
converted
.mesh
.analyze(&Default::default())
.unwrap()
.vertex_normals,
Some(vec![Vec3::Z; 3])
);
assert_eq!(converted.triangle_face_ids, vec![Some(7)]);
}
#[test]
fn mesh_conversion_rejects_misaligned_face_ids() {
let source = brep_kernel::Mesh {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
normals: vec![],
indices: vec![0, 1, 2],
face_ids: vec![1, 2],
};
assert!(convert_kernel_mesh(&source).is_err());
}
#[test]
fn mesh_conversion_rejects_malformed_kernel_normals() {
let base = brep_kernel::Mesh {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
normals: vec![0.0, 0.0, 1.0],
indices: vec![0, 1, 2],
face_ids: vec![0],
};
assert!(convert_kernel_mesh(&base).is_err());
let mut non_finite = base;
non_finite.normals = vec![0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
non_finite.normals[4] = f64::NAN;
assert!(convert_kernel_mesh(&non_finite).is_err());
}
#[test]
fn metadata_routes_transient_and_stable_face_ids_separately() {
let source = brep_kernel::Mesh {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
normals: vec![],
indices: vec![0, 1, 2],
face_ids: vec![3],
};
let mut converted = convert_kernel_mesh(&source).unwrap();
attach_face_metadata(
&mut converted,
3,
SurfaceHint::KnownType {
surface_type: SurfaceType::Plane,
},
Some(9001),
Some("CAP".into()),
None,
Some(1),
Some(1.0e-7),
)
.unwrap();
let metadata = &converted.mesh.source_metadata[0];
assert_eq!(metadata.triangle_indices, vec![0]);
assert_eq!(metadata.source_face_id, Some(9001));
}
#[test]
fn all_analytic_surfaces_convert_to_native_nurbs_with_separate_orientation() {
let cases = [
(
AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::new(1.0, 2.0, 3.0),
normal: Vec3::new(0.2, -0.3, 0.9).normalized().unwrap(),
}),
Some(FinitePatchBounds::plane(-2.0, 3.0, -1.0, 4.0).unwrap()),
),
(
AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::new(-1.0, 0.5, 2.0),
axis: Vec3::new(0.1, 0.2, 0.97).normalized().unwrap(),
radius: 2.5,
}),
Some(FinitePatchBounds::axial(-3.0, 6.0).unwrap()),
),
(
AnalyticSurface::Cone(crate::ConeSurface {
apex: Vec3::new(0.5, -1.0, 2.0),
axis: Vec3::new(-0.2, 0.3, 0.93).normalized().unwrap(),
half_angle: 0.35,
}),
Some(FinitePatchBounds::axial(1.5, 7.0).unwrap()),
),
(
AnalyticSurface::Sphere(SphereSurface {
center: Vec3::new(3.0, -2.0, 1.0),
radius: 4.0,
}),
None,
),
(
AnalyticSurface::Torus(TorusSurface {
center: Vec3::new(-2.0, 1.0, 0.5),
axis: Vec3::new(0.2, 0.9, -0.3).normalized().unwrap(),
major_radius: 6.0,
minor_radius: 1.25,
}),
None,
),
];
for (surface, bounds) in cases {
let converted = nurbs_surface_from_analytic(surface, -1, bounds).unwrap();
assert_eq!(converted.orientation, -1);
let analytic = converted.surface.analytic().unwrap_or_else(|| {
panic!("{:?} native patch not analytic", surface.surface_type())
});
let recovered = surface_from_kernel_analytic(analytic)
.unwrap()
.unwrap_or_else(|| panic!("{:?} did not round trip", surface.surface_type()));
assert_eq!(recovered.surface_type(), surface.surface_type());
}
}
#[test]
fn native_nurbs_conversion_rejects_missing_or_mismatched_bounds() {
let plane = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::ZERO,
normal: Vec3::Z,
});
assert!(FiniteInterval::new(1.0, 1.0).is_err());
assert!(FiniteInterval::new(f64::NAN, 2.0).is_err());
assert!(nurbs_surface_from_analytic(plane, 1, None).is_err());
assert!(nurbs_surface_from_analytic(
plane,
1,
Some(FinitePatchBounds::axial(0.0, 1.0).unwrap())
)
.is_err());
let cone = AnalyticSurface::Cone(crate::ConeSurface {
apex: Vec3::ZERO,
axis: Vec3::Z,
half_angle: 0.3,
});
assert!(nurbs_surface_from_analytic(
cone,
1,
Some(FinitePatchBounds::axial(-1.0, 2.0).unwrap())
)
.is_err());
}
#[test]
fn all_carriers_convert_with_orientation() {
let cases = [
AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::ZERO,
normal: Vec3::Z,
}),
AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 2.0,
}),
AnalyticSurface::Sphere(SphereSurface {
center: Vec3::ZERO,
radius: 3.0,
}),
AnalyticSurface::Torus(TorusSurface {
center: Vec3::ZERO,
axis: Vec3::Z,
major_radius: 5.0,
minor_radius: 1.0,
}),
AnalyticSurface::Cone(crate::ConeSurface {
apex: Vec3::ZERO,
axis: Vec3::Z,
half_angle: 0.25,
}),
];
for surface in cases {
let carrier = region_carrier_from_surface(surface, -1).unwrap();
assert_eq!(carrier.kind(), surface.surface_type().name());
}
let plane = region_carrier_from_surface(cases[0], -1).unwrap();
let brep_kernel::RegionCarrier::Plane { normal, .. } = plane else {
unreachable!()
};
assert_eq!(normal.z, -1.0);
}
#[test]
fn kernel_plane_truth_uses_parametric_normal_without_face_sense() {
let source = brep_kernel::AnalyticSurface::Plane {
origin: brep_kernel::Vec3::new(2.0, 3.0, 4.0),
u_dir: brep_kernel::Vec3::new(2.0, 0.0, 0.0),
v_dir: brep_kernel::Vec3::new(0.0, -3.0, 0.0),
u_domain: [0.0, 1.0],
v_domain: [0.0, 1.0],
};
let Some(AnalyticSurface::Plane(plane)) = surface_from_kernel_analytic(&source).unwrap()
else {
panic!("expected plane")
};
assert_eq!(plane.origin, Vec3::new(2.0, 3.0, 4.0));
assert_eq!(plane.normal, -Vec3::Z);
}
#[test]
fn ruled_revolution_converts_cylinder_and_signed_slope_cone() {
let cylinder = brep_kernel::AnalyticSurface::RuledRevolution {
frame: frame(
brep_kernel::Vec3::new(1.0, 2.0, 3.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
),
rho0: 4.0,
rho1: 4.0,
height: -7.0,
};
let Some(AnalyticSurface::Cylinder(cylinder)) =
surface_from_kernel_analytic(&cylinder).unwrap()
else {
panic!("expected cylinder")
};
assert_eq!(cylinder.axis_origin, Vec3::new(1.0, 2.0, 3.0));
assert_eq!(cylinder.axis, Vec3::Z);
close(cylinder.radius, 4.0);
let cone = brep_kernel::AnalyticSurface::RuledRevolution {
frame: frame(
brep_kernel::Vec3::new(0.0, 0.0, 0.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
),
rho0: 4.0,
rho1: 2.0,
height: 5.0,
};
let Some(AnalyticSurface::Cone(cone)) = surface_from_kernel_analytic(&cone).unwrap() else {
panic!("expected cone")
};
assert_eq!(cone.apex, Vec3::new(0.0, 0.0, 10.0));
assert_eq!(cone.axis, -Vec3::Z);
close(cone.half_angle, 0.4_f64.atan());
let cone = AnalyticSurface::Cone(cone);
close(cone.signed_distance(Vec3::new(4.0, 0.0, 0.0)), 0.0);
close(cone.signed_distance(Vec3::new(2.0, 0.0, 5.0)), 0.0);
}
#[test]
fn cone_conversion_preserves_nappe_and_parametric_orientation_gauge() {
let origin = brep_kernel::Vec3::new(1.0, -2.0, 0.5);
let axis = brep_kernel::Vec3::new(0.0, 0.0, 1.0);
let rho0 = 3.0;
for (height, delta_radius) in [(5.0, 2.0), (5.0, -2.0), (-5.0, 2.0), (-5.0, -2.0)] {
let kernel_frame = frame(origin, axis);
let source = brep_kernel::AnalyticSurface::RuledRevolution {
frame: kernel_frame.clone(),
rho0,
rho1: rho0 + delta_radius,
height,
};
let Some(AnalyticSurface::Cone(cone)) = surface_from_kernel_analytic(&source).unwrap()
else {
panic!("expected cone")
};
let slope = delta_radius / height;
let expected_axis = vec3_from_kernel(axis) * slope.signum();
let expected_apex = vec3_from_kernel(origin) + vec3_from_kernel(axis) * (-rho0 / slope);
assert_eq!(cone.axis, expected_axis, "height={height}, slope={slope}");
assert_eq!(cone.apex, expected_apex, "height={height}, slope={slope}");
close(cone.half_angle, slope.abs().atan());
let t = 0.37;
let radial = vec3_from_kernel(kernel_frame.x_axis);
let kernel_axis = vec3_from_kernel(kernel_frame.axis);
let point = vec3_from_kernel(kernel_frame.origin)
+ radial * (rho0 + t * delta_radius)
+ kernel_axis * (t * height);
let canonical = AnalyticSurface::Cone(cone);
close(canonical.signed_distance(point), 0.0);
let canonical_normal = canonical.normal_at(point).unwrap();
let parametric_normal = (radial * height - kernel_axis * delta_radius)
.normalized()
.unwrap();
let expected_gauge = if height < 0.0 { -1 } else { 1 };
close(
canonical_normal.dot(parametric_normal),
expected_gauge as f64,
);
assert_eq!(
kernel_carrier_orientation_gauge(&source),
expected_gauge,
"height={height}, slope={slope}"
);
assert_eq!(
kernel_face_orientation(&source, true),
expected_gauge,
"same_sense=true, height={height}, slope={slope}"
);
assert_eq!(
kernel_face_orientation(&source, false),
-expected_gauge,
"same_sense=false, height={height}, slope={slope}"
);
}
}
#[test]
fn primitive_brep_truth_covers_five_types_and_keeps_face_sense_separate() {
let cylinder = brep_kernel::make_cylinder_brep(
brep_kernel::Vec3::new(0.0, 0.0, 0.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
2.0,
4.0,
)
.unwrap();
let truths = analytic_truths_from_solid(&cylinder).unwrap();
assert_eq!(truths.len(), 3);
assert!(truths
.iter()
.any(|truth| matches!(truth.surface, AnalyticSurface::Cylinder(_))));
for truth in &truths {
let face = &cylinder.shells[0].faces[truth.mesh_face_id as usize];
assert_eq!(truth.orientation, if face.same_sense { 1 } else { -1 });
if let AnalyticSurface::Plane(plane) = truth.surface {
let brep_kernel::AnalyticSurface::Plane { u_dir, v_dir, .. } =
face.surface.analytic().unwrap()
else {
unreachable!()
};
let expected = vec3_from_kernel(*u_dir)
.cross(vec3_from_kernel(*v_dir))
.normalized()
.unwrap();
assert_eq!(plane.normal, expected);
}
}
let cone = brep_kernel::make_cone_brep(
brep_kernel::Vec3::new(0.0, 0.0, 0.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
3.0,
1.0,
5.0,
)
.unwrap();
assert!(analytic_truths_from_solid(&cone)
.unwrap()
.iter()
.any(|truth| matches!(truth.surface, AnalyticSurface::Cone(_))));
let sphere = brep_kernel::make_sphere_brep(
brep_kernel::Vec3::new(1.0, 2.0, 3.0),
2.5,
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
)
.unwrap();
assert!(matches!(
analytic_truths_from_solid(&sphere).unwrap()[0].surface,
AnalyticSurface::Sphere(_)
));
let torus = brep_kernel::make_torus_brep(
brep_kernel::Vec3::new(-1.0, 2.0, 0.5),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
5.0,
1.25,
)
.unwrap();
assert!(matches!(
analytic_truths_from_solid(&torus).unwrap()[0].surface,
AnalyticSurface::Torus(_)
));
}
#[test]
fn solid_truth_metadata_matches_tessellator_face_ids() {
let solid = brep_kernel::make_cylinder_brep(
brep_kernel::Vec3::new(0.0, 0.0, 0.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
2.0,
4.0,
)
.unwrap();
let source = brep_kernel::tessellate_brep_watertight(&solid, 0.05).unwrap();
let mut converted = convert_kernel_mesh(&source).unwrap();
let attached =
attach_solid_analytic_metadata(&mut converted, &solid, Some(1.0e-7)).unwrap();
assert_eq!(attached, 3);
assert_eq!(converted.mesh.source_metadata.len(), 3);
for metadata in &converted.mesh.source_metadata {
let stable_id = metadata.source_face_id.unwrap();
let face = solid.shells[0]
.faces
.iter()
.find(|face| face.id == stable_id)
.unwrap();
assert_eq!(
metadata.orientation,
Some(if face.same_sense { 1 } else { -1 })
);
assert!(matches!(metadata.hint, SurfaceHint::ExactCandidate { .. }));
assert!(!metadata.triangle_indices.is_empty());
}
}
#[test]
fn step_round_trip_preserves_convertible_analytic_truth() {
let fixtures = [
(
"cylinder",
brep_kernel::make_cylinder_brep(
brep_kernel::Vec3::new(1.0, -2.0, 0.5),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
2.25,
4.5,
)
.unwrap(),
SurfaceType::Cylinder,
),
(
"cone",
brep_kernel::make_cone_brep(
brep_kernel::Vec3::new(0.0, 0.0, 0.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
3.0,
1.0,
5.0,
)
.unwrap(),
SurfaceType::Cone,
),
(
"sphere",
brep_kernel::make_sphere_brep(
brep_kernel::Vec3::new(2.0, 3.0, 4.0),
1.75,
brep_kernel::Vec3::new(0.0, 1.0, 0.0),
)
.unwrap(),
SurfaceType::Sphere,
),
(
"torus",
brep_kernel::make_torus_brep(
brep_kernel::Vec3::new(-1.0, 0.5, 2.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
4.0,
0.75,
)
.unwrap(),
SurfaceType::Torus,
),
];
for (label, original, expected_type) in fixtures {
let step = brep_kernel::export_step(
std::slice::from_ref(&original),
label,
"millimeter",
"fixed",
)
.unwrap();
let imported = brep_kernel::import_step(&step).unwrap();
assert_eq!(imported.len(), 1, "{label}");
let truths = analytic_truths_from_solid(&imported[0]).unwrap();
assert!(
truths
.iter()
.any(|truth| truth.surface.surface_type() == expected_type),
"{label} STEP round trip lost {expected_type:?}: {truths:?}"
);
for truth in truths {
let face = imported[0]
.shells
.iter()
.flat_map(|shell| &shell.faces)
.nth(truth.mesh_face_id as usize)
.unwrap();
assert_eq!(truth.orientation, if face.same_sense { 1 } else { -1 });
}
}
}
#[test]
fn kernel_tessellation_converts_for_exact_prior_workflow() {
let solid = brep_kernel::make_cylinder_brep(
brep_kernel::Vec3::new(0.0, 0.0, 0.0),
brep_kernel::Vec3::new(0.0, 0.0, 1.0),
2.0,
4.0,
)
.unwrap();
let source = brep_kernel::tessellate_brep_watertight(&solid, 0.05).unwrap();
let mut converted = convert_kernel_mesh(&source).unwrap();
attach_face_metadata(
&mut converted,
0,
SurfaceHint::ExactCandidate {
surface: AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 2.0,
}),
},
Some(solid.shells[0].faces[0].id),
solid.shells[0].faces[0].name.clone(),
None,
Some(if solid.shells[0].faces[0].same_sense {
1
} else {
-1
}),
Some(1.0e-7),
)
.unwrap();
let triangle_indices = converted.mesh.source_metadata[0].triangle_indices.clone();
assert!(!triangle_indices.is_empty());
converted.mesh.analyze(&Default::default()).unwrap();
let options = crate::RecognitionOptions {
distance_tolerance: 1.0e-7,
normal_tolerance: 0.3,
sampling: crate::SamplingMode::Vertices,
..Default::default()
};
let fit = crate::reconstruct_surface(
&converted.mesh,
&triangle_indices,
&converted.mesh.source_metadata[0].hint,
&options,
)
.unwrap();
assert_eq!(fit.diagnostics.path, crate::FitPath::ExactCandidateReused);
assert!(fit.diagnostics.exact_parameters_reused);
let carrier = region_carrier_from_surface(fit.surface, fit.orientation).unwrap();
assert_eq!(carrier.kind(), "cylinder");
}
}