Skip to main content

brep_kernel/geometry/analytic_surface/
recognition.rs

1use super::*;
2
3pub(super) fn nearly_equal_points(a: &NurbsSurface, b: &NurbsSurface, scale: f64) -> bool {
4    if a.degree_u != b.degree_u
5        || a.degree_v != b.degree_v
6        || a.knots_u.len() != b.knots_u.len()
7        || a.knots_v.len() != b.knots_v.len()
8        || a.control_points.len() != b.control_points.len()
9    {
10        return false;
11    }
12    // Numerical knot dedup for exact-reconstruction recognition (see curve.rs).
13    let knot_tolerance = crate::curve::KNOT_DEDUP_EPS;
14    if a.knots_u
15        .iter()
16        .zip(&b.knots_u)
17        .any(|(x, y)| (x - y).abs() > knot_tolerance)
18        || a.knots_v
19            .iter()
20            .zip(&b.knots_v)
21            .any(|(x, y)| (x - y).abs() > knot_tolerance)
22    {
23        return false;
24    }
25    let tolerance = RECOGNITION_TOLERANCE * scale.max(1.0);
26    a.control_points
27        .iter()
28        .zip(&b.control_points)
29        .all(|(row_a, row_b)| {
30            row_a.len() == row_b.len()
31                && row_a.iter().zip(row_b).all(|(p, q)| {
32                    (p.x - q.x).abs() <= tolerance
33                        && (p.y - q.y).abs() <= tolerance
34                        && (p.z - q.z).abs() <= tolerance
35                        && (p.w - q.w).abs() <= RECOGNITION_TOLERANCE
36                })
37        })
38}
39
40pub(super) fn surface_scale(surface: &NurbsSurface) -> f64 {
41    surface
42        .control_points
43        .iter()
44        .flatten()
45        .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
46        .fold(0.0, f64::max)
47}
48
49pub(super) fn full_circle_knots(spans: usize) -> Vec<f64> {
50    let mut knots = vec![0.0, 0.0, 0.0];
51    for index in 1..spans {
52        let knot = index as f64 / spans as f64;
53        knots.extend([knot, knot]);
54    }
55    knots.extend([1.0, 1.0, 1.0]);
56    knots
57}
58
59fn recognize_plane(surface: &NurbsSurface) -> Option<AnalyticSurface> {
60    if !surface.is_affine().unwrap_or(false) {
61        return None;
62    }
63    let u_domain = [surface.knots_u[0], *surface.knots_u.last()?];
64    let v_domain = [surface.knots_v[0], *surface.knots_v.last()?];
65    let extent_u = u_domain[1] - u_domain[0];
66    let extent_v = v_domain[1] - v_domain[0];
67    if extent_u <= 0.0 || extent_v <= 0.0 {
68        return None;
69    }
70    let p00 = surface.control_points[0][0].point().ok()?;
71    let p10 = surface.control_points[1][0].point().ok()?;
72    let p01 = surface.control_points[0][1].point().ok()?;
73    Some(AnalyticSurface::Plane {
74        origin: p00,
75        u_dir: p10.sub(p00).scale(1.0 / extent_u),
76        v_dir: p01.sub(p00).scale(1.0 / extent_v),
77        u_domain,
78        v_domain,
79    })
80}
81
82fn recognize_revolution(surface: &NurbsSurface) -> Option<AnalyticSurface> {
83    if surface.degree_u != 2 || surface.control_points.len() != 9 {
84        return None;
85    }
86    let expected_knots = full_circle_knots(4);
87    if surface.knots_u.len() != expected_knots.len()
88        || surface
89            .knots_u
90            .iter()
91            .zip(&expected_knots)
92            .any(|(a, b)| (a - b).abs() > 1e-12)
93    {
94        return None;
95    }
96    let scale = surface_scale(surface);
97    let rows = &surface.control_points;
98    // Antipodal full-circle control points straddle the axis; their midpoint
99    // lies on it for every generatrix column.
100    let mut frame: Option<RevolutionFrame> = None;
101    for column in 0..rows[0].len() {
102        let p0 = rows[0][column].point().ok()?;
103        let p180 = rows[4][column].point().ok()?;
104        let p90 = rows[2][column].point().ok()?;
105        let center = p0.add(p180).scale(0.5);
106        let radial = p0.sub(center);
107        let radius = radial.length();
108        if radius <= RECOGNITION_TOLERANCE * scale.max(1.0) {
109            continue;
110        }
111        let toward_90 = p90.sub(center);
112        let axis = radial.cross(toward_90).normalized().ok()?;
113        let x_axis = radial.scale(1.0 / radius);
114        let y_axis = axis.cross(x_axis);
115        frame = Some(RevolutionFrame {
116            origin: center,
117            axis,
118            x_axis,
119            y_axis,
120        });
121        break;
122    }
123    let frame = frame?;
124    let generatrix =
125        NurbsCurve::new(surface.degree_v, surface.knots_v.clone(), rows[0].clone()).ok()?;
126    let rebuilt =
127        make_revolution(frame.origin, frame.axis, &generatrix, std::f64::consts::TAU).ok()?;
128    if !nearly_equal_points(surface, &rebuilt, scale) {
129        return None;
130    }
131    classify_generatrix(surface, frame, &generatrix, scale)
132}
133
134fn classify_generatrix(
135    surface: &NurbsSurface,
136    frame: RevolutionFrame,
137    generatrix: &NurbsCurve,
138    scale: f64,
139) -> Option<AnalyticSurface> {
140    let tolerance = RECOGNITION_TOLERANCE * scale.max(1.0);
141    let controls = &generatrix.control_points;
142    if generatrix.degree == 1
143        && controls.len() == 2
144        && (controls[0].w - 1.0).abs() <= RECOGNITION_TOLERANCE
145        && (controls[1].w - 1.0).abs() <= RECOGNITION_TOLERANCE
146        && (surface.knots_v[0], *surface.knots_v.last()?) == (0.0, 1.0)
147        && generatrix_is_meridional_half_ray(generatrix, &frame)
148    {
149        let p0 = controls[0].point().ok()?;
150        let p1 = controls[1].point().ok()?;
151        let (_, rho0, z0) = frame.cylindrical(p0);
152        let (_, rho1, z1) = frame.cylindrical(p1);
153        let height = z1 - z0;
154        if height.abs() <= tolerance {
155            return None;
156        }
157        // Rebase the frame origin to the generatrix start's axial position
158        // so v = axial / height exactly.
159        let origin = frame.origin.add(frame.axis.scale(z0));
160        return Some(AnalyticSurface::RuledRevolution {
161            frame: RevolutionFrame { origin, ..frame },
162            rho0,
163            rho1,
164            height,
165        });
166    }
167    if generatrix.degree == 2 && controls.len() == 5 {
168        // Polar meridian: poles at both ends, equator at the middle column.
169        let south = controls[0].point().ok()?;
170        let north = controls[4].point().ok()?;
171        let (_, rho_south, z_south) = frame.cylindrical(south);
172        let (_, rho_north, z_north) = frame.cylindrical(north);
173        if rho_south <= tolerance && rho_north <= tolerance {
174            let radius = (z_north - z_south) * 0.5;
175            if radius <= tolerance {
176                return None;
177            }
178            let center = frame.origin.add(frame.axis.scale(z_south + radius));
179            let meridian = make_arc(
180                center,
181                frame.x_axis,
182                frame.axis,
183                radius,
184                -std::f64::consts::FRAC_PI_2,
185                std::f64::consts::FRAC_PI_2,
186            )
187            .ok()?;
188            if curves_nearly_equal(generatrix, &meridian, scale) {
189                return Some(AnalyticSurface::Sphere {
190                    frame: RevolutionFrame {
191                        origin: center,
192                        ..frame
193                    },
194                    radius,
195                });
196            }
197        }
198        return None;
199    }
200    if generatrix.degree == 2 && controls.len() == 9 {
201        let tube_start = controls[0].point().ok()?;
202        let tube_opposite = controls[4].point().ok()?;
203        let tube_center = tube_start.add(tube_opposite).scale(0.5);
204        let minor = tube_start.sub(tube_center).length();
205        let (_, major, axial) = frame.cylindrical(tube_center);
206        if minor <= tolerance || major <= minor || axial.abs() > tolerance {
207            return None;
208        }
209        let tube = make_arc(
210            tube_center,
211            frame.x_axis,
212            frame.axis,
213            minor,
214            0.0,
215            std::f64::consts::TAU,
216        )
217        .ok()?;
218        if curves_nearly_equal(generatrix, &tube, scale) {
219            return Some(AnalyticSurface::Torus {
220                frame,
221                major_radius: major,
222                minor_radius: minor,
223            });
224        }
225        return None;
226    }
227    None
228}
229
230fn curves_nearly_equal(a: &NurbsCurve, b: &NurbsCurve, scale: f64) -> bool {
231    if a.degree != b.degree
232        || a.knots.len() != b.knots.len()
233        || a.control_points.len() != b.control_points.len()
234    {
235        return false;
236    }
237    if a.knots
238        .iter()
239        .zip(&b.knots)
240        .any(|(x, y)| (x - y).abs() > 1e-12)
241    {
242        return false;
243    }
244    let tolerance = RECOGNITION_TOLERANCE * scale.max(1.0);
245    a.control_points
246        .iter()
247        .zip(&b.control_points)
248        .all(|(p, q)| {
249            (p.x - q.x).abs() <= tolerance
250                && (p.y - q.y).abs() <= tolerance
251                && (p.z - q.z).abs() <= tolerance
252                && (p.w - q.w).abs() <= RECOGNITION_TOLERANCE
253        })
254}
255
256pub fn recognize(surface: &NurbsSurface) -> Option<AnalyticSurface> {
257    recognize_plane(surface)
258        .or_else(|| recognize_revolution(surface))
259        .or_else(|| recognize_general_revolution(surface))
260}
261
262/// Fallback recognizer: any `make_revolution` product the specialized
263/// quadric recognizers above rejected (arbitrary generatrix, partial
264/// sweep), validated by exact reconstruction in `revolution_structure`.
265fn recognize_general_revolution(surface: &NurbsSurface) -> Option<AnalyticSurface> {
266    let structure = revolution_structure(surface)?;
267    let spans = (surface.control_points.len() - 1) / 2;
268    Some(AnalyticSurface::Revolution {
269        frame: structure.frame,
270        spans,
271        sweep: structure.sweep,
272        generatrix: structure.generatrix,
273    })
274}
275
276/// Whether the generatrix lies in the axis frame's starting meridian
277/// half-plane.  Only then can a surface projection be reduced to projection
278/// onto the unrotated 3D generatrix.
279pub(super) fn generatrix_is_meridional_half_ray(
280    generatrix: &NurbsCurve,
281    frame: &RevolutionFrame,
282) -> bool {
283    let mut scale = 0.0_f64;
284    for control in &generatrix.control_points {
285        let Ok(point) = control.point() else {
286            return false;
287        };
288        let offset = point.sub(frame.origin);
289        let radial = offset.sub(frame.axis.scale(offset.dot(frame.axis)));
290        scale = scale.max(radial.length());
291    }
292    let tolerance = RECOGNITION_TOLERANCE * scale.max(1.0);
293    generatrix.control_points.iter().all(|control| {
294        let Ok(point) = control.point() else {
295            return false;
296        };
297        let offset = point.sub(frame.origin);
298        let radial = offset.sub(frame.axis.scale(offset.dot(frame.axis)));
299        radial.dot(frame.y_axis).abs() <= tolerance
300            && radial.dot(frame.x_axis) >= -tolerance
301    })
302}
303
304impl AnalyticSurface {
305    /// The centre and radius of a carrier that IS a sphere, whichever way
306    /// it is parameterized.
307    ///
308    /// The `Sphere` variant is the south-to-north meridian swept
309    /// counter-clockwise about its polar axis — exactly what `make_sphere_surface`
310    /// builds.  A REFLECTED sphere (the mirror feature, any negative-determinant
311    /// `transform_brep`) is the same point set with the opposite handedness:
312    /// relative to the u-consistent right-handed frame the recognizer derives,
313    /// its meridian runs north → south, so the `Sphere` template rejects it and
314    /// it recognizes as a general `Revolution`.  That representation is
315    /// faithful (explicit generatrix, exact projection), but a closed-form
316    /// consumer keyed on the `Sphere` variant alone then falls back to the
317    /// marcher — which cannot terminate on a point tangency (a corner blend's
318    /// sphere kissing the mirrored copy's face plane).
319    ///
320    /// Verified by exact reconstruction like every recognition: the generatrix
321    /// must be the polar meridian semicircle of `(centre, radius)` in either
322    /// direction, every control point and weight matching.
323    pub fn sphere_geometry(&self) -> Option<(Vec3, f64)> {
324        match self {
325            AnalyticSurface::Sphere { frame, radius } => Some((frame.origin, *radius)),
326            AnalyticSurface::Revolution {
327                frame,
328                sweep,
329                generatrix,
330                ..
331            } => {
332                if *sweep < std::f64::consts::TAU - 1e-9 {
333                    return None;
334                }
335                let controls = &generatrix.control_points;
336                if generatrix.degree != 2 || controls.len() != 5 {
337                    return None;
338                }
339                let start = controls[0].point().ok()?;
340                let end = controls[4].point().ok()?;
341                let scale = curve_scale(generatrix);
342                let tolerance = RECOGNITION_TOLERANCE * scale.max(1.0);
343                // Both meridian ends sit on the axis: the poles.
344                let (_, rho_start, z_start) = frame.cylindrical(start);
345                let (_, rho_end, z_end) = frame.cylindrical(end);
346                if rho_start > tolerance || rho_end > tolerance {
347                    return None;
348                }
349                let radius = (z_end - z_start).abs() * 0.5;
350                if radius <= tolerance {
351                    return None;
352                }
353                // The frame origin is on the axis but not necessarily at the
354                // centre (`revolution_structure` anchors it at the generatrix's
355                // first control circle — a pole here); the centre is the poles'
356                // midpoint.
357                let center = start.add(end).scale(0.5);
358                // Rebuild the meridian in the direction this generatrix runs.
359                let polar = if z_end > z_start {
360                    frame.axis
361                } else {
362                    frame.axis.scale(-1.0)
363                };
364                let meridian = make_arc(
365                    center,
366                    frame.x_axis,
367                    polar,
368                    radius,
369                    -std::f64::consts::FRAC_PI_2,
370                    std::f64::consts::FRAC_PI_2,
371                )
372                .ok()?;
373                curves_nearly_equal(generatrix, &meridian, scale).then_some((center, radius))
374            }
375            _ => None,
376        }
377    }
378
379    /// The centre, radius and a RIGHT-HANDED orthonormal basis of a carrier
380    /// that IS a sphere — [`Self::sphere_geometry`] plus the orientation the
381    /// pole-free cube atlas (`geometry/sphere_chart.rs`) is built on.
382    ///
383    /// The basis is `[x_axis, y_axis, axis]` of the recognition frame, so
384    /// `basis[2]` is the polar axis: the atlas puts the two degenerate poles at
385    /// the CENTRES of its `±z` charts, which is the whole point — a pole is then
386    /// an ordinary interior point of a regular chart rather than a coordinate
387    /// singularity. Derived only from data stored on the surface, so two call
388    /// sites looking at the same surface always build the same atlas, and a
389    /// REFLECTED sphere (which recognizes as a general `Revolution`) gets a
390    /// basis exactly as a direct one does.
391    pub fn sphere_frame(&self) -> Option<(Vec3, f64, [Vec3; 3])> {
392        let (center, radius) = self.sphere_geometry()?;
393        let frame = match self {
394            AnalyticSurface::Sphere { frame, .. } => frame,
395            AnalyticSurface::Revolution { frame, .. } => frame,
396            _ => return None,
397        };
398        Some((center, radius, [frame.x_axis, frame.y_axis, frame.axis]))
399    }
400
401    /// The frame (origin ON the axis at the tube centre's axial position),
402    /// major and minor radius of a carrier that IS a torus, whichever way it
403    /// is parameterized — the `Torus` variant, or the general `Revolution` a
404    /// reflected torus recognizes as (its tube circle runs the opposite way
405    /// round, so the `Torus` template rejects it; see [`Self::sphere_geometry`]).
406    /// Verified by exact reconstruction of the tube circle in either direction.
407    pub fn torus_geometry(&self) -> Option<(RevolutionFrame, f64, f64)> {
408        match self {
409            AnalyticSurface::Torus {
410                frame,
411                major_radius,
412                minor_radius,
413            } => Some((frame.clone(), *major_radius, *minor_radius)),
414            AnalyticSurface::Revolution {
415                frame,
416                sweep,
417                generatrix,
418                ..
419            } => {
420                if *sweep < std::f64::consts::TAU - 1e-9 {
421                    return None;
422                }
423                let controls = &generatrix.control_points;
424                if generatrix.degree != 2 || controls.len() != 9 {
425                    return None;
426                }
427                let tube_start = controls[0].point().ok()?;
428                let tube_opposite = controls[4].point().ok()?;
429                let tube_center = tube_start.add(tube_opposite).scale(0.5);
430                let minor = tube_start.sub(tube_center).length();
431                let scale = curve_scale(generatrix);
432                let tolerance = RECOGNITION_TOLERANCE * scale.max(1.0);
433                let (_, major, axial) = frame.cylindrical(tube_center);
434                if minor <= tolerance || major <= minor {
435                    return None;
436                }
437                for polar in [frame.axis, frame.axis.scale(-1.0)] {
438                    let tube = make_arc(
439                        tube_center,
440                        frame.x_axis,
441                        polar,
442                        minor,
443                        0.0,
444                        std::f64::consts::TAU,
445                    )
446                    .ok()?;
447                    if curves_nearly_equal(generatrix, &tube, scale) {
448                        return Some((
449                            RevolutionFrame {
450                                origin: frame.origin.add(frame.axis.scale(axial)),
451                                ..frame.clone()
452                            },
453                            major,
454                            minor,
455                        ));
456                    }
457                }
458                None
459            }
460            _ => None,
461        }
462    }
463}
464
465/// Largest absolute Cartesian coordinate over a curve's control points — the
466/// curve counterpart of [`surface_scale`], for scale-relative tolerances.
467fn curve_scale(curve: &NurbsCurve) -> f64 {
468    curve
469        .control_points
470        .iter()
471        .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
472        .fold(0.0, f64::max)
473}