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