use brepkit_math::nurbs::surface::NurbsSurface;
use brepkit_math::vec::{Point3, Vec3};
#[derive(Debug, Clone, PartialEq)]
pub enum RecognizedSurface {
Plane {
normal: Vec3,
d: f64,
},
Cylinder {
origin: Point3,
axis: Vec3,
radius: f64,
},
Sphere {
center: Point3,
radius: f64,
},
Cone {
apex: Point3,
axis: Vec3,
half_angle: f64,
},
Torus {
center: Point3,
axis: Vec3,
major_radius: f64,
minor_radius: f64,
},
NotRecognized,
}
#[must_use]
pub fn recognize_surface(surface: &NurbsSurface, tolerance: f64) -> RecognizedSurface {
if let Some((normal, d)) = try_recognize_plane(surface, tolerance) {
return RecognizedSurface::Plane { normal, d };
}
if let Some((origin, axis, radius)) = try_recognize_cylinder(surface, tolerance) {
return RecognizedSurface::Cylinder {
origin,
axis,
radius,
};
}
if let Some((center, radius)) = try_recognize_sphere(surface, tolerance) {
return RecognizedSurface::Sphere { center, radius };
}
if let Some((apex, axis, half_angle)) = try_recognize_cone(surface, tolerance) {
return RecognizedSurface::Cone {
apex,
axis,
half_angle,
};
}
if let Some((center, axis, major_radius, minor_radius)) =
try_recognize_torus(surface, tolerance)
{
return RecognizedSurface::Torus {
center,
axis,
major_radius,
minor_radius,
};
}
RecognizedSurface::NotRecognized
}
fn try_recognize_plane(surface: &NurbsSurface, tolerance: f64) -> Option<(Vec3, f64)> {
let cps = surface.control_points();
if cps.is_empty() || cps[0].is_empty() {
return None;
}
let mut all_pts: Vec<Point3> = Vec::new();
for row in cps {
for pt in row {
all_pts.push(*pt);
}
}
if all_pts.len() < 3 {
return None;
}
let p0 = all_pts[0];
let mut normal: Option<Vec3> = None;
'outer: for i in 1..all_pts.len() {
let v1 = all_pts[i] - p0;
for pt in all_pts.iter().skip(i + 1) {
let v2 = *pt - p0;
let n = v1.cross(v2);
if n.length() > tolerance
&& let Ok(normalized) = n.normalize()
{
normal = Some(normalized);
break 'outer;
}
}
}
let n = normal?;
let d = n.dot(Vec3::new(p0.x(), p0.y(), p0.z()));
for pt in &all_pts {
let dist = n.dot(Vec3::new(pt.x(), pt.y(), pt.z())) - d;
if dist.abs() > tolerance {
return None;
}
}
Some((n, d))
}
#[allow(clippy::items_after_statements)]
fn try_recognize_cylinder(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, Vec3, f64)> {
let cps = surface.control_points();
if cps.len() < 2 {
return None;
}
for row in cps {
if row.len() < 2 {
return None;
}
}
let mut axis_sum = Vec3::new(0.0, 0.0, 0.0);
for row in cps {
let v = row[row.len() - 1] - row[0];
axis_sum += v;
}
#[allow(clippy::cast_precision_loss)]
let axis_avg = axis_sum * (1.0 / cps.len() as f64);
let axis_len = axis_avg.length();
if axis_len < tolerance {
return None;
}
let axis = axis_avg.normalize().ok()?;
let (u0, u1) = surface.domain_u();
let (v0, v1) = surface.domain_v();
const N: usize = 8;
let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
for iu in 0..N {
#[allow(clippy::cast_precision_loss)]
let u = u0 + (u1 - u0) * (iu as f64) / ((N - 1) as f64);
for iv in 0..N {
#[allow(clippy::cast_precision_loss)]
let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
samples.push(surface.evaluate(u, v));
}
}
let ref_pt = samples[0];
let perp1 = {
let trial = if axis.x().abs() < 0.9 {
Vec3::new(1.0, 0.0, 0.0)
} else {
Vec3::new(0.0, 1.0, 0.0)
};
let p = trial - axis * axis.dot(trial);
p.normalize().unwrap_or(Vec3::new(1.0, 0.0, 0.0))
};
let perp2 = axis.cross(perp1);
let pts_2d: Vec<(f64, f64)> = samples
.iter()
.map(|pt| {
let v = *pt - ref_pt;
(perp1.dot(v), perp2.dot(v))
})
.collect();
let mut ata = [[0.0_f64; 3]; 3];
let mut atb = [0.0_f64; 3];
for &(x, y) in &pts_2d {
let rhs = x * x + y * y;
let row = [2.0 * x, 2.0 * y, 1.0];
for i in 0..3 {
for j in 0..3 {
ata[i][j] += row[i] * row[j];
}
atb[i] += row[i] * rhs;
}
}
let sol = solve_3x3(ata, atb)?;
let cx = sol[0];
let cy = sol[1];
let origin = ref_pt + perp1 * cx + perp2 * cy;
let mut radii: Vec<f64> = Vec::with_capacity(samples.len());
for pt in &samples {
let to_pt = *pt - origin;
let along = axis.dot(to_pt);
let radial = to_pt - axis * along;
radii.push(radial.length());
}
if radii.is_empty() {
return None;
}
let sum: f64 = radii.iter().sum();
#[allow(clippy::cast_precision_loss)]
let mean_radius = sum / radii.len() as f64;
if mean_radius < tolerance {
return None; }
let max_dev = radii
.iter()
.map(|r| (r - mean_radius).abs())
.fold(0.0_f64, f64::max);
if max_dev > tolerance {
return None;
}
Some((origin, axis, mean_radius))
}
#[allow(clippy::items_after_statements)]
fn try_recognize_sphere(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, f64)> {
let (u0, u1) = surface.domain_u();
let (v0, v1) = surface.domain_v();
const N: usize = 8;
let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
for iu in 0..N {
#[allow(clippy::cast_precision_loss)]
let u = u0 + (u1 - u0) * (iu as f64) / ((N - 1) as f64);
for iv in 0..N {
#[allow(clippy::cast_precision_loss)]
let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
samples.push(surface.evaluate(u, v));
}
}
if samples.len() < 4 {
return None;
}
let sq = |p: Point3| p.x() * p.x() + p.y() * p.y() + p.z() * p.z();
let n = samples.len();
let mut ata = [[0.0_f64; 3]; 3];
let mut atb = [0.0_f64; 3];
let p0 = samples[0];
let sq0 = sq(p0);
for i in 1..n {
let pi = samples[i];
let a_row = [
2.0 * (pi.x() - p0.x()),
2.0 * (pi.y() - p0.y()),
2.0 * (pi.z() - p0.z()),
];
let bi = sq(pi) - sq0;
for r in 0..3 {
for c in 0..3 {
ata[r][c] += a_row[r] * a_row[c];
}
atb[r] += a_row[r] * bi;
}
}
let center = solve_3x3(ata, atb)?;
let center_pt = Point3::new(center[0], center[1], center[2]);
let mut distances: Vec<f64> = Vec::with_capacity(n);
for pt in &samples {
let d = Vec3::new(
pt.x() - center_pt.x(),
pt.y() - center_pt.y(),
pt.z() - center_pt.z(),
)
.length();
distances.push(d);
}
let sum: f64 = distances.iter().sum();
#[allow(clippy::cast_precision_loss)]
let mean_radius = sum / distances.len() as f64;
if mean_radius < tolerance {
return None;
}
let max_dev = distances
.iter()
.map(|d| (d - mean_radius).abs())
.fold(0.0_f64, f64::max);
if max_dev > tolerance {
return None;
}
Some((center_pt, mean_radius))
}
fn try_recognize_cone(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, Vec3, f64)> {
const N: usize = 8;
let cps = surface.control_points();
if cps.len() < 2 {
return None;
}
for row in cps {
if row.len() < 2 {
return None;
}
}
let n_rows = cps.len();
let row_count = if n_rows >= 3 && (cps[0][0] - cps[n_rows - 1][0]).length() < tolerance {
n_rows - 1
} else {
n_rows
};
let mut axis_sum = Vec3::new(0.0, 0.0, 0.0);
for row in cps.iter().take(row_count) {
let v = row[row.len() - 1] - row[0];
axis_sum += v;
}
#[allow(clippy::cast_precision_loss)]
let axis_avg = axis_sum * (1.0 / row_count as f64);
if axis_avg.length() < tolerance {
return None;
}
let axis = axis_avg.normalize().ok()?;
let (u0, u1) = surface.domain_u();
let (v0, v1) = surface.domain_v();
let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
for iu in 0..N {
#[allow(clippy::cast_precision_loss)]
let u = u0 + (u1 - u0) * (iu as f64 + 0.5) / (N as f64);
for iv in 0..N {
#[allow(clippy::cast_precision_loss)]
let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
samples.push(surface.evaluate(u, v));
}
}
#[allow(clippy::cast_precision_loss)]
let inv_n = 1.0 / samples.len() as f64;
let mut anchor_x = 0.0_f64;
let mut anchor_y = 0.0_f64;
let mut anchor_z = 0.0_f64;
for p in &samples {
anchor_x += p.x();
anchor_y += p.y();
anchor_z += p.z();
}
let anchor = Point3::new(anchor_x * inv_n, anchor_y * inv_n, anchor_z * inv_n);
let mut axials: Vec<f64> = Vec::with_capacity(samples.len());
let mut radials: Vec<f64> = Vec::with_capacity(samples.len());
for p in &samples {
let to_p = *p - anchor;
let along = axis.dot(to_p);
let radial_vec = to_p - axis * along;
axials.push(along);
radials.push(radial_vec.length());
}
let max_r = radials.iter().fold(0.0_f64, |m, &r| m.max(r));
let min_r = radials.iter().fold(f64::INFINITY, |m, &r| m.min(r));
if max_r - min_r < tolerance {
return None; }
let n_f = samples.len() as f64;
let sum_a: f64 = axials.iter().sum();
let sum_r: f64 = radials.iter().sum();
let mean_a = sum_a / n_f;
let mean_r = sum_r / n_f;
let mut s_aa = 0.0_f64;
let mut s_ar = 0.0_f64;
for i in 0..samples.len() {
let da = axials[i] - mean_a;
let dr = radials[i] - mean_r;
s_aa += da * da;
s_ar += da * dr;
}
if s_aa < 1e-30 {
return None;
}
let slope = s_ar / s_aa;
let intercept = mean_r - slope * mean_a;
if slope.abs() < tolerance {
return None; }
let axial_apex = -intercept / slope;
for i in 0..samples.len() {
let pred = slope * axials[i] + intercept;
if (radials[i] - pred).abs() > tolerance {
return None;
}
}
let half_angle = (1.0 / slope.abs()).atan();
if !(0.0 < half_angle && half_angle < std::f64::consts::FRAC_PI_2) {
return None;
}
let apex_offset = axis * axial_apex;
let apex = anchor + apex_offset;
let cone_axis = if slope > 0.0 { axis } else { -axis };
Some((apex, cone_axis, half_angle))
}
#[allow(clippy::items_after_statements)]
fn try_recognize_torus(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, Vec3, f64, f64)> {
const N: usize = 8;
let cps = surface.control_points();
if cps.len() < 2 {
return None;
}
for row in cps {
if row.len() < 2 {
return None;
}
}
let n_rows = cps.len();
if n_rows < 3 {
return None;
}
let p0 = cps[0][0];
let mut axis: Option<Vec3> = None;
'outer: for i in 1..n_rows {
let v1 = cps[i][0] - p0;
for j in (i + 1)..n_rows {
let v2 = cps[j][0] - p0;
let cross = v1.cross(v2);
if cross.length() > tolerance
&& let Ok(normalized) = cross.normalize()
{
axis = Some(normalized);
break 'outer;
}
}
}
let axis = axis?;
let (u0, u1) = surface.domain_u();
let (v0, v1) = surface.domain_v();
let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
for iu in 0..N {
#[allow(clippy::cast_precision_loss)]
let u = u0 + (u1 - u0) * (iu as f64 + 0.5) / (N as f64);
for iv in 0..N {
#[allow(clippy::cast_precision_loss)]
let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
samples.push(surface.evaluate(u, v));
}
}
#[allow(clippy::cast_precision_loss)]
let inv_n = 1.0 / samples.len() as f64;
let mut ax = 0.0_f64;
let mut ay = 0.0_f64;
let mut az = 0.0_f64;
for p in &samples {
ax += p.x();
ay += p.y();
az += p.z();
}
let anchor = Point3::new(ax * inv_n, ay * inv_n, az * inv_n);
let mut axials: Vec<f64> = Vec::with_capacity(samples.len());
let mut radials: Vec<f64> = Vec::with_capacity(samples.len());
for p in &samples {
let to_p = *p - anchor;
let along = axis.dot(to_p);
let radial = (to_p - axis * along).length();
axials.push(along);
radials.push(radial);
}
let mut ata = [[0.0_f64; 3]; 3];
let mut atb = [0.0_f64; 3];
for i in 0..samples.len() {
let x = axials[i];
let y = radials[i];
let row = [2.0 * x, 2.0 * y, 1.0];
let rhs = x * x + y * y;
for r in 0..3 {
for c in 0..3 {
ata[r][c] += row[r] * row[c];
}
atb[r] += row[r] * rhs;
}
}
let sol = solve_3x3(ata, atb)?;
let center_axial = sol[0];
let major_radius = sol[1];
let k = sol[2]; let r_sq = k + center_axial * center_axial + major_radius * major_radius;
if r_sq <= 0.0 || major_radius <= 0.0 {
return None;
}
let minor_radius = r_sq.sqrt();
if minor_radius >= major_radius - tolerance {
return None;
}
for i in 0..samples.len() {
let dx = axials[i] - center_axial;
let dy = radials[i] - major_radius;
let dist = (dx * dx + dy * dy).sqrt();
if (dist - minor_radius).abs() > tolerance {
return None;
}
}
let center = anchor + axis * center_axial;
Some((center, axis, major_radius, minor_radius))
}
pub(super) fn solve_3x3(a: [[f64; 3]; 3], b: [f64; 3]) -> Option<[f64; 3]> {
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
if det.abs() < 1e-30 {
return None;
}
let inv = 1.0 / det;
let x0 = (b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
+ a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]))
* inv;
let x1 = (a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
- b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]))
* inv;
let x2 = (a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
- a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
+ b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]))
* inv;
Some([x0, x1, x2])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectedSurfaceKind {
Plane,
Sphere,
Cylinder,
BSpline,
}
impl DetectedSurfaceKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Plane => "plane",
Self::Sphere => "sphere",
Self::Cylinder => "cylinder",
Self::BSpline => "bspline",
}
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn detect_surface_kind(surface: &NurbsSurface) -> DetectedSurfaceKind {
let (u_min, u_max) = surface.domain_u();
let (v_min, v_max) = surface.domain_v();
let n = 8;
let mut points = Vec::with_capacity(n * n);
for i in 0..n {
for j in 0..n {
let u = u_min + (u_max - u_min) * (i as f64) / ((n - 1) as f64);
let v = v_min + (v_max - v_min) * (j as f64) / ((n - 1) as f64);
points.push(surface.evaluate(u, v));
}
}
let mut cx = 0.0_f64;
let mut cy = 0.0_f64;
let mut cz = 0.0_f64;
for p in &points {
cx += p.x();
cy += p.y();
cz += p.z();
}
let np = points.len() as f64;
let center = Point3::new(cx / np, cy / np, cz / np);
let mut plane_normal = None;
for i in 1..points.len() {
for j in (i + 1)..points.len() {
let v0 = points[i] - center;
let v1 = points[j] - center;
let n = v0.cross(v1);
if let Ok(normalized) = n.normalize() {
plane_normal = Some(normalized);
break;
}
}
if plane_normal.is_some() {
break;
}
}
if let Some(normal) = plane_normal {
let is_plane = points
.iter()
.all(|p| (*p - center).dot(normal).abs() < 1e-6);
if is_plane {
return DetectedSurfaceKind::Plane;
}
}
let distances: Vec<f64> = points.iter().map(|p| (*p - center).length()).collect();
let avg_dist = distances.iter().sum::<f64>() / np;
if avg_dist < 1e-10 {
return DetectedSurfaceKind::BSpline;
}
let tol = avg_dist * 1e-3; let is_sphere = distances.iter().all(|d| (d - avg_dist).abs() < tol);
if is_sphere {
return DetectedSurfaceKind::Sphere;
}
if let Some(axis_dir) = estimate_cylinder_axis(&points, center) {
let projected_distances: Vec<f64> = points
.iter()
.map(|p| {
let v = *p - center;
let along_axis = v.dot(axis_dir);
let radial = Vec3::new(
v.x() - axis_dir.x() * along_axis,
v.y() - axis_dir.y() * along_axis,
v.z() - axis_dir.z() * along_axis,
);
radial.length()
})
.collect();
let avg_r = projected_distances.iter().sum::<f64>() / np;
if avg_r > 1e-10 {
let r_tol = avg_r * 1e-3;
let is_cylinder = projected_distances
.iter()
.all(|d| (d - avg_r).abs() < r_tol);
if is_cylinder {
return DetectedSurfaceKind::Cylinder;
}
}
}
DetectedSurfaceKind::BSpline
}
fn estimate_cylinder_axis(points: &[Point3], center: Point3) -> Option<Vec3> {
let mut cxx = 0.0_f64;
let mut cxy = 0.0_f64;
let mut cxz = 0.0_f64;
let mut cyy = 0.0_f64;
let mut cyz = 0.0_f64;
let mut czz = 0.0_f64;
for p in points {
let dx = p.x() - center.x();
let dy = p.y() - center.y();
let dz = p.z() - center.z();
cxx += dx * dx;
cxy += dx * dy;
cxz += dx * dz;
cyy += dy * dy;
cyz += dy * dz;
czz += dz * dz;
}
let mut v = Vec3::new(1.0, 0.0, 0.0);
for _ in 0..20 {
let new_v = Vec3::new(
v.x().mul_add(cxx, v.y().mul_add(cxy, v.z() * cxz)),
v.x().mul_add(cxy, v.y().mul_add(cyy, v.z() * cyz)),
v.x().mul_add(cxz, v.y().mul_add(cyz, v.z() * czz)),
);
let len = new_v.length();
if len < 1e-15 {
return None;
}
v = Vec3::new(new_v.x() / len, new_v.y() / len, new_v.z() / len);
}
Some(v)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use brepkit_math::surfaces::{
ConicalSurface, CylindricalSurface, SphericalSurface, ToroidalSurface,
};
use brepkit_math::vec::{Point3, Vec3};
use super::*;
use crate::convert::surface_to_nurbs::{
cone_to_nurbs, cylinder_to_nurbs, sphere_to_nurbs, torus_to_nurbs,
};
fn origin() -> Point3 {
Point3::new(0.0, 0.0, 0.0)
}
fn z_axis() -> Vec3 {
Vec3::new(0.0, 0.0, 1.0)
}
#[test]
fn recognize_cylinder_round_trip() {
let cyl = CylindricalSurface::new(origin(), z_axis(), 3.0).unwrap();
let nurbs = cylinder_to_nurbs(&cyl, (0.0, 5.0)).unwrap();
let result = recognize_surface(&nurbs, 1e-4);
match result {
RecognizedSurface::Cylinder { radius, .. } => {
assert!((radius - 3.0).abs() < 0.01, "radius {radius} != 3.0");
}
other => panic!("expected Cylinder, got {other:?}"),
}
}
#[test]
fn recognize_sphere_round_trip() {
let sphere = SphericalSurface::new(origin(), 5.0).unwrap();
let nurbs = sphere_to_nurbs(&sphere).unwrap();
let result = recognize_surface(&nurbs, 0.1);
match result {
RecognizedSurface::Sphere { center, radius } => {
let dist = Vec3::new(center.x(), center.y(), center.z()).length();
assert!(dist < 0.5, "center too far from origin: {dist}");
assert!((radius - 5.0).abs() < 0.5, "radius {radius} != 5.0");
}
other => panic!("expected Sphere, got {other:?}"),
}
}
#[test]
fn recognize_cone_round_trip() {
let half_angle = std::f64::consts::PI / 6.0;
let cone = ConicalSurface::new(origin(), z_axis(), half_angle).unwrap();
let nurbs = cone_to_nurbs(&cone, (1.0, 4.0)).unwrap();
match recognize_surface(&nurbs, 0.05) {
RecognizedSurface::Cone {
apex,
axis,
half_angle: ha,
} => {
assert!(
Vec3::new(apex.x(), apex.y(), apex.z()).length() < 0.05,
"apex {apex:?}"
);
assert!(
axis.dot(z_axis()).abs() > 1.0 - 1e-3,
"axis {axis:?} not aligned with z"
);
assert!(
(ha - half_angle).abs() < 1e-3,
"half_angle {ha} vs {half_angle}"
);
}
other => panic!("expected Cone, got {other:?}"),
}
}
#[test]
fn cylinder_is_recognized_as_cylinder_not_cone() {
let cyl = CylindricalSurface::new(origin(), z_axis(), 2.0).unwrap();
let nurbs = cylinder_to_nurbs(&cyl, (0.0, 5.0)).unwrap();
assert!(matches!(
recognize_surface(&nurbs, 1e-4),
RecognizedSurface::Cylinder { .. }
));
}
#[test]
fn recognize_torus_round_trip() {
let torus = ToroidalSurface::new(origin(), 3.0, 0.5).unwrap();
let nurbs = torus_to_nurbs(&torus).unwrap();
match recognize_surface(&nurbs, 0.05) {
RecognizedSurface::Torus {
center,
axis,
major_radius,
minor_radius,
} => {
assert!(
Vec3::new(center.x(), center.y(), center.z()).length() < 0.05,
"center {center:?} not at origin"
);
assert!(
axis.dot(z_axis()).abs() > 1.0 - 1e-3,
"axis {axis:?} not aligned with z"
);
assert!(
(major_radius - 3.0).abs() < 0.05,
"major_radius {major_radius} vs 3.0"
);
assert!(
(minor_radius - 0.5).abs() < 0.05,
"minor_radius {minor_radius} vs 0.5"
);
}
other => panic!("expected Torus, got {other:?}"),
}
}
#[test]
fn cylinder_is_not_recognized_as_torus() {
let cyl = CylindricalSurface::new(origin(), z_axis(), 2.0).unwrap();
let nurbs = cylinder_to_nurbs(&cyl, (0.0, 5.0)).unwrap();
assert!(matches!(
recognize_surface(&nurbs, 1e-4),
RecognizedSurface::Cylinder { .. }
));
}
}