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