Skip to main content

ogeom_heal/
canonical.rs

1//! Canonical simplification: recognizing that exact geometry is secretly
2//! analytic: a B-spline surface that *is* a cylinder becomes the cylinder.
3//!
4//! The input is exact geometry, not samples of unknown provenance: the
5//! decision is made against the surface's own equation, sampled on its own
6//! chart, with its own normals. That is what separates this from reverse
7//! engineering: nothing is guessed about what the data means, only checked
8//! against a candidate the estimators propose. A candidate is accepted when
9//! **every** sample sits within the caller's stated tolerance of it, and the
10//! certificate is the worst deviation actually measured; a surface that is
11//! genuinely free-form at that tolerance stays what it is.
12//!
13//! Exchange formats routinely spell a cylinder out pointwise, and every
14//! algorithm downstream (intersection, blending, the boolean's closed forms)
15//! is faster and exacter on the analytic carrier.
16//!
17//! The estimators are classical: a plane is the mean normal; a sphere's
18//! centre is where the normal lines meet, in least squares; a cylinder's
19//! axis is the direction the normals avoid (their covariance's smallest
20//! eigenvector), and a cone adds the linear taper of radius against height.
21
22use ogeom_algo::Built;
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::Curve3d as _;
25use ogeom_geom::Surface as _;
26use ogeom_geom::{
27    CircleCurve, ConeSurface, Curve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
28    SurfaceGeometry,
29};
30use ogeom_math::Circle;
31use ogeom_math::{Cone, Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
32use ogeom_topo::{Filter, Model, NodeData, Shape, ShapeType, explore};
33
34/// What one face's simplification found.
35#[derive(Debug, Clone, PartialEq)]
36pub enum Simplified {
37    /// The surface is a plane, to the deviation measured.
38    Plane {
39        /// The worst deviation any sample showed.
40        worst: f64,
41    },
42    /// A cylinder.
43    Cylinder {
44        /// The recognized radius.
45        radius: f64,
46        /// The worst deviation any sample showed.
47        worst: f64,
48    },
49    /// A cone.
50    Cone {
51        /// The recognized half angle, in radians.
52        half_angle: f64,
53        /// The worst deviation any sample showed.
54        worst: f64,
55    },
56    /// A sphere.
57    Sphere {
58        /// The recognized radius.
59        radius: f64,
60        /// The worst deviation any sample showed.
61        worst: f64,
62    },
63}
64
65/// A report of what was simplified, face by face.
66#[derive(Debug, Default)]
67pub struct CanonicalReport {
68    /// One entry per face whose surface became analytic.
69    pub simplified: Vec<Simplified>,
70    /// Faces examined and left as they were: already analytic, or
71    /// genuinely free-form at the stated tolerance.
72    pub untouched: usize,
73}
74
75/// Replace every free-form surface in `shape` that is secretly analytic,
76/// within `tolerance`, by the plane, cylinder, cone or sphere it is.
77///
78/// Faces are rebuilt on the recognized carrier with exact pcurves; their
79/// edges' space curves are untouched, because the curves were never wrong.
80/// A surface that does not verify is left untouched: the decision is
81/// the product, and a wrong yes is a solid that measures nearly right with
82/// the wrong surface under every later operation.
83///
84/// # Errors
85///
86/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
87/// `tolerance` is not a positive distance, or a recognized face cannot be
88/// rebuilt on its own boundary.
89pub fn canonical_simplify(
90    model: &mut Model,
91    shape: &Shape,
92    tolerance: f64,
93    tol: Tolerances,
94) -> OgeomResult<(Built, CanonicalReport)> {
95    if !tolerance.is_finite() || tolerance <= 0.0 {
96        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
97    }
98    let mut report = CanonicalReport::default();
99    let mut history = ogeom_algo::History::new();
100    let mut replaced: Vec<(Shape, Shape)> = Vec::new();
101    // Edges whose free-form curves turned out to be lines or circles,
102    // rebuilt once and shared: two faces meeting along one edge must keep
103    // meeting along one edge.
104    let mut edge_map: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
105        ogeom_core::FastMap::default();
106
107    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
108        let Some(data) = model.node(&face).and_then(|n| match n.data() {
109            NodeData::Face(d) => Some(d.clone()),
110            _ => None,
111        }) else {
112            continue;
113        };
114        let Some(surface) = model.geometry().surface(data.surface).cloned() else {
115            continue;
116        };
117        // Only free-form carriers are candidates; the analytic ones already
118        // are what they are.
119        if !matches!(surface, SurfaceGeometry::BSpline(_)) {
120            report.untouched += 1;
121            continue;
122        }
123        let placement = face.transform(model.datums())?;
124        let world = {
125            use ogeom_geom::Transformable as _;
126            surface.transformed(&placement, tol)?
127        };
128        let Some(found) = recognize_surface(&world, tolerance, tol)? else {
129            report.untouched += 1;
130            continue;
131        };
132        let carrier: SurfaceGeometry = match &found {
133            Simplified::Plane { .. } => {
134                let plane = fit_plane(&world, tol)?;
135                PlaneSurface::over(plane, (-1e5, 1e5), (-1e5, 1e5))?.into()
136            }
137            Simplified::Cylinder { .. } => {
138                let cylinder = fit_cylinder(&world, tol)?;
139                CylinderSurface::new(cylinder, (-1e5, 1e5))?.into()
140            }
141            Simplified::Cone { .. } => {
142                let cone = fit_cone(&world, tol)?;
143                ConeSurface::new(cone, (-1e5, 1e5))?.into()
144            }
145            Simplified::Sphere { .. } => {
146                let sphere = fit_sphere(&world, tol)?;
147                SphereSurface::new(sphere).into()
148            }
149        };
150        // Rebuild the face on the carrier from its own boundary. The edges'
151        // space curves never lied about *where* they run, but a rim spelt
152        // as a B-spline that is exactly a circle becomes the circle first,
153        // or the exact-pcurve machinery has nothing to project in closed
154        // form. Curve recognition is held to the same certificate as the
155        // surfaces': every sample within tolerance, or no change.
156        //
157        // A cylinder, cone or sphere has its own normal away from its axis
158        // or centre whichever side the material was on; a bore spelt as a
159        // spline with its normal towards the axis comes out turned. The
160        // flag carries over, and turns once more where the normals oppose.
161        // The rings are read as the face stores them, keeping it on their
162        // left about the old surface's normal, and walked back where the
163        // new normal opposes it.
164        let flipped = ogeom_algo::normals_oppose(&world, &carrier, tol)?;
165        let mut wires = Vec::new();
166        for wire in model.ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))? {
167            let mut edges = Vec::new();
168            for edge in model.ordered_children_of(&wire)? {
169                // Mapped in its stored direction; the occurrence's own
170                // direction goes back on below, once.
171                let stored = if edge.orientation() == ogeom_topo::Orientation::Reversed {
172                    edge.reversed()
173                } else {
174                    edge.clone()
175                };
176                let mapped = simplified_edge(model, &stored, tolerance, &mut edge_map, tol)?;
177                edges.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
178                    mapped.reversed()
179                } else {
180                    mapped
181                });
182            }
183            if flipped {
184                edges = edges.iter().rev().map(Shape::reversed).collect();
185            }
186            wires.push(edges);
187        }
188        let rebuilt = ogeom_algo::make_face_with_pcurves(model, carrier, &wires, tol)?.shape;
189        let reversed = face.orientation() == ogeom_topo::Orientation::Reversed;
190        let rebuilt = if reversed == flipped {
191            rebuilt
192        } else {
193            rebuilt.reversed()
194        };
195        // The carrier stands up to the certificate off the surface the
196        // boundary was drawn on: the face states it, and each edge states
197        // how far its chart on the carrier, lifted, stands from its curve.
198        let worst = match found {
199            Simplified::Plane { worst }
200            | Simplified::Cylinder { worst, .. }
201            | Simplified::Cone { worst, .. }
202            | Simplified::Sphere { worst, .. } => worst,
203        };
204        if worst > tol.confusion() {
205            model.widen(
206                &rebuilt,
207                ogeom_core::Tolerance::new(worst + tol.confusion())?,
208            )?;
209        }
210        let edges = ogeom_topo::explore_unique(model, &rebuilt, ShapeType::Edge)?;
211        ogeom_algo::state_pcurve_gaps_of(model, &edges, tol)?;
212        history.modify(&face, rebuilt.clone());
213        replaced.push((face, rebuilt));
214        report.simplified.push(found);
215    }
216
217    if replaced.is_empty() {
218        history.generate(shape, shape.clone());
219        return Ok((Built::new(shape.clone(), history), report));
220    }
221
222    // Reassemble: untouched faces as they are, recognized ones rebuilt, the
223    // shell sewn back on the shared edges.
224    let mut faces = Vec::new();
225    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
226        match replaced.iter().find(|(old, _)| old.node() == face.node()) {
227            Some((_, new)) => faces.push(new.clone()),
228            None => faces.push(face),
229        }
230    }
231    let sewn = ogeom_algo::sew(model, &faces, tol)?;
232    let out = match sewn.shells.as_slice() {
233        [shell] if ogeom_algo::is_shell_closed(model, shell)? => {
234            ogeom_algo::make_solid(model, core::slice::from_ref(shell))?.shape
235        }
236        [shell] => shell.clone(),
237        _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
238    };
239    history.modify(shape, out.clone());
240    Ok((Built::new(out, history), report))
241}
242
243/// Sampled positions and unit normals over the surface's own chart.
244fn samples(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<(Vec<Point>, Vec<Vector>)> {
245    const N: usize = 9;
246    let ((u0, u1), (v0, v1)) = surface.domain();
247    let mut points = Vec::with_capacity(N * N);
248    let mut normals = Vec::with_capacity(N * N);
249    for i in 0..N {
250        for j in 0..N {
251            // Interior offsets: chart corners of a swept patch can be
252            // degenerate, and a degenerate sample's normal is noise.
253            #[allow(clippy::cast_precision_loss, reason = "a sample index")]
254            let fu = (i as f64 + 0.5) / N as f64;
255            #[allow(clippy::cast_precision_loss, reason = "a sample index")]
256            let fv = (j as f64 + 0.5) / N as f64;
257            let u = u0 + (u1 - u0) * fu;
258            let v = v0 + (v1 - v0) * fv;
259            points.push(surface.point_at(u, v, tol)?);
260            normals.push(surface.normal_at(u, v, tol)?.vector());
261        }
262    }
263    Ok((points, normals))
264}
265
266/// The candidate the estimators propose, verified against every sample.
267///
268/// # Errors
269///
270/// As the samplers'.
271pub fn recognize_surface(
272    surface: &SurfaceGeometry,
273    tolerance: f64,
274    tol: Tolerances,
275) -> OgeomResult<Option<Simplified>> {
276    // The estimators read the interior samples, whose normals are sound;
277    // each candidate is verified on these and on a grid over the whole
278    // chart, boundary strips included, at every span of a patch.
279    let (mut points, normals) = samples(surface, tol)?;
280    points.extend(verification_grid(surface, tol)?);
281    // Most specific last: a plane verifies on nothing curved, a sphere and
282    // cylinder disagree everywhere but a torus, so the order only decides
283    // ties on genuinely ambiguous windows, where any verified answer is
284    // right by the stated tolerance.
285    if let Ok(plane) = fit_plane(surface, tol) {
286        let worst = worst_of(&points, |p| plane.signed_distance_to(p).abs());
287        if worst <= tolerance {
288            return Ok(Some(Simplified::Plane { worst }));
289        }
290    }
291    if let Ok(sphere) = fit_sphere(surface, tol) {
292        let worst = worst_of(&points, |p| {
293            (p.distance(sphere.centre()) - sphere.radius()).abs()
294        });
295        if worst <= tolerance {
296            return Ok(Some(Simplified::Sphere {
297                radius: sphere.radius(),
298                worst,
299            }));
300        }
301    }
302    if let Ok(cylinder) = fit_cylinder(surface, tol) {
303        let worst = worst_of(&points, |p| cylinder.distance_to(p).abs());
304        if worst <= tolerance {
305            return Ok(Some(Simplified::Cylinder {
306                radius: cylinder.radius(),
307                worst,
308            }));
309        }
310    }
311    if let Ok(cone) = fit_cone(surface, tol) {
312        let worst = worst_of(&points, |p| cone.distance_to(p).abs());
313        if worst <= tolerance {
314            return Ok(Some(Simplified::Cone {
315                half_angle: cone.half_angle(),
316                worst,
317            }));
318        }
319    }
320    let _ = normals;
321    Ok(None)
322}
323
324/// Points over the surface's whole chart, its edges included: each span of
325/// a patch cut in two each way, at least sixteen pieces a direction and
326/// no more than 256.
327fn verification_grid(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Vec<Point>> {
328    const FEWEST: usize = 16;
329    const MOST: usize = 256;
330    let ((u0, u1), (v0, v1)) = surface.domain();
331    let direction = |range: (f64, f64), knots: Option<&ogeom_math::KnotVector>| -> Vec<f64> {
332        let breaks: Vec<f64> = knots.map_or_else(Vec::new, |k| {
333            k.distinct().into_iter().map(|(value, _)| value).collect()
334        });
335        let cut = ogeom_algo::traced::stations_between(range, &breaks, 1, FEWEST);
336        if cut.len() > MOST + 1 {
337            ogeom_algo::traced::stations_between(range, &[], 1, MOST)
338        } else {
339            cut
340        }
341    };
342    let (us, vs) = match surface {
343        SurfaceGeometry::BSpline(patch) => (
344            direction((u0, u1), Some(patch.u_knots())),
345            direction((v0, v1), Some(patch.v_knots())),
346        ),
347        _ => (direction((u0, u1), None), direction((v0, v1), None)),
348    };
349    let mut out = Vec::with_capacity(us.len() * vs.len());
350    for &u in &us {
351        for &v in &vs {
352            out.push(surface.point_at(u, v, tol)?);
353        }
354    }
355    Ok(out)
356}
357
358fn worst_of(points: &[Point], f: impl Fn(Point) -> f64) -> f64 {
359    points.iter().map(|p| f(*p)).fold(0.0, f64::max)
360}
361
362/// The edge, its free-form curve replaced by the line or circle it verifiably
363/// is, or the edge itself when it is analytic already or genuinely free.
364fn simplified_edge(
365    model: &mut Model,
366    edge: &Shape,
367    tolerance: f64,
368    cache: &mut ogeom_core::FastMap<ogeom_topo::TShapeId, Shape>,
369    tol: Tolerances,
370) -> OgeomResult<Shape> {
371    if let Some(found) = cache.get(&edge.node()) {
372        return Ok(found.clone());
373    }
374    let Some((curve_id, range)) = model
375        .node(edge)
376        .and_then(|n| n.data().as_edge())
377        .and_then(|d| match d.curve3d()? {
378            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
379            _ => None,
380        })
381    else {
382        return Ok(edge.clone());
383    };
384    let Some(curve) = model.geometry().curve(curve_id).cloned() else {
385        return Ok(edge.clone());
386    };
387    if !matches!(curve, Curve::BSpline(_)) {
388        return Ok(edge.clone());
389    }
390    let placement = edge.transform(model.datums())?;
391    let world = {
392        use ogeom_geom::Transformable as _;
393        curve.transformed(&placement, tol)?
394    };
395    // Sampled at every span of the curve, so detail between a fixed
396    // count of samples is seen; the replacement states the worst measured.
397    let pts = ogeom_algo::traced::stations(&world, range, 16)
398        .into_iter()
399        .map(|t| world.point_at(t, tol))
400        .collect::<OgeomResult<Vec<Point>>>()?;
401    let n = pts.len();
402    let stated = |model: &mut Model, built: &Shape, worst: f64| -> OgeomResult<()> {
403        if worst > tol.confusion() {
404            model.widen(built, ogeom_core::Tolerance::new(worst + tol.confusion())?)?;
405        }
406        Ok(())
407    };
408    let bounds = model.children_of(edge)?;
409    let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
410        return Ok(edge.clone());
411    };
412    let start = pts[0];
413    let end = pts[n - 1];
414    let closed = start.distance(end) <= tol.confusion();
415
416    // A line: every sample on the chord.
417    if !closed && start.distance(end) > tol.confusion() {
418        let dir = Direction::new(end - start, tol)?;
419        let worst = worst_of(&pts, |p| (p - start).cross(dir.vector()).magnitude());
420        if worst <= tolerance {
421            let line = LineCurve::segment(start, end, tol)?;
422            let built = ogeom_algo::make_edge_between(
423                model,
424                Curve::from(line),
425                (0.0, start.distance(end)),
426                &va,
427                &vb,
428                tol,
429            )?
430            .shape;
431            stated(model, &built, worst)?;
432            cache.insert(edge.node(), built.clone());
433            return Ok(built);
434        }
435    }
436
437    // A circle: planar, and equidistant from the fitted centre.
438    if let Ok(plane) = fit_plane_points(&pts, tol) {
439        let planar = worst_of(&pts, |p| plane.signed_distance_to(p).abs());
440        if planar <= tolerance {
441            let frame = plane.frame();
442            let flat: Vec<(f64, f64)> = pts
443                .iter()
444                .map(|p| {
445                    let l = frame.to_local(*p);
446                    (l.x, l.y)
447                })
448                .collect();
449            if let Ok((cx, cy, r)) = fit_circle_2d(&flat) {
450                let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
451                let round = worst_of(&pts, |p| (p.distance(centre) - r).abs());
452                if round <= tolerance {
453                    // The frame's x-axis runs through the start point, so a
454                    // closed rim's range is (0, τ) inside the curve's own
455                    // domain rather than a window shifted past it.
456                    let x = Direction::new(start - centre, tol)?;
457                    let cframe = Frame::new(centre, frame.z(), x, tol)?;
458                    let circle = Circle::new(cframe, r, tol)?;
459                    let angle = |p: Point| -> f64 {
460                        let l = cframe.to_local(p);
461                        l.y.atan2(l.x).rem_euclid(core::f64::consts::TAU)
462                    };
463                    let (t0, t1) = if closed {
464                        // The frame's x-axis runs through the start by
465                        // construction, so its angle is zero; computed, it
466                        // is −ε, which rem_euclid folds to τ and the range
467                        // overflows the domain by a full turn.
468                        (0.0, core::f64::consts::TAU)
469                    } else {
470                        let a = angle(start);
471                        let mut b = angle(end);
472                        // The arc runs the way the samples do.
473                        let m = angle(pts[n / 2]);
474                        let fwd = (m - a).rem_euclid(core::f64::consts::TAU)
475                            <= (b - a).rem_euclid(core::f64::consts::TAU);
476                        if !fwd {
477                            b -= core::f64::consts::TAU;
478                        }
479                        if b <= a {
480                            b += core::f64::consts::TAU;
481                        }
482                        (a, b)
483                    };
484                    let built = ogeom_algo::make_edge_between(
485                        model,
486                        Curve::from(CircleCurve::new(circle)),
487                        (t0, t1),
488                        &va,
489                        &vb,
490                        tol,
491                    )?
492                    .shape;
493                    stated(model, &built, round.max(planar))?;
494                    cache.insert(edge.node(), built.clone());
495                    return Ok(built);
496                }
497            }
498        }
499    }
500    Ok(edge.clone())
501}
502
503/// The best plane through raw points: centroid plus the cross-product mean.
504fn fit_plane_points(points: &[Point], tol: Tolerances) -> OgeomResult<Plane> {
505    let c = centroid(points);
506    let mut n = Vector::ZERO;
507    for w in points.windows(2) {
508        n += (w[0] - c).cross(w[1] - c);
509    }
510    Ok(Plane::through(c, Direction::new(n, tol)?))
511}
512
513/// The plane through the samples: mean point, mean normal.
514fn fit_plane(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Plane> {
515    let (points, normals) = samples(surface, tol)?;
516    let n = mean(&normals);
517    let centroid = centroid(&points);
518    Ok(Plane::through(centroid, Direction::new(n, tol)?))
519}
520
521/// The sphere whose centre is where the normal lines meet, in least squares:
522/// minimise Σ‖(I − nnᵀ)(c − p)‖², a 3×3 solve.
523fn fit_sphere(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Sphere> {
524    let (points, normals) = samples(surface, tol)?;
525    let mut a = nalgebra::Matrix3::<f64>::zeros();
526    let mut b = nalgebra::Vector3::<f64>::zeros();
527    for (p, n) in points.iter().zip(&normals) {
528        let nv = nalgebra::Vector3::new(n.x, n.y, n.z);
529        let proj = nalgebra::Matrix3::identity() - nv * nv.transpose();
530        a += proj;
531        b += proj * nalgebra::Vector3::new(p.x, p.y, p.z);
532    }
533    let c = a
534        .lu()
535        .solve(&b)
536        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "normal lines meet nowhere"))?;
537    let centre = Point::new(c.x, c.y, c.z);
538    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
539    let count = points.len().max(1) as f64;
540    let radius = points.iter().map(|p| p.distance(centre)).sum::<f64>() / count;
541    Sphere::centred(centre, radius, tol)
542}
543
544/// The direction the normals avoid: the smallest eigenvector of Σ nnᵀ. A
545/// cylinder's normals are all perpendicular to its axis; a cone's make one
546/// constant angle with it, which leaves the same eigenvector.
547fn normal_axis(normals: &[Vector], tol: Tolerances) -> OgeomResult<Direction> {
548    let mut m = nalgebra::Matrix3::<f64>::zeros();
549    for n in normals {
550        let v = nalgebra::Vector3::new(n.x, n.y, n.z);
551        m += v * v.transpose();
552    }
553    let eigen = nalgebra::SymmetricEigen::new(m);
554    let mut best = 0;
555    for i in 1..3 {
556        if eigen.eigenvalues[i] < eigen.eigenvalues[best] {
557            best = i;
558        }
559    }
560    let d = eigen.eigenvectors.column(best);
561    Direction::from_coords(d.x, d.y, d.z, tol)
562}
563
564fn fit_cylinder(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Cylinder> {
565    let (points, normals) = samples(surface, tol)?;
566    let axis = normal_axis(&normals, tol)?;
567    // Project onto the plane through the centroid perpendicular to the axis
568    // and fit the circle there: its centre lifts to a point on the axis.
569    let origin = centroid(&points);
570    let frame = frame_about(origin, axis, tol)?;
571    let flat: Vec<(f64, f64)> = points
572        .iter()
573        .map(|p| {
574            let l = frame.to_local(*p);
575            (l.x, l.y)
576        })
577        .collect();
578    let (cx, cy, r) = fit_circle_2d(&flat)?;
579    let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
580    Cylinder::new(frame_about(centre, axis, tol)?, r, tol)
581}
582
583fn fit_cone(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Cone> {
584    let (points, normals) = samples(surface, tol)?;
585    let axis = normal_axis(&normals, tol)?;
586    // Radius against height is affine on a cone; the slope is the taper.
587    let origin = centroid(&points);
588    let frame = frame_about(origin, axis, tol)?;
589    let hs: Vec<f64> = points.iter().map(|p| frame.to_local(*p).z).collect();
590    // The section centre may be off the centroid; fit the circle at two
591    // height bands to place the axis exactly.
592    let (lo, hi) = hs
593        .iter()
594        .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
595            (a.min(*h), b.max(*h))
596        });
597    if hi - lo <= tol.confusion() {
598        ogeom_bail!(Construction, "a flat band tapers to nothing");
599    }
600    let band = |keep: &dyn Fn(f64) -> bool| -> Vec<(f64, f64)> {
601        points
602            .iter()
603            .filter(|p| keep(frame.to_local(**p).z))
604            .map(|p| {
605                let l = frame.to_local(*p);
606                (l.x, l.y)
607            })
608            .collect()
609    };
610    let mid = f64::midpoint(lo, hi);
611    let lower = band(&|h| h <= mid);
612    let upper = band(&|h| h > mid);
613    let (ax, ay, r_lo) = fit_circle_2d(&lower)?;
614    let (bx, by, r_hi) = fit_circle_2d(&upper)?;
615    let h_lo = lower_mean(&points, &frame, mid, true);
616    let h_hi = lower_mean(&points, &frame, mid, false);
617    if (h_hi - h_lo).abs() <= tol.confusion() {
618        ogeom_bail!(Construction, "the bands coincide");
619    }
620    let slope = (r_hi - r_lo) / (h_hi - h_lo);
621    let half_angle = slope.atan();
622    // Reference circle at h = 0, centre interpolated along the fitted axis.
623    let t = -h_lo / (h_hi - h_lo);
624    let cx = ax + (bx - ax) * t;
625    let cy = ay + (by - ay) * t;
626    let r0 = r_lo + (r_hi - r_lo) * t;
627    let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
628    Cone::new(frame_about(centre, axis, tol)?, r0, half_angle, tol)
629}
630
631fn lower_mean(points: &[Point], frame: &Frame, mid: f64, low: bool) -> f64 {
632    let hs: Vec<f64> = points
633        .iter()
634        .map(|p| frame.to_local(*p).z)
635        .filter(|h| (*h <= mid) == low)
636        .collect();
637    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
638    let n = hs.len().max(1) as f64;
639    hs.iter().sum::<f64>() / n
640}
641
642/// Least-squares circle through 2D points, by the |c|² − r² substitution.
643fn fit_circle_2d(points: &[(f64, f64)]) -> OgeomResult<(f64, f64, f64)> {
644    if points.len() < 3 {
645        ogeom_bail!(Construction, "a circle needs three points");
646    }
647    // x² + y² = 2 cx x + 2 cy y + (r² − cx² − cy²): linear in (cx, cy, k).
648    let (mut sxx, mut sxy, mut sx, mut syy, mut sy, mut s1) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
649    let (mut bx, mut by, mut b1) = (0.0, 0.0, 0.0);
650    for (x, y) in points {
651        let rhs = x * x + y * y;
652        sxx += 2.0 * x * 2.0 * x;
653        sxy += 2.0 * x * 2.0 * y;
654        sx += 2.0 * x;
655        syy += 2.0 * y * 2.0 * y;
656        sy += 2.0 * y;
657        s1 += 1.0;
658        bx += 2.0 * x * rhs;
659        by += 2.0 * y * rhs;
660        b1 += rhs;
661    }
662    let a = nalgebra::Matrix3::new(sxx, sxy, sx, sxy, syy, sy, sx, sy, s1);
663    let sol = a
664        .lu()
665        .solve(&nalgebra::Vector3::new(bx, by, b1))
666        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "the points close no circle"))?;
667    let (cx, cy, k) = (sol.x, sol.y, sol.z);
668    let r2 = k + cx * cx + cy * cy;
669    if r2 <= 0.0 {
670        ogeom_bail!(Construction, "the points close no circle");
671    }
672    Ok((cx, cy, r2.sqrt()))
673}
674
675fn centroid(points: &[Point]) -> Point {
676    let mut sum = Vector::ZERO;
677    for p in points {
678        sum += p.to_vector();
679    }
680    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
681    let n = points.len().max(1) as f64;
682    Point::ORIGIN + sum * (1.0 / n)
683}
684
685fn mean(vs: &[Vector]) -> Vector {
686    let mut sum = Vector::ZERO;
687    for v in vs {
688        sum += *v;
689    }
690    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
691    let n = vs.len().max(1) as f64;
692    sum * (1.0 / n)
693}
694
695fn frame_about(origin: Point, axis: Direction, tol: Tolerances) -> OgeomResult<Frame> {
696    let seed = if axis.vector().dot(Vector::X).abs() < 0.9 {
697        Vector::X
698    } else {
699        Vector::Y
700    };
701    let x = Direction::from_cross(axis.vector(), seed, tol)?;
702    Frame::new(origin, axis, x, tol)
703}