Skip to main content

brepkit_geometry/convert/
recognize_surface.rs

1//! Recognize NURBS surfaces as elementary analytic forms.
2//!
3//! Ported from `brepkit-heal/analysis/canonical.rs` but expressed entirely
4//! in terms of `brepkit-math` types (no topology dependency). The result is
5//! a [`RecognizedSurface`] enum describing the best-fit analytic surface, or
6//! [`RecognizedSurface::NotRecognized`] when no match is found.
7
8use brepkit_math::nurbs::surface::NurbsSurface;
9use brepkit_math::vec::{Point3, Vec3};
10
11/// The analytic surface form recognized from a NURBS surface.
12#[derive(Debug, Clone, PartialEq)]
13pub enum RecognizedSurface {
14    /// Recognized as a plane.
15    Plane {
16        /// Outward normal (unit vector).
17        normal: Vec3,
18        /// Signed distance from origin: `normal · (any point on plane)`.
19        d: f64,
20    },
21    /// Recognized as a cylinder.
22    Cylinder {
23        /// A point on the cylinder axis.
24        origin: Point3,
25        /// Axis direction (unit vector).
26        axis: Vec3,
27        /// Cylinder radius.
28        radius: f64,
29    },
30    /// Recognized as a sphere.
31    Sphere {
32        /// Center of the sphere.
33        center: Point3,
34        /// Sphere radius.
35        radius: f64,
36    },
37    /// Recognized as a cone.
38    Cone {
39        /// The cone's apex (point where radius = 0).
40        apex: Point3,
41        /// Cone axis direction (from apex into the cone, unit vector).
42        axis: Vec3,
43        /// Half-angle from the radial plane to the cone generator
44        /// (radians, in `(0, π/2)`).
45        half_angle: f64,
46    },
47    /// Recognized as a torus.
48    Torus {
49        /// Torus center (the axis passes through this point).
50        center: Point3,
51        /// Torus axis direction (perpendicular to the major-circle
52        /// plane, unit vector).
53        axis: Vec3,
54        /// Major radius (distance from torus center to tube center).
55        major_radius: f64,
56        /// Minor radius (tube cross-section radius).
57        minor_radius: f64,
58    },
59    /// The surface could not be matched to any elementary form.
60    NotRecognized,
61}
62
63/// Attempt to recognize a NURBS surface as an elementary analytic surface.
64///
65/// Tries recognition in order: plane, cylinder, sphere, cone. Returns
66/// the first match whose maximum sample deviation is within
67/// `tolerance`. Cylinder is tested before cone so that constant-radius
68/// surfaces are classified as `Cylinder`, not as `Cone` with apex at
69/// infinity.
70#[must_use]
71pub fn recognize_surface(surface: &NurbsSurface, tolerance: f64) -> RecognizedSurface {
72    if let Some((normal, d)) = try_recognize_plane(surface, tolerance) {
73        return RecognizedSurface::Plane { normal, d };
74    }
75    if let Some((origin, axis, radius)) = try_recognize_cylinder(surface, tolerance) {
76        return RecognizedSurface::Cylinder {
77            origin,
78            axis,
79            radius,
80        };
81    }
82    if let Some((center, radius)) = try_recognize_sphere(surface, tolerance) {
83        return RecognizedSurface::Sphere { center, radius };
84    }
85    if let Some((apex, axis, half_angle)) = try_recognize_cone(surface, tolerance) {
86        return RecognizedSurface::Cone {
87            apex,
88            axis,
89            half_angle,
90        };
91    }
92    if let Some((center, axis, major_radius, minor_radius)) =
93        try_recognize_torus(surface, tolerance)
94    {
95        return RecognizedSurface::Torus {
96            center,
97            axis,
98            major_radius,
99            minor_radius,
100        };
101    }
102    RecognizedSurface::NotRecognized
103}
104
105// ── Plane recognition ─────────────────────────────────────────────────────────
106
107/// Check if all control points of a NURBS surface lie on a single plane.
108///
109/// Returns `(normal, d)` if recognized, where `d = normal · p0`.
110fn try_recognize_plane(surface: &NurbsSurface, tolerance: f64) -> Option<(Vec3, f64)> {
111    let cps = surface.control_points();
112    if cps.is_empty() || cps[0].is_empty() {
113        return None;
114    }
115
116    // Collect all control points.
117    let mut all_pts: Vec<Point3> = Vec::new();
118    for row in cps {
119        for pt in row {
120            all_pts.push(*pt);
121        }
122    }
123
124    if all_pts.len() < 3 {
125        return None;
126    }
127
128    // Take the normal from three points spread across the net: the first,
129    // the one farthest from it, and the one farthest off the line through
130    // those two. Points crowded together tilt their plane by their own
131    // deviation over their spacing, which can far exceed the tolerance
132    // across the rest of the net. A net within `tolerance` of a point or a
133    // line spans no plane.
134    let p0 = all_pts[0];
135    let farthest = |measure: &dyn Fn(Point3) -> f64| {
136        all_pts
137            .iter()
138            .map(|&p| (p, measure(p)))
139            .max_by(|a, b| a.1.total_cmp(&b.1))
140    };
141    let (p1, base) = farthest(&|p| (p - p0).length())?;
142    if base <= tolerance {
143        return None;
144    }
145    let along = (p1 - p0) * (1.0 / base);
146    let (p2, height) = farthest(&|p| {
147        let v = p - p0;
148        (v - along * along.dot(v)).length()
149    })?;
150    if height <= tolerance {
151        return None;
152    }
153    let n = (p1 - p0).cross(p2 - p0).normalize().ok()?;
154    let d = n.dot(Vec3::new(p0.x(), p0.y(), p0.z()));
155
156    // Check all control points lie within tolerance of the plane.
157    for pt in &all_pts {
158        let dist = n.dot(Vec3::new(pt.x(), pt.y(), pt.z())) - d;
159        if dist.abs() > tolerance {
160            return None;
161        }
162    }
163
164    Some((n, d))
165}
166
167// ── Cylinder recognition ──────────────────────────────────────────────────────
168
169/// Check if a NURBS surface is a cylinder.
170///
171/// Estimates the axis from the v-direction within each control-point row
172/// (averaged across all rows), then verifies that an 8×8 sample grid lies
173/// at a consistent radial distance from that axis.
174///
175/// This handles both the exact rational form (9 u-rows × 2 v-columns) and
176/// the sampled bilinear form (nu rows × nv columns).
177#[allow(clippy::items_after_statements)]
178fn try_recognize_cylinder(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, Vec3, f64)> {
179    let cps = surface.control_points();
180    if cps.len() < 2 {
181        return None;
182    }
183    for row in cps {
184        if row.len() < 2 {
185            return None;
186        }
187    }
188
189    // Estimate axis as average of (last_col - first_col) across all rows.
190    let mut axis_sum = Vec3::new(0.0, 0.0, 0.0);
191    for row in cps {
192        let v = row[row.len() - 1] - row[0];
193        axis_sum += v;
194    }
195    #[allow(clippy::cast_precision_loss)]
196    let axis_avg = axis_sum * (1.0 / cps.len() as f64);
197    let axis_len = axis_avg.length();
198    if axis_len < tolerance {
199        return None;
200    }
201    let axis = axis_avg.normalize().ok()?;
202
203    // Sample at an 8×8 grid of evaluated surface points.
204    // We use evaluated points (not control points) for the axis origin because
205    // rational NURBS control points are NOT on the surface — the centroid of
206    // weighted CPs would be skewed.
207    let (u0, u1) = surface.domain_u();
208    let (v0, v1) = surface.domain_v();
209    const N: usize = 8;
210
211    let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
212    for iu in 0..N {
213        #[allow(clippy::cast_precision_loss)]
214        let u = u0 + (u1 - u0) * (iu as f64) / ((N - 1) as f64);
215        for iv in 0..N {
216            #[allow(clippy::cast_precision_loss)]
217            let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
218            samples.push(surface.evaluate(u, v));
219        }
220    }
221
222    // Find the axis position by least-squares circle fitting in the plane
223    // perpendicular to the axis. Project each sample to 2D (removing the
224    // axial component), then solve the algebraic circle equation:
225    //   x² + y² = 2·cx·x + 2·cy·y + (r² - cx² - cy²)
226    // This is linear in (cx, cy, C) and gives the circle center.
227    let ref_pt = samples[0];
228
229    // Build a 2D coordinate system perpendicular to the axis.
230    let perp1 = {
231        let trial = if axis.x().abs() < 0.9 {
232            Vec3::new(1.0, 0.0, 0.0)
233        } else {
234            Vec3::new(0.0, 1.0, 0.0)
235        };
236        let p = trial - axis * axis.dot(trial);
237        p.normalize().unwrap_or(Vec3::new(1.0, 0.0, 0.0))
238    };
239    let perp2 = axis.cross(perp1);
240
241    // Project samples to 2D (perpendicular to axis).
242    let pts_2d: Vec<(f64, f64)> = samples
243        .iter()
244        .map(|pt| {
245            let v = *pt - ref_pt;
246            (perp1.dot(v), perp2.dot(v))
247        })
248        .collect();
249
250    // Solve least-squares: for each (x,y), x²+y² = 2*cx*x + 2*cy*y + C
251    // ATA * [cx, cy, C/2] = ATb where A[i] = [2x, 2y, 1] and b[i] = x²+y²
252    let mut ata = [[0.0_f64; 3]; 3];
253    let mut atb = [0.0_f64; 3];
254    for &(x, y) in &pts_2d {
255        let rhs = x * x + y * y;
256        let row = [2.0 * x, 2.0 * y, 1.0];
257        for i in 0..3 {
258            for j in 0..3 {
259                ata[i][j] += row[i] * row[j];
260            }
261            atb[i] += row[i] * rhs;
262        }
263    }
264
265    let sol = solve_3x3(ata, atb)?;
266    let cx = sol[0];
267    let cy = sol[1];
268    // Recover axis origin in 3D.
269    let origin = ref_pt + perp1 * cx + perp2 * cy;
270
271    let mut radii: Vec<f64> = Vec::with_capacity(samples.len());
272    for pt in &samples {
273        let to_pt = *pt - origin;
274        let along = axis.dot(to_pt);
275        let radial = to_pt - axis * along;
276        radii.push(radial.length());
277    }
278
279    if radii.is_empty() {
280        return None;
281    }
282
283    let sum: f64 = radii.iter().sum();
284    #[allow(clippy::cast_precision_loss)]
285    let mean_radius = sum / radii.len() as f64;
286
287    if mean_radius < tolerance {
288        return None; // Degenerate — axis passes through all points.
289    }
290
291    let max_dev = radii
292        .iter()
293        .map(|r| (r - mean_radius).abs())
294        .fold(0.0_f64, f64::max);
295    if max_dev > tolerance {
296        return None;
297    }
298
299    Some((origin, axis, mean_radius))
300}
301
302// ── Sphere recognition ────────────────────────────────────────────────────────
303
304/// Check if a NURBS surface is a sphere.
305///
306/// Samples an 8×8 grid, estimates the center by solving a 3×3 least-squares
307/// system, then verifies all sample points are equidistant from that center.
308#[allow(clippy::items_after_statements)]
309fn try_recognize_sphere(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, f64)> {
310    let (u0, u1) = surface.domain_u();
311    let (v0, v1) = surface.domain_v();
312    const N: usize = 8;
313
314    let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
315
316    for iu in 0..N {
317        #[allow(clippy::cast_precision_loss)]
318        let u = u0 + (u1 - u0) * (iu as f64) / ((N - 1) as f64);
319        for iv in 0..N {
320            #[allow(clippy::cast_precision_loss)]
321            let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
322            samples.push(surface.evaluate(u, v));
323        }
324    }
325
326    if samples.len() < 4 {
327        return None;
328    }
329
330    // Solve least-squares for center using algebraic approach.
331    // For each pair (p0, pi), the difference equation eliminates R²:
332    //   2*(pi - p0) · c = pi² - p0²
333    let sq = |p: Point3| p.x() * p.x() + p.y() * p.y() + p.z() * p.z();
334
335    let n = samples.len();
336    let mut ata = [[0.0_f64; 3]; 3];
337    let mut atb = [0.0_f64; 3];
338
339    let p0 = samples[0];
340    let sq0 = sq(p0);
341
342    for i in 1..n {
343        let pi = samples[i];
344        let a_row = [
345            2.0 * (pi.x() - p0.x()),
346            2.0 * (pi.y() - p0.y()),
347            2.0 * (pi.z() - p0.z()),
348        ];
349        let bi = sq(pi) - sq0;
350
351        for r in 0..3 {
352            for c in 0..3 {
353                ata[r][c] += a_row[r] * a_row[c];
354            }
355            atb[r] += a_row[r] * bi;
356        }
357    }
358
359    let center = solve_3x3(ata, atb)?;
360    let center_pt = Point3::new(center[0], center[1], center[2]);
361
362    let mut distances: Vec<f64> = Vec::with_capacity(n);
363    for pt in &samples {
364        let d = Vec3::new(
365            pt.x() - center_pt.x(),
366            pt.y() - center_pt.y(),
367            pt.z() - center_pt.z(),
368        )
369        .length();
370        distances.push(d);
371    }
372
373    let sum: f64 = distances.iter().sum();
374    #[allow(clippy::cast_precision_loss)]
375    let mean_radius = sum / distances.len() as f64;
376
377    if mean_radius < tolerance {
378        return None;
379    }
380
381    let max_dev = distances
382        .iter()
383        .map(|d| (d - mean_radius).abs())
384        .fold(0.0_f64, f64::max);
385
386    if max_dev > tolerance {
387        return None;
388    }
389
390    Some((center_pt, mean_radius))
391}
392
393// ── Cone recognition ──────────────────────────────────────────────────────────
394
395/// Check if all sampled surface points lie on a cone.
396///
397/// Estimates the axis from the average of "last column − first column"
398/// across all CP rows (same as cylinder, since cone has the same
399/// rotational structure). Then verifies samples lie on a cone by
400/// checking that:
401///
402/// 1. The axial-component vs radial-component relationship is linear
403///    (samples lie on a 2D wedge in `(axial, radial)` space).
404/// 2. The radial component is consistent for all u at each fixed v
405///    (each iso-v line is a circle around the axis).
406///
407/// The slope of the radial-vs-axial line gives `cot(half_angle)`; the
408/// apex is the (axial, 0) intercept extrapolated from this line.
409fn try_recognize_cone(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, Vec3, f64)> {
410    const N: usize = 8;
411    let cps = surface.control_points();
412    if cps.len() < 2 {
413        return None;
414    }
415    for row in cps {
416        if row.len() < 2 {
417            return None;
418        }
419    }
420
421    // Estimate axis direction. For cones (unlike cylinders), the
422    // (last_col - first_col) vector at row i has both an axial AND a
423    // radial component (cos_a · radial_dir(u_i) + sin_a · axis) — so
424    // averaging across u must cancel the radial part. The 33×9 CP
425    // grid produced by `analytic_to_nurbs_sampled` duplicates the
426    // u=0 and u=2π seam (CP[0] and CP[N-1] at the same 3D point),
427    // which biases the unweighted sum. Skip the last row to remove
428    // the duplicate before averaging.
429    let n_rows = cps.len();
430    let row_count = if n_rows >= 3 && (cps[0][0] - cps[n_rows - 1][0]).length() < tolerance {
431        n_rows - 1
432    } else {
433        n_rows
434    };
435    let mut axis_sum = Vec3::new(0.0, 0.0, 0.0);
436    for row in cps.iter().take(row_count) {
437        let v = row[row.len() - 1] - row[0];
438        axis_sum += v;
439    }
440    #[allow(clippy::cast_precision_loss)]
441    let axis_avg = axis_sum * (1.0 / row_count as f64);
442    if axis_avg.length() < tolerance {
443        return None;
444    }
445    let axis = axis_avg.normalize().ok()?;
446
447    // Sample at an 8×8 grid. CRITICAL: use OPEN range in u to avoid
448    // duplicating the closing seam point (u_nurbs=0 and u_nurbs=1
449    // coincide for full-revolution surfaces). Duplicates bias the
450    // centroid off-axis, which throws off the radial-component
451    // computation for samples near the duplicate.
452    let (u0, u1) = surface.domain_u();
453    let (v0, v1) = surface.domain_v();
454    let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
455    for iu in 0..N {
456        #[allow(clippy::cast_precision_loss)]
457        let u = u0 + (u1 - u0) * (iu as f64 + 0.5) / (N as f64);
458        for iv in 0..N {
459            #[allow(clippy::cast_precision_loss)]
460            let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
461            samples.push(surface.evaluate(u, v));
462        }
463    }
464
465    // Estimate the apex (axis origin) by linear-fitting (axial,
466    // radial) pairs. For each sample, `axial = axis · (p − sample[0])`
467    // is a relative axial distance; `radial` is the perpendicular
468    // distance from sample[0]'s axial projection. Wait — for cone
469    // recognition we need a robust BUT axis-relative reference. Use
470    // the centroid of all samples as the "anchor" for axial measurement.
471    #[allow(clippy::cast_precision_loss)]
472    let inv_n = 1.0 / samples.len() as f64;
473    let mut anchor_x = 0.0_f64;
474    let mut anchor_y = 0.0_f64;
475    let mut anchor_z = 0.0_f64;
476    for p in &samples {
477        anchor_x += p.x();
478        anchor_y += p.y();
479        anchor_z += p.z();
480    }
481    let anchor = Point3::new(anchor_x * inv_n, anchor_y * inv_n, anchor_z * inv_n);
482
483    // Measure axial and radial offsets from anchor along the axis.
484    // For each sample, compute axial = axis · (p − anchor) and
485    // radial = |(p − anchor) − axial · axis|. For a true cone with
486    // apex at (anchor + axial_apex · axis), the radial component is
487    // a linear function of axial: radial = |slope · (axial − axial_apex)|.
488    let mut axials: Vec<f64> = Vec::with_capacity(samples.len());
489    let mut radials: Vec<f64> = Vec::with_capacity(samples.len());
490    for p in &samples {
491        let to_p = *p - anchor;
492        let along = axis.dot(to_p);
493        let radial_vec = to_p - axis * along;
494        axials.push(along);
495        radials.push(radial_vec.length());
496    }
497
498    // Reject degenerate (all radials zero or all the same): would be
499    // a line/cylinder, not a cone.
500    let max_r = radials.iter().fold(0.0_f64, |m, &r| m.max(r));
501    let min_r = radials.iter().fold(f64::INFINITY, |m, &r| m.min(r));
502    if max_r - min_r < tolerance {
503        return None; // Constant radius → cylinder (handled earlier).
504    }
505
506    // Linear fit: radial = m · axial + b. Then cone apex is at
507    // axial_apex = -b / m, with radial_apex = 0. For axisymmetry,
508    // the radial side should be an ABSOLUTE value (always >= 0); we
509    // exploit the fact that radial is a vector magnitude, so on the
510    // cone the relationship `radial = slope · (axial − axial_apex)`
511    // holds with `slope > 0` for axials > axial_apex.
512    //
513    // We use unsigned-radial least-squares: pick the slope from a
514    // simple linear regression of (axial, radial). For a true cone
515    // the residual should be near zero.
516    let n_f = samples.len() as f64;
517    let sum_a: f64 = axials.iter().sum();
518    let sum_r: f64 = radials.iter().sum();
519    let mean_a = sum_a / n_f;
520    let mean_r = sum_r / n_f;
521    let mut s_aa = 0.0_f64;
522    let mut s_ar = 0.0_f64;
523    for i in 0..samples.len() {
524        let da = axials[i] - mean_a;
525        let dr = radials[i] - mean_r;
526        s_aa += da * da;
527        s_ar += da * dr;
528    }
529    if s_aa < 1e-30 {
530        return None;
531    }
532    let slope = s_ar / s_aa;
533    let intercept = mean_r - slope * mean_a;
534    if slope.abs() < tolerance {
535        return None; // Slope ≈ 0 → cylinder.
536    }
537    // Apex axial position relative to anchor.
538    let axial_apex = -intercept / slope;
539
540    // Verify residuals.
541    for i in 0..samples.len() {
542        let pred = slope * axials[i] + intercept;
543        if (radials[i] - pred).abs() > tolerance {
544            return None;
545        }
546    }
547
548    // Compute half-angle from slope. The cone equation in local
549    // (axial, radial) coords is `radial = (axial - axial_apex) ·
550    // |slope|` for axial > axial_apex. The slope equals
551    // cos(half_angle) / sin(half_angle) = cot(half_angle), so
552    // half_angle = atan(1 / |slope|).
553    //
554    // brepkit's half_angle convention is the angle from the RADIAL
555    // plane to the generator, so half_angle ∈ (0, π/2).
556    let half_angle = (1.0 / slope.abs()).atan();
557    if !(0.0 < half_angle && half_angle < std::f64::consts::FRAC_PI_2) {
558        return None;
559    }
560
561    // Apex in 3D. Cone axis points from apex INTO the cone (positive
562    // axial direction). If our slope is negative (radial decreases
563    // with positive axial), the apex is in the +axial direction;
564    // axis should point AWAY from the apex (negative-axial-from-apex
565    // direction, i.e., positive `slope` convention).
566    let apex_offset = axis * axial_apex;
567    let apex = anchor + apex_offset;
568    let cone_axis = if slope > 0.0 { axis } else { -axis };
569
570    Some((apex, cone_axis, half_angle))
571}
572
573// ── Torus recognition ────────────────────────────────────────────────────────
574
575/// Check if all sampled surface points lie on a torus.
576///
577/// A torus is the surface of revolution of a cross-section circle of
578/// radius `minor_radius` whose center lies at distance `major_radius`
579/// from the axis. In `(axial, radial)` space relative to the axis, the
580/// samples lie on a circle of radius `minor_radius` centered at
581/// `(0, major_radius)`.
582///
583/// Algorithm:
584/// 1. Estimate axis (skip seam-duplicate row, same as cone).
585/// 2. Sample 8×8 with open-range u to avoid centroid bias.
586/// 3. Compute `(axial, radial)` per sample relative to the centroid.
587/// 4. Fit a circle to the `(axial, radial)` points via algebraic
588///    least-squares.
589/// 5. Verify all points lie on this circle within tolerance.
590/// 6. Center of fit circle gives `(axial_center, major_radius)`;
591///    radius gives `minor_radius`.
592#[allow(clippy::items_after_statements)]
593fn try_recognize_torus(surface: &NurbsSurface, tolerance: f64) -> Option<(Point3, Vec3, f64, f64)> {
594    const N: usize = 8;
595    let cps = surface.control_points();
596    if cps.len() < 2 {
597        return None;
598    }
599    for row in cps {
600        if row.len() < 2 {
601            return None;
602        }
603    }
604
605    // Estimate axis: for a torus, the U direction is revolution
606    // around the major axis, so the FIRST column of CPs (cps[i][0]
607    // for varying i) traces a circle in a plane perpendicular to the
608    // axis. The cylinder/cone trick (last_col − first_col averaged)
609    // doesn't work because v is closed (last_col ≈ first_col).
610    //
611    // Find three non-collinear CPs in the first column and take the
612    // cross-product of their relative offsets — that gives the
613    // plane normal, i.e., the torus axis.
614    let n_rows = cps.len();
615    if n_rows < 3 {
616        return None;
617    }
618    let p0 = cps[0][0];
619    let mut axis: Option<Vec3> = None;
620    'outer: for i in 1..n_rows {
621        let v1 = cps[i][0] - p0;
622        for j in (i + 1)..n_rows {
623            let v2 = cps[j][0] - p0;
624            let cross = v1.cross(v2);
625            if cross.length() > tolerance
626                && let Ok(normalized) = cross.normalize()
627            {
628                axis = Some(normalized);
629                break 'outer;
630            }
631        }
632    }
633    let axis = axis?;
634
635    // Sample with open-range u.
636    let (u0, u1) = surface.domain_u();
637    let (v0, v1) = surface.domain_v();
638    let mut samples: Vec<Point3> = Vec::with_capacity(N * N);
639    for iu in 0..N {
640        #[allow(clippy::cast_precision_loss)]
641        let u = u0 + (u1 - u0) * (iu as f64 + 0.5) / (N as f64);
642        for iv in 0..N {
643            #[allow(clippy::cast_precision_loss)]
644            let v = v0 + (v1 - v0) * (iv as f64) / ((N - 1) as f64);
645            samples.push(surface.evaluate(u, v));
646        }
647    }
648
649    // Centroid as anchor.
650    #[allow(clippy::cast_precision_loss)]
651    let inv_n = 1.0 / samples.len() as f64;
652    let mut ax = 0.0_f64;
653    let mut ay = 0.0_f64;
654    let mut az = 0.0_f64;
655    for p in &samples {
656        ax += p.x();
657        ay += p.y();
658        az += p.z();
659    }
660    let anchor = Point3::new(ax * inv_n, ay * inv_n, az * inv_n);
661
662    // (axial, radial) for each sample.
663    let mut axials: Vec<f64> = Vec::with_capacity(samples.len());
664    let mut radials: Vec<f64> = Vec::with_capacity(samples.len());
665    for p in &samples {
666        let to_p = *p - anchor;
667        let along = axis.dot(to_p);
668        let radial = (to_p - axis * along).length();
669        axials.push(along);
670        radials.push(radial);
671    }
672
673    // Fit a circle to (axial, radial) points: solve algebraic
674    //   x² + y² = 2·cx·x + 2·cy·y + (R² − cx² − cy²)
675    // ⇒ row = [2x, 2y, 1], rhs = x² + y², solve for [cx, cy, K].
676    let mut ata = [[0.0_f64; 3]; 3];
677    let mut atb = [0.0_f64; 3];
678    for i in 0..samples.len() {
679        let x = axials[i];
680        let y = radials[i];
681        let row = [2.0 * x, 2.0 * y, 1.0];
682        let rhs = x * x + y * y;
683        for r in 0..3 {
684            for c in 0..3 {
685                ata[r][c] += row[r] * row[c];
686            }
687            atb[r] += row[r] * rhs;
688        }
689    }
690    let sol = solve_3x3(ata, atb)?;
691    let center_axial = sol[0];
692    let major_radius = sol[1];
693    let k = sol[2]; // R² − cx² − cy²
694    let r_sq = k + center_axial * center_axial + major_radius * major_radius;
695    if r_sq <= 0.0 || major_radius <= 0.0 {
696        return None;
697    }
698    let minor_radius = r_sq.sqrt();
699
700    // Reject if minor >= major (degenerate torus / cylinder-with-radius).
701    if minor_radius >= major_radius - tolerance {
702        return None;
703    }
704
705    // Verify residuals.
706    for i in 0..samples.len() {
707        let dx = axials[i] - center_axial;
708        let dy = radials[i] - major_radius;
709        let dist = (dx * dx + dy * dy).sqrt();
710        if (dist - minor_radius).abs() > tolerance {
711            return None;
712        }
713    }
714
715    let center = anchor + axis * center_axial;
716    Some((center, axis, major_radius, minor_radius))
717}
718
719// ── Utilities ─────────────────────────────────────────────────────────────────
720
721/// Solve a 3×3 linear system `A * x = b` via Cramer's rule.
722///
723/// Returns `None` if the determinant is near zero (singular system).
724/// Exposed at `pub(super)` so [`super::recognize_curve`] can reuse it
725/// (avoids duplicating the same 3×3 solver).
726pub(super) fn solve_3x3(a: [[f64; 3]; 3], b: [f64; 3]) -> Option<[f64; 3]> {
727    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
728        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
729        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
730
731    if det.abs() < 1e-30 {
732        return None;
733    }
734
735    let inv = 1.0 / det;
736
737    let x0 = (b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
738        - a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
739        + a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]))
740        * inv;
741
742    let x1 = (a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
743        - b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
744        + a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]))
745        * inv;
746
747    let x2 = (a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
748        - a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
749        + b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]))
750        * inv;
751
752    Some([x0, x1, x2])
753}
754
755// ── Lightweight detection ────────────────────────────────────────────────────
756
757/// Detected geometric kind of a NURBS surface (without recovering full analytic
758/// parameters). Cheaper than [`recognize_surface`] when you only need a type tag.
759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760pub enum DetectedSurfaceKind {
761    /// All sampled points lie on a plane.
762    Plane,
763    /// All sampled points are equidistant from a center (sphere).
764    Sphere,
765    /// All sampled points are equidistant from an axis (cylinder).
766    Cylinder,
767    /// Generic B-spline surface.
768    BSpline,
769}
770
771impl DetectedSurfaceKind {
772    /// Returns the lowercase string tag for this surface kind.
773    #[must_use]
774    pub const fn as_str(self) -> &'static str {
775        match self {
776            Self::Plane => "plane",
777            Self::Sphere => "sphere",
778            Self::Cylinder => "cylinder",
779            Self::BSpline => "bspline",
780        }
781    }
782}
783
784/// Detect the geometric kind of a NURBS surface by sampling.
785///
786/// Samples an 8x8 grid and checks for sphere (equidistant from centroid) or
787/// cylinder (equidistant from a PCA axis). Falls back to `BSpline`.
788///
789/// This is a lightweight heuristic — use [`recognize_surface`] for full analytic
790/// parameter recovery.
791#[must_use]
792#[allow(clippy::cast_precision_loss)]
793pub fn detect_surface_kind(surface: &NurbsSurface) -> DetectedSurfaceKind {
794    let (u_min, u_max) = surface.domain_u();
795    let (v_min, v_max) = surface.domain_v();
796    let n = 8; // 8x8 grid = 64 sample points
797
798    let mut points = Vec::with_capacity(n * n);
799    for i in 0..n {
800        for j in 0..n {
801            let u = u_min + (u_max - u_min) * (i as f64) / ((n - 1) as f64);
802            let v = v_min + (v_max - v_min) * (j as f64) / ((n - 1) as f64);
803            points.push(surface.evaluate(u, v));
804        }
805    }
806
807    // Compute center as average.
808    let mut cx = 0.0_f64;
809    let mut cy = 0.0_f64;
810    let mut cz = 0.0_f64;
811    for p in &points {
812        cx += p.x();
813        cy += p.y();
814        cz += p.z();
815    }
816    let np = points.len() as f64;
817    let center = Point3::new(cx / np, cy / np, cz / np);
818
819    // Plane test: check if all points are coplanar.
820    // Find a normal from the first non-degenerate cross product.
821    let mut plane_normal = None;
822    for i in 1..points.len() {
823        for j in (i + 1)..points.len() {
824            let v0 = points[i] - center;
825            let v1 = points[j] - center;
826            let n = v0.cross(v1);
827            if let Ok(normalized) = n.normalize() {
828                plane_normal = Some(normalized);
829                break;
830            }
831        }
832        if plane_normal.is_some() {
833            break;
834        }
835    }
836    if let Some(normal) = plane_normal {
837        let is_plane = points
838            .iter()
839            .all(|p| (*p - center).dot(normal).abs() < 1e-6);
840        if is_plane {
841            return DetectedSurfaceKind::Plane;
842        }
843    }
844
845    // Check if all points equidistant from center (sphere test).
846    let distances: Vec<f64> = points.iter().map(|p| (*p - center).length()).collect();
847    let avg_dist = distances.iter().sum::<f64>() / np;
848
849    if avg_dist < 1e-10 {
850        return DetectedSurfaceKind::BSpline;
851    }
852
853    let tol = avg_dist * 1e-3; // 0.1% relative tolerance
854    let is_sphere = distances.iter().all(|d| (d - avg_dist).abs() < tol);
855
856    if is_sphere {
857        return DetectedSurfaceKind::Sphere;
858    }
859
860    // Cylinder test: points should be equidistant from an axis line.
861    if let Some(axis_dir) = estimate_cylinder_axis(&points, center) {
862        let projected_distances: Vec<f64> = points
863            .iter()
864            .map(|p| {
865                let v = *p - center;
866                let along_axis = v.dot(axis_dir);
867                let radial = Vec3::new(
868                    v.x() - axis_dir.x() * along_axis,
869                    v.y() - axis_dir.y() * along_axis,
870                    v.z() - axis_dir.z() * along_axis,
871                );
872                radial.length()
873            })
874            .collect();
875
876        let avg_r = projected_distances.iter().sum::<f64>() / np;
877        if avg_r > 1e-10 {
878            let r_tol = avg_r * 1e-3;
879            let is_cylinder = projected_distances
880                .iter()
881                .all(|d| (d - avg_r).abs() < r_tol);
882            if is_cylinder {
883                return DetectedSurfaceKind::Cylinder;
884            }
885        }
886    }
887
888    DetectedSurfaceKind::BSpline
889}
890
891/// Estimate the cylinder axis direction from a set of surface sample points
892/// using a simple PCA-like approach (direction of maximum variance).
893fn estimate_cylinder_axis(points: &[Point3], center: Point3) -> Option<Vec3> {
894    // Build covariance matrix.
895    let mut cxx = 0.0_f64;
896    let mut cxy = 0.0_f64;
897    let mut cxz = 0.0_f64;
898    let mut cyy = 0.0_f64;
899    let mut cyz = 0.0_f64;
900    let mut czz = 0.0_f64;
901
902    for p in points {
903        let dx = p.x() - center.x();
904        let dy = p.y() - center.y();
905        let dz = p.z() - center.z();
906        cxx += dx * dx;
907        cxy += dx * dy;
908        cxz += dx * dz;
909        cyy += dy * dy;
910        cyz += dy * dz;
911        czz += dz * dz;
912    }
913
914    // Power iteration to find the principal eigenvector.
915    let mut v = Vec3::new(1.0, 0.0, 0.0);
916    for _ in 0..20 {
917        let new_v = Vec3::new(
918            v.x().mul_add(cxx, v.y().mul_add(cxy, v.z() * cxz)),
919            v.x().mul_add(cxy, v.y().mul_add(cyy, v.z() * cyz)),
920            v.x().mul_add(cxz, v.y().mul_add(cyz, v.z() * czz)),
921        );
922        let len = new_v.length();
923        if len < 1e-15 {
924            return None;
925        }
926        v = Vec3::new(new_v.x() / len, new_v.y() / len, new_v.z() / len);
927    }
928    Some(v)
929}
930
931// ── Tests ─────────────────────────────────────────────────────────────────────
932
933#[cfg(test)]
934mod tests {
935    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
936
937    use brepkit_math::surfaces::{
938        ConicalSurface, CylindricalSurface, SphericalSurface, ToroidalSurface,
939    };
940    use brepkit_math::vec::{Point3, Vec3};
941
942    use super::*;
943    use crate::convert::surface_to_nurbs::{
944        cone_to_nurbs, cylinder_to_nurbs, sphere_to_nurbs, torus_to_nurbs,
945    };
946
947    fn origin() -> Point3 {
948        Point3::new(0.0, 0.0, 0.0)
949    }
950
951    fn z_axis() -> Vec3 {
952        Vec3::new(0.0, 0.0, 1.0)
953    }
954
955    #[test]
956    fn recognize_cylinder_round_trip() {
957        let cyl = CylindricalSurface::new(origin(), z_axis(), 3.0).unwrap();
958        let nurbs = cylinder_to_nurbs(&cyl, (0.0, 5.0)).unwrap();
959
960        let result = recognize_surface(&nurbs, 1e-4);
961        match result {
962            RecognizedSurface::Cylinder { radius, .. } => {
963                assert!((radius - 3.0).abs() < 0.01, "radius {radius} != 3.0");
964            }
965            other => panic!("expected Cylinder, got {other:?}"),
966        }
967    }
968
969    #[test]
970    fn recognize_sphere_round_trip() {
971        let sphere = SphericalSurface::new(origin(), 5.0).unwrap();
972        let nurbs = sphere_to_nurbs(&sphere).unwrap();
973
974        let result = recognize_surface(&nurbs, 0.1);
975        match result {
976            RecognizedSurface::Sphere { center, radius } => {
977                let dist = Vec3::new(center.x(), center.y(), center.z()).length();
978                assert!(dist < 0.5, "center too far from origin: {dist}");
979                assert!((radius - 5.0).abs() < 0.5, "radius {radius} != 5.0");
980            }
981            other => panic!("expected Sphere, got {other:?}"),
982        }
983    }
984
985    #[test]
986    fn recognize_cone_round_trip() {
987        // Cone with apex at origin, axis +z, half-angle π/6 (from
988        // radial plane). At v=1 from apex (along generator),
989        // radial = cos(π/6) ≈ 0.866, axial = sin(π/6) = 0.5.
990        let half_angle = std::f64::consts::PI / 6.0;
991        let cone = ConicalSurface::new(origin(), z_axis(), half_angle).unwrap();
992        let nurbs = cone_to_nurbs(&cone, (1.0, 4.0)).unwrap();
993
994        match recognize_surface(&nurbs, 0.05) {
995            RecognizedSurface::Cone {
996                apex,
997                axis,
998                half_angle: ha,
999            } => {
1000                // Apex should be at origin within tolerance.
1001                assert!(
1002                    Vec3::new(apex.x(), apex.y(), apex.z()).length() < 0.05,
1003                    "apex {apex:?}"
1004                );
1005                // Axis should be along +z (or -z; both describe the same cone).
1006                assert!(
1007                    axis.dot(z_axis()).abs() > 1.0 - 1e-3,
1008                    "axis {axis:?} not aligned with z"
1009                );
1010                assert!(
1011                    (ha - half_angle).abs() < 1e-3,
1012                    "half_angle {ha} vs {half_angle}"
1013                );
1014            }
1015            other => panic!("expected Cone, got {other:?}"),
1016        }
1017    }
1018
1019    #[test]
1020    fn cylinder_is_recognized_as_cylinder_not_cone() {
1021        // True cylinders should match Cylinder (tested first), not
1022        // fall through to Cone.
1023        let cyl = CylindricalSurface::new(origin(), z_axis(), 2.0).unwrap();
1024        let nurbs = cylinder_to_nurbs(&cyl, (0.0, 5.0)).unwrap();
1025        assert!(matches!(
1026            recognize_surface(&nurbs, 1e-4),
1027            RecognizedSurface::Cylinder { .. }
1028        ));
1029    }
1030
1031    #[test]
1032    fn recognize_torus_round_trip() {
1033        let torus = ToroidalSurface::new(origin(), 3.0, 0.5).unwrap();
1034        let nurbs = torus_to_nurbs(&torus).unwrap();
1035
1036        match recognize_surface(&nurbs, 0.05) {
1037            RecognizedSurface::Torus {
1038                center,
1039                axis,
1040                major_radius,
1041                minor_radius,
1042            } => {
1043                assert!(
1044                    Vec3::new(center.x(), center.y(), center.z()).length() < 0.05,
1045                    "center {center:?} not at origin"
1046                );
1047                assert!(
1048                    axis.dot(z_axis()).abs() > 1.0 - 1e-3,
1049                    "axis {axis:?} not aligned with z"
1050                );
1051                assert!(
1052                    (major_radius - 3.0).abs() < 0.05,
1053                    "major_radius {major_radius} vs 3.0"
1054                );
1055                assert!(
1056                    (minor_radius - 0.5).abs() < 0.05,
1057                    "minor_radius {minor_radius} vs 0.5"
1058                );
1059            }
1060            other => panic!("expected Torus, got {other:?}"),
1061        }
1062    }
1063
1064    /// A flat bilinear square of side 1e-4 in the plane x + y + z = 1 spans
1065    /// control-point areas of 1e-8, under a tolerance of 1e-7, yet each corner
1066    /// lies 1e-4 off the line through two others: it is a plane.
1067    #[test]
1068    fn recognize_a_tiny_plane() {
1069        use brepkit_math::nurbs::surface::NurbsSurface;
1070        let side = 1e-4;
1071        let base = Point3::new(1.0, 0.0, 0.0);
1072        let u = Vec3::new(-1.0, 1.0, 0.0).normalize().unwrap() * side;
1073        let v = Vec3::new(-1.0, -1.0, 2.0).normalize().unwrap() * side;
1074        let nurbs = NurbsSurface::new(
1075            1,
1076            1,
1077            vec![0.0, 0.0, 1.0, 1.0],
1078            vec![0.0, 0.0, 1.0, 1.0],
1079            vec![vec![base, base + v], vec![base + u, base + u + v]],
1080            vec![vec![1.0; 2]; 2],
1081        )
1082        .unwrap();
1083        match recognize_surface(&nurbs, 1e-7) {
1084            RecognizedSurface::Plane { normal, d } => {
1085                let expected = Vec3::new(1.0, 1.0, 1.0).normalize().unwrap();
1086                let sign = normal.dot(expected).signum();
1087                assert!(
1088                    (normal * sign - expected).length() < 1e-9,
1089                    "normal {normal:?}"
1090                );
1091                assert!((d * sign - expected.x()).abs() < 1e-12, "d {d}");
1092            }
1093            other => panic!("expected Plane, got {other:?}"),
1094        }
1095    }
1096
1097    /// A 3 by 3 net over x in {0, 1e-6, 1} and y in {0, 1e-4, 1}, flat but
1098    /// for z = 5e-8 at (1e-6, 0), lies within 1e-7 of z = 0. Its first
1099    /// points crowd the origin, where the third of them stands 1e-6 off the
1100    /// line of the first two and tilts their plane by about 0.05: the
1101    /// normal comes from points spread across the net instead.
1102    #[test]
1103    fn recognize_a_plane_whose_first_points_crowd_together() {
1104        use brepkit_math::nurbs::surface::NurbsSurface;
1105        let (xs, ys) = ([0.0, 1e-6, 1.0], [0.0, 1e-4, 1.0]);
1106        let net: Vec<Vec<Point3>> = (0..3)
1107            .map(|i| {
1108                (0..3)
1109                    .map(|j| {
1110                        let z = if (i, j) == (1, 0) { 5e-8 } else { 0.0 };
1111                        Point3::new(xs[i], ys[j], z)
1112                    })
1113                    .collect()
1114            })
1115            .collect();
1116        let nurbs = NurbsSurface::new(
1117            2,
1118            2,
1119            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
1120            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
1121            net,
1122            vec![vec![1.0; 3]; 3],
1123        )
1124        .unwrap();
1125        match recognize_surface(&nurbs, 1e-7) {
1126            RecognizedSurface::Plane { normal, d } => {
1127                assert!(normal.z().abs() > 1.0 - 1e-12, "normal {normal:?}");
1128                assert!(d.abs() < 1e-7, "d {d}");
1129            }
1130            other => panic!("expected Plane, got {other:?}"),
1131        }
1132    }
1133
1134    #[test]
1135    fn cylinder_is_not_recognized_as_torus() {
1136        // A cylinder must hit Cylinder (tested first), not fall
1137        // through to Torus.
1138        let cyl = CylindricalSurface::new(origin(), z_axis(), 2.0).unwrap();
1139        let nurbs = cylinder_to_nurbs(&cyl, (0.0, 5.0)).unwrap();
1140        assert!(matches!(
1141            recognize_surface(&nurbs, 1e-4),
1142            RecognizedSurface::Cylinder { .. }
1143        ));
1144    }
1145}