Skip to main content

ogeom_fillet/
corner.rs

1//! Rounding a vertex: the ball-and-block tool at a planar corner.
2//!
3//! The ball's centre may sit anywhere a radius in from every host plane.
4//! At a corner one ball touches, that region's tip is a point and the
5//! rounded corner is one spherical patch; at a corner no single ball
6//! touches, the tip is a few points joined by short ridges, and the
7//! rounded corner is a sphere at each and a cylinder along each ridge:
8//! the exact envelope of the rolling ball, where the setback family fits
9//! a plate through the bands' ends instead.
10
11use ogeom_algo::Built;
12use ogeom_core::{OgeomError, OgeomResult, Tolerances, ogeom_bail};
13use ogeom_math::{Direction, Frame, Point, Vector};
14use ogeom_topo::{Model, Shape, ShapeType};
15
16/// Round a solid's vertex with a ball of `radius`.
17///
18/// The corner block spanned by the edges, less the ball seated a radius in
19/// from every host plane, is exactly the spike a rounded corner sheds, and
20/// the general boolean does the shedding. The block's faces through the
21/// ball's centre are square to the edges, so each edge's flush band ends
22/// on the ball's rim: three sequential fillets at a box corner followed by
23/// this call round the vertex the setback way. At a vertex of more edges
24/// the corner goes first and the fillets follow (four bands built before
25/// the corner crash into each other at a pyramid's apex) and the block is
26/// the polyhedron of the N host planes and the N planes square to the
27/// edges.
28///
29/// Where one ball touches every face, a band that another of the corner's
30/// bands stopped short of the block is carried back to it first. Rounded
31/// one at a time, an edge's band runs out past the vertex where the face
32/// there leans out past its cap, and the band of an edge it crosses stops
33/// flush against it, further out than the block reaches. Carried back,
34/// that band too ends on the ball's rim, as every band does at a corner
35/// where none ran out. A band stopped by anything else (a notch across its
36/// crease) is left as it stands, and the crease before it stays sharp.
37///
38/// A vertex whose planes share no tangent ball (a rectangular pyramid's
39/// apex, any general N-edged vertex) is rounded by the envelope of every
40/// ball a radius in from all of them: a sphere at each vertex of the
41/// region the ball's centre may occupy and a cylinder along each ridge
42/// between two of them, each cut with its own compartment, the spheres
43/// by the one-ball tool on their three planes and the ridges by the flush
44/// fillet of a virtual crease. The compartments meet on the planes
45/// square to the ridges, cap to cap.
46///
47/// A vertex where a curved face meets (fewer than three planes pass
48/// through it) is rounded by the one ball touching the three surfaces that
49/// do, wherever they curve: its centre walked to a radius in from each,
50/// the compartment bounded by the three planes through the centre and two
51/// touch points, clipped to the solid, less the ball.
52///
53/// What `solid` shares with other shapes is copied before it is
54/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
55///
56/// # Errors
57///
58/// [`OgeomError::Construction`] if the
59/// vertex is not a vertex of the solid; if fewer than three planes pass
60/// through it and other than three surfaces do, or the ball does not seat
61/// inside every face; if the region the ball's centre may occupy has a tip vertex touching more than three planes
62/// without one ball touching all of the corner's, or with other than three
63/// edges leaving it; or if the corner turns out concave, where a ball adds
64/// material instead of shedding it and a tool built from a cut cannot say
65/// so. The corner may be oblique: the block is then the hexahedron bounded
66/// by the host planes and the three planes through the ball's centre
67/// square to the edges.
68pub fn round_vertex(
69    model: &mut Model,
70    solid: &Shape,
71    vertex: &Shape,
72    radius: f64,
73    tol: Tolerances,
74) -> OgeomResult<Built> {
75    ogeom_algo::on_own_nodes(
76        model,
77        solid,
78        std::slice::from_ref(vertex),
79        |model, solid, parts| round_vertex_own(model, solid, &parts[0], radius, tol),
80    )
81}
82
83/// [`round_vertex`] on a shape every node below which is its own.
84fn round_vertex_own(
85    model: &mut Model,
86    solid: &Shape,
87    vertex: &Shape,
88    radius: f64,
89    tol: Tolerances,
90) -> OgeomResult<Built> {
91    round_vertex_with(model, solid, vertex, radius, None, tol)
92}
93
94/// [`round_vertex`], with the one-ball corner held to the labelling
95/// `forced` names (by index, the ring's start then its direction) instead
96/// of offered each in turn.
97pub(crate) fn round_vertex_with(
98    model: &mut Model,
99    solid: &Shape,
100    vertex: &Shape,
101    radius: f64,
102    forced: Option<usize>,
103    tol: Tolerances,
104) -> OgeomResult<Built> {
105    match corner_at(model, solid, vertex, radius, tol)? {
106        Corner::Curved(corner) => {
107            crate::corner_curved::curved_corner(model, solid, vertex, corner, radius, tol)
108        }
109        Corner::Setback(corner, m) => setback_corner(model, solid, vertex, corner, &m, radius, tol),
110        Corner::OneBall(one) => one_ball_corner(model, solid, vertex, &one, radius, forced, tol),
111    }
112}
113
114/// The kind of corner a vertex of a solid is to a ball of some radius.
115enum Corner {
116    /// Fewer than three planes pass through the corner: a curved face
117    /// meets there.
118    Curved(Point),
119    /// No one ball touches every plane through the corner, whose inward
120    /// normals these are.
121    Setback(Point, Vec<Vector>),
122    /// One ball touches every plane through a convex corner.
123    OneBall(OneBall),
124}
125
126/// A convex planar corner one ball touches every face of.
127struct OneBall {
128    /// The vertex where it stands.
129    corner: Point,
130    /// The ball's centre.
131    far: Point,
132    /// The inward normal of each plane through the corner.
133    m: Vec<Vector>,
134    /// The planes in their order round the vertex.
135    ring: Vec<usize>,
136    /// Edge `k`, from the vertex into the solid, where plane `ring[k]` and
137    /// plane `ring[k + 1]` meet.
138    d: Vec<Direction>,
139    /// The points whose containment in the solid told the corner convex.
140    probes: Vec<Point>,
141}
142
143impl OneBall {
144    /// The box round the corner's block: the vertex, the foot of each edge
145    /// on its plane through the ball's centre, the point where the ball
146    /// touches each plane, and the centre.
147    fn block_bounds(&self, radius: f64) -> ogeom_math::Aabb {
148        let mut points = vec![self.corner, self.far];
149        for (dk, mk) in self.d.iter().zip(&self.m) {
150            points.push(self.foot(dk.vector()));
151            points.push(self.far - *mk * radius);
152        }
153        ogeom_math::Aabb::of_points(&points)
154    }
155
156    /// Where the plane through the ball's centre square to `along` meets
157    /// the edge leaving the vertex along it.
158    fn foot(&self, along: Vector) -> Point {
159        self.corner + along * (self.far - self.corner).dot(along)
160    }
161}
162
163/// The corner `vertex` of `solid` is to a ball of `radius`, and every check
164/// that the tool speaks it.
165fn corner_at(
166    model: &Model,
167    solid: &Shape,
168    vertex: &Shape,
169    radius: f64,
170    tol: Tolerances,
171) -> OgeomResult<Corner> {
172    if model.kind_of(vertex)? != ShapeType::Vertex {
173        ogeom_bail!(Construction, "round_vertex rounds a vertex");
174    }
175    if radius <= tol.confusion() {
176        ogeom_bail!(Construction, "a blend radius must be a positive distance");
177    }
178    let Some(raw) = model
179        .node(vertex)
180        .and_then(|n| n.data().as_vertex().map(|d| d.point))
181    else {
182        ogeom_bail!(Construction, "the vertex holds no point");
183    };
184    // Where the vertex stands, not where its node was built: a prism's far
185    // end is its near end moved, and read unplaced the far corner is the
186    // near one, so the tool would round the wrong corner of the solid.
187    let corner = vertex.transform(model.datums())?.apply(raw);
188
189    // The corner's frame comes from the planes that pass through the
190    // vertex's point, not from the vertex's own adjacency, which the very
191    // sequence this tool serves destroys: after three fillets the tip is
192    // consumed, but the three shrunk planes still contain the corner, and
193    // still say exactly which corner it was. The vertex argument may
194    // therefore come from an earlier state of the solid (the sharp box's
195    // corner captured before the fillets) and anchors the history. Each
196    // plane's material side is read off its face, which the fillets shrink
197    // but never turn over.
198    let mut m: Vec<Vector> = Vec::new();
199    for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
200        let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
201            continue;
202        };
203        let Some(surface) = model.geometry().surface(data.surface) else {
204            continue;
205        };
206        if !matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)) {
207            continue;
208        }
209        let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
210        if (corner - origin).dot(outward).abs() > tol.confusion() * 100.0 {
211            continue;
212        }
213        // One vote per plane: coplanar trims share it.
214        if m.iter()
215            .any(|n| n.cross(outward).magnitude() < tol.angular() * 10.0)
216        {
217            continue;
218        }
219        m.push(-outward);
220    }
221    let n = m.len();
222    if n < 3 {
223        // A curved face meets here: the one-ball corner on whatever
224        // surfaces pass through the vertex.
225        return Ok(Corner::Curved(corner));
226    }
227    // The ball's centre: the point a radius in from every plane. Three
228    // planes that span always hold one; more only when they share a
229    // tangent ball, which the least-squares fit's residual tells: a
230    // square pyramid's apex does, a general N-edged vertex does not, and
231    // that vertex takes the general setback patch instead.
232    let centre_for = |m: &[Vector]| -> Option<(Point, f64)> {
233        // m_k · (c − corner) = radius over all k: solved directly for
234        // three planes, through the normal equations for more. Three are
235        // solved directly for the last bit, not for speed: the boolean's
236        // paving at the touch points is sensitive to an ulp of the centre,
237        // and an oblique corner closes on the direct solve's value.
238        let mut a = [[0.0_f64; 3]; 3];
239        let mut b = [0.0_f64; 3];
240        if m.len() == 3 {
241            for (i, mk) in m.iter().enumerate() {
242                a[i] = [mk.x, mk.y, mk.z];
243                b[i] = radius;
244            }
245        } else {
246            for mk in m {
247                let v = [mk.x, mk.y, mk.z];
248                for i in 0..3 {
249                    b[i] += radius * v[i];
250                    for j in 0..3 {
251                        a[i][j] += v[i] * v[j];
252                    }
253                }
254            }
255        }
256        let det3 = |a: &[[f64; 3]; 3]| -> f64 {
257            a[0][0].mul_add(
258                a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
259                -a[0][1].mul_add(
260                    a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
261                    -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
262                ),
263            )
264        };
265        let det = det3(&a);
266        if !det.is_finite() || det.abs() <= 1e-12 {
267            return None;
268        }
269        let mut x = [0.0_f64; 3];
270        for (k, xk) in x.iter_mut().enumerate() {
271            let mut ak = a;
272            for (row, bi) in ak.iter_mut().zip(b) {
273                row[k] = bi;
274            }
275            *xk = det3(&ak) / det;
276        }
277        let centre = corner + Vector::new(x[0], x[1], x[2]);
278        let residual = m
279            .iter()
280            .map(|mk| (mk.dot(centre - corner) - radius).abs())
281            .fold(0.0, f64::max);
282        Some((centre, residual))
283    };
284    let Some((far, residual)) = centre_for(&m) else {
285        ogeom_bail!(
286            Construction,
287            "the {n} planes through this vertex do not span a corner"
288        );
289    };
290    if residual > tol.confusion() * 10.0 {
291        // No one ball touches every face: the rounded corner is the
292        // envelope of every ball a radius in from all of them, which is
293        // more than one sphere. Its pieces are read off the region the
294        // ball's centre may occupy.
295        return Ok(Corner::Setback(corner, m));
296    }
297    // A concave vertex puts the ball's centre outside the material: a ball
298    // there adds material instead of shedding it, and a tool built from a
299    // cut cannot say so.
300    let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
301    let mut probes = vec![far];
302    if boundary.holds(model, far, tol)? != ogeom_algo::Containment::In {
303        ogeom_bail!(
304            Construction,
305            "no material a radius in from every face at this vertex; a \
306             concave vertex gains a ball instead of shedding one, and this \
307             tool cannot round it"
308        );
309    }
310    // The corner's edges: where two of the planes meet along a ray that
311    // lies inside all the others, directed from the vertex into the solid.
312    // A simple convex corner has one edge per plane, and the edges chain the
313    // planes into a ring round the vertex.
314    let mut edges: Vec<(usize, usize, Direction)> = Vec::new();
315    for i in 0..n {
316        for j in i + 1..n {
317            let cross = m[i].cross(m[j]);
318            let length = cross.magnitude();
319            if length <= tol.angular() * 10.0 {
320                continue;
321            }
322            for sign in [1.0, -1.0] {
323                let d = cross * (sign / length);
324                let inside = (0..n)
325                    .filter(|&k| k != i && k != j)
326                    .all(|k| m[k].dot(d) >= -tol.angular() * 10.0);
327                if inside {
328                    edges.push((i, j, Direction::new(d, tol)?));
329                    break;
330                }
331            }
332        }
333    }
334    if edges.len() != n {
335        ogeom_bail!(
336            Construction,
337            "the {n} planes through this vertex meet along {} edges, not {n}; \
338             the corner is not the simple convex one this tool speaks",
339            edges.len()
340        );
341    }
342    // The planes taken whole make any corner look convex, but a re-entrant
343    // one (an L-bracket's step, where a concave edge meets two convex ones)
344    // has material outside a plane beside one of its edges. At a convex
345    // corner the solid lies within every plane near the vertex, however
346    // much of it earlier blends have cut away, so a point just off one of
347    // an edge's planes, and in from the other, is outside it.
348    {
349        let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
350        let step = (radius * 1e-2).max(tol.confusion() * 1e3);
351        for &(i, j, d) in &edges {
352            for (k, other) in [(i, j), (j, i)] {
353                let off = corner + (d.vector() - m[k] + m[other]) * step;
354                probes.push(off);
355                if boundary.holds(model, off, tol)? == ogeom_algo::Containment::In {
356                    ogeom_bail!(
357                        Construction,
358                        "material stands outside a plane through this vertex beside \
359                         one of its edges; the corner is re-entrant, not the simple \
360                         convex one this tool speaks"
361                    );
362                }
363            }
364        }
365    }
366    let mut ring: Vec<usize> = vec![0];
367    let mut last_edge: Option<usize> = None;
368    loop {
369        let here = *ring.last().unwrap_or(&0);
370        let Some((e, other)) = edges.iter().enumerate().find_map(|(e, &(i, j, _))| {
371            if Some(e) == last_edge {
372                None
373            } else if i == here {
374                Some((e, j))
375            } else if j == here {
376                Some((e, i))
377            } else {
378                None
379            }
380        }) else {
381            ogeom_bail!(
382                Construction,
383                "the planes through this vertex do not chain into a ring round it"
384            );
385        };
386        last_edge = Some(e);
387        if other == 0 {
388            break;
389        }
390        if ring.contains(&other) || ring.len() >= n {
391            ogeom_bail!(
392                Construction,
393                "the planes through this vertex do not chain into a ring round it"
394            );
395        }
396        ring.push(other);
397    }
398    if ring.len() != n {
399        ogeom_bail!(
400            Construction,
401            "the planes through this vertex do not chain into a ring round it"
402        );
403    }
404    // Plane `ring[k]` and plane `ring[k+1]` meet along edge k.
405    let d: Vec<Direction> = (0..n)
406        .map(|k| {
407            let (p, q) = (ring[k], ring[(k + 1) % n]);
408            edges
409                .iter()
410                .find(|&&(i, j, _)| (i == p && j == q) || (i == q && j == p))
411                .map(|&(_, _, dir)| dir)
412        })
413        .collect::<Option<_>>()
414        .ok_or_else(|| {
415            ogeom_core::ogeom_err!(Construction, "the corner's edges lost their ring")
416        })?;
417    Ok(Corner::OneBall(OneBall {
418        corner,
419        far,
420        m,
421        ring,
422        d,
423        probes,
424    }))
425}
426
427/// A convex planar corner one ball touches every face of, read as
428/// [`round_vertex`] reads it: the vertex's point, the ball's centre, each
429/// plane's inward normal, the planes in their order round the vertex, and
430/// edge `k`'s direction into the solid, where plane `ring[k]` meets plane
431/// `ring[k + 1]`.
432pub(crate) struct CornerFrame {
433    pub corner: Point,
434    pub far: Point,
435    pub m: Vec<Vector>,
436    pub ring: Vec<usize>,
437    pub d: Vec<Direction>,
438}
439
440/// The frame of `vertex` of `solid` as a corner one ball of `radius`
441/// touches every plane of, refused by name where it is another kind.
442pub(crate) fn corner_frame(
443    model: &Model,
444    solid: &Shape,
445    vertex: &Shape,
446    radius: f64,
447    tol: Tolerances,
448) -> OgeomResult<CornerFrame> {
449    match corner_at(model, solid, vertex, radius, tol)? {
450        Corner::OneBall(one) => Ok(CornerFrame {
451            corner: one.corner,
452            far: one.far,
453            m: one.m,
454            ring: one.ring,
455            d: one.d,
456        }),
457        Corner::Curved(_) => ogeom_bail!(
458            Construction,
459            "a setback corner where a curved face meets the vertex is still \
460             owed; see docs/CAPABILITIES.md, fillet.setback-corners"
461        ),
462        Corner::Setback(..) => ogeom_bail!(
463            Construction,
464            "a setback corner whose planes share no tangent ball is still \
465             owed; see docs/CAPABILITIES.md, fillet.setback-corners"
466        ),
467    }
468}
469
470/// [`round_vertex_with`] at a corner one ball touches every face of.
471fn one_ball_corner(
472    model: &mut Model,
473    solid: &Shape,
474    vertex: &Shape,
475    one: &OneBall,
476    radius: f64,
477    forced: Option<usize>,
478    tol: Tolerances,
479) -> OgeomResult<Built> {
480    let OneBall {
481        corner,
482        far,
483        ref m,
484        ref ring,
485        ref d,
486        ..
487    } = *one;
488    let n = m.len();
489
490    // A band left short of the block by another band's run-out is carried
491    // back to it first, so the block's cut ends it as it ends a flush one.
492    // The wedge carrying it starts at the vertex or on the block's plane:
493    // the same material either way, which the boolean resolves in one or
494    // the other. Where neither carries it the corner is cut as it stands.
495    let carry = |model: &mut Model, from_vertex: bool| -> OgeomResult<Built> {
496        let mut carried = Built::from_nothing(solid.clone());
497        for k in 0..n {
498            let planes = [m[ring[k]], m[ring[(k + 1) % n]]];
499            if let Some(band) = band_gap(
500                model,
501                &carried.shape,
502                corner,
503                far,
504                d,
505                k,
506                planes,
507                radius,
508                from_vertex,
509                tol,
510            )? {
511                carried = Built {
512                    shape: band.shape,
513                    history: carried.history.then(&band.history),
514                };
515            }
516        }
517        Ok(carried)
518    };
519
520    // Which edge is first and which way the ring runs is the tool's
521    // labelling ([`one_ball_tool`]), and the solid it builds is the same
522    // for all 2N. The tool offers each labelling in turn and the first that
523    // closes stands, so a corner no labelling closes is refused by name. A
524    // failed attempt's nodes stay in the model unreferenced, under their
525    // own operation.
526    let attempt = |model: &mut Model, held: &Built, start: usize, reverse| -> OgeomResult<Built> {
527        let tool = one_ball_tool(model, one, radius, start, reverse, tol)?;
528        let rounded = ogeom_bool::cut(model, &held.shape, &tool.shape, tol)?;
529        Ok(Built {
530            shape: rounded.shape,
531            history: held.history.then(&tool.history).then(&rounded.history),
532        })
533    };
534    let mut outcome: Option<Built> = None;
535    let mut last: Option<ogeom_core::OgeomError> = None;
536    let mut tried: Vec<Shape> = Vec::new();
537    'carries: for from_vertex in [Some(true), Some(false), None] {
538        let carried = match from_vertex {
539            Some(from_vertex) => match carry(model, from_vertex) {
540                Ok(carried) => carried,
541                Err(err @ (OgeomError::NotDone(_) | OgeomError::Construction(_))) => {
542                    last = Some(err);
543                    continue;
544                }
545                Err(err) => return Err(err),
546            },
547            None => Built::from_nothing(solid.clone()),
548        };
549        // A corner with no band to carry is cut once.
550        if tried.iter().any(|held| held.is_same(&carried.shape)) {
551            continue;
552        }
553        tried.push(carried.shape.clone());
554        let labellings = (0..2 * n).map(|index| (index % n, index >= n));
555        for (index, (start, reverse)) in labellings.enumerate() {
556            if forced.is_some_and(|f| f != index) {
557                continue;
558            }
559            match attempt(model, &carried, start, reverse) {
560                Ok(built) => {
561                    outcome = Some(built);
562                    break 'carries;
563                }
564                Err(err) => last = Some(err),
565            }
566        }
567    }
568    let Some(rounded) = outcome else {
569        ogeom_bail!(
570            NotDone,
571            "the corner tool's cut closed on none of the corner's {} \
572             labellings; the last said: {}",
573            2 * n,
574            last.map_or_else(String::new, |e| e.to_string())
575        );
576    };
577    let mut built = rounded;
578    built.history.modify(vertex, built.shape.clone());
579    Ok(built)
580}
581
582/// The corners at `vertices` of `solid` rounded in one cut, each as
583/// [`round_vertex_with`] rounds it alone; `None` where that is not shown
584/// to round them as rounding them one after another would, and the caller
585/// rounds them so.
586///
587/// Every face of the solid must be planar, every corner one a single ball
588/// touches every face of, and every corner's tool clear of every other
589/// corner. With no curved face no band stands to be carried back to a
590/// block. A tool lies within its block, and a corner whose block keeps a
591/// margin from every other corner's leaves what that corner is read from
592/// as it found it: the faces through its vertex keep their pieces round
593/// it, the points its convexity was probed at stay as far inside or
594/// outside, and no plane square to an edge of it passes through another
595/// vertex to add a face there. A face of the solid on a plane through the
596/// vertex that no face at the vertex lies on could be cut away whole; such
597/// a corner is left to the caller. Each corner is then the same corner,
598/// and its tool the same solid, before and after the others are cut, and
599/// cutting the solid by every tool at once takes away the union of the
600/// tools, as cutting by them in turn does. The margin is the chord the
601/// convexity probes' classifier draws trims at, which also stands well
602/// clear of the band in which a plane counts as passing through a vertex.
603///
604/// Cut in turn, a corner's patch meets the balls of the corners cut
605/// before it, and a patch whose pole comes within the boolean's touching
606/// distance of another ball is recharted, seams and all, which one cut of
607/// a solid with no curved face never does. Every two balls therefore
608/// stand further apart than that: no point of one comes that close to the
609/// other.
610///
611/// The tool of each corner is built under the first labelling that builds
612/// one; which labelling holds decides only the charts of the patch, as it
613/// does when the corners are rounded in turn. Where the one cut does not
614/// close, the tolerances it widened are put back.
615pub(crate) fn round_vertices_together(
616    model: &mut Model,
617    solid: &Shape,
618    vertices: &[Shape],
619    radius: f64,
620    tol: Tolerances,
621) -> OgeomResult<Option<Built>> {
622    let mark = model.note_widened();
623    match vertices_together(model, solid, vertices, radius, tol) {
624        Ok(Some(built)) => {
625            model.keep_widened(mark);
626            Ok(Some(built))
627        }
628        Err(OgeomError::Cancelled) => {
629            model.keep_widened(mark);
630            Err(OgeomError::Cancelled)
631        }
632        Ok(None) | Err(_) => {
633            model.undo_widened(mark);
634            Ok(None)
635        }
636    }
637}
638
639/// [`round_vertices_together`], before what a cut that does not close
640/// widened is put back.
641fn vertices_together(
642    model: &mut Model,
643    solid: &Shape,
644    vertices: &[Shape],
645    radius: f64,
646    tol: Tolerances,
647) -> OgeomResult<Option<Built>> {
648    let margin = tol.confusion() * 1e4;
649    let faces = ogeom_topo::explore_unique(model, solid, ShapeType::Face)?;
650    let planar = |face: &Shape| {
651        model
652            .node(face)
653            .and_then(|n| n.data().as_face())
654            .is_some_and(|data| {
655                matches!(
656                    model.geometry().surface(data.surface),
657                    Some(ogeom_geom::SurfaceGeometry::Plane(_))
658                )
659            })
660    };
661    if !faces.iter().all(planar) {
662        return Ok(None);
663    }
664    let mut corners: Vec<OneBall> = Vec::with_capacity(vertices.len());
665    for vertex in vertices {
666        let Corner::OneBall(one) = corner_at(model, solid, vertex, radius, tol)? else {
667            return Ok(None);
668        };
669        // Every plane through the vertex is one a face at the vertex lies on.
670        let mut at_vertex: Vec<Vector> = Vec::new();
671        for face in &faces {
672            let holds = ogeom_topo::explore_unique(model, face, ShapeType::Vertex)?
673                .iter()
674                .any(|v| v.is_same(vertex));
675            if holds {
676                at_vertex.push(ogeom_algo::face_normal(model, face, tol)?.1);
677            }
678        }
679        let held = one.m.iter().all(|mk| {
680            at_vertex
681                .iter()
682                .any(|n| n.cross(*mk).magnitude() < tol.angular() * 10.0)
683        });
684        if !held {
685            return Ok(None);
686        }
687        corners.push(one);
688    }
689    let bounds: Vec<ogeom_math::Aabb> = corners.iter().map(|c| c.block_bounds(radius)).collect();
690    for (i, one) in corners.iter().enumerate() {
691        for (j, other) in corners.iter().enumerate() {
692            if i == j {
693                continue;
694            }
695            let apart = one.far.distance(other.far) - radius * 2.0;
696            let clear = apart > radius * ogeom_bool::POLE_TOUCHING + margin
697                && bounds[i].distance_to_box(&bounds[j]) > margin
698                && other
699                    .probes
700                    .iter()
701                    .all(|p| bounds[i].distance_to(*p) > margin)
702                && one.d.iter().all(|dk| {
703                    let along = dk.vector();
704                    (other.corner - one.foot(along)).dot(along).abs() > margin
705                });
706            if !clear {
707                return Ok(None);
708            }
709        }
710    }
711    let mut tools = Vec::with_capacity(corners.len());
712    let mut history = ogeom_algo::History::new();
713    for one in &corners {
714        let n = one.m.len();
715        let tool = (0..2 * n).find_map(|index| {
716            match one_ball_tool(model, one, radius, index % n, index >= n, tol) {
717                Ok(tool) => Some(Ok(tool)),
718                Err(OgeomError::Cancelled) => Some(Err(OgeomError::Cancelled)),
719                Err(_) => None,
720            }
721        });
722        let Some(tool) = tool.transpose()? else {
723            return Ok(None);
724        };
725        history = history.then(&tool.history);
726        tools.push(tool.shape);
727    }
728    let compound = model.add_compound(&tools)?;
729    let rounded = ogeom_bool::cut(model, solid, &compound, tol)?;
730    let mut history = history.then(&rounded.history);
731    for vertex in vertices {
732        history.modify(vertex, rounded.shape.clone());
733    }
734    Ok(Some(Built {
735        shape: rounded.shape,
736        history,
737    }))
738}
739
740/// The tool that rounds a one-ball corner: its block less its ball, under
741/// the labelling whose first edge is `start` and whose ring runs backwards
742/// where `reverse`.
743///
744/// The block: the corner bounded by its N host planes and, through the
745/// ball's centre, the N planes square to its edges, where each band's
746/// circle and the ball's own rim coincide, so the cut ends the band and
747/// starts the patch on one curve. On a square corner it is the box of
748/// side `radius`; on an oblique one a hexahedron; at a pyramid's apex a
749/// polyhedron of eight faces. Its corners: the vertex, the foot of each
750/// edge on its cutting plane, on each host plane the point where the
751/// ball touches it, and the centre itself.
752///
753/// The ball on the corner's axes: a pole at one corner of the patch it
754/// leaves and its seam meridian out past the block through the first
755/// edge: the pole axis is the inward normal of the host plane holding
756/// the first two edges, the third edge itself on a square corner. The
757/// charts the block's faces and the ball wear differ between labellings
758/// (where a rim is exact and where fitted, where a seam falls against a
759/// patch arc); the solid does not.
760fn one_ball_tool(
761    model: &mut Model,
762    one: &OneBall,
763    radius: f64,
764    start: usize,
765    reverse: bool,
766    tol: Tolerances,
767) -> OgeomResult<Built> {
768    let OneBall {
769        corner,
770        far,
771        ref m,
772        ref ring,
773        ref d,
774        ..
775    } = *one;
776    let n = m.len();
777    let inward_of = |k: usize| m[ring[k % n]];
778    // Edge t of the labelling and the host plane holding edges t and t+1.
779    let edge_at = |t: usize| -> usize {
780        if reverse {
781            (start + n - t % n) % n
782        } else {
783            (start + t) % n
784        }
785    };
786    let dl: Vec<Direction> = (0..n).map(|t| d[edge_at(t)]).collect();
787    let ml: Vec<Vector> = (0..n)
788        .map(|t| {
789            // Forward: edges t, t+1 are E_s+t, E_s+t+1, both on plane
790            // ring[s+t+1]. Reverse: E_s−t, E_s−t−1, both on ring[s−t].
791            if reverse {
792                inward_of((start + n - t % n) % n)
793            } else {
794                inward_of(edge_at(t) + 1)
795            }
796        })
797        .collect();
798    let along = |t: usize| -> Point { one.foot(dl[t].vector()) };
799    let touch = |t: usize| -> Point { far - ml[t] * radius };
800    if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
801        eprintln!(
802            "CORNER attempt start {start} reverse {reverse} far {far:?} d {:?} m {ml:?}",
803            dl.iter().map(|x| x.vector()).collect::<Vec<_>>()
804        );
805    }
806    model.begin_operation();
807    let mut points: Vec<Point> = vec![corner];
808    points.extend((0..n).map(along));
809    points.extend((0..n).map(touch));
810    points.push(far);
811    let mut rings: Vec<Vec<usize>> = Vec::with_capacity(2 * n);
812    for t in 0..n {
813        // The host face on plane t: vertex, edge t's foot, the touch
814        // point, edge t+1's foot.
815        rings.push(vec![0, 1 + t, 1 + n + t, 1 + (t + 1) % n]);
816        // The face square to edge t: its foot, the touch points either
817        // side, the centre.
818        rings.push(vec![1 + t, 1 + n + (t + n - 1) % n, 1 + 2 * n, 1 + n + t]);
819    }
820    let block = ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape;
821    let ball_frame = Frame::new(far, Direction::new(ml[0], tol)?, dl[0], tol)?;
822    let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
823    let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
824    if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
825        let faces = ogeom_topo::explore_unique(model, &tool.shape, ShapeType::Face)?;
826        let kinds: Vec<String> = faces
827            .iter()
828            .map(|f| {
829                let kind = model
830                    .node(f)
831                    .and_then(|n| n.data().as_face())
832                    .and_then(|d| model.geometry().surface(d.surface))
833                    .map_or("?", |sg| match sg {
834                        ogeom_geom::SurfaceGeometry::Plane(_) => "plane",
835                        ogeom_geom::SurfaceGeometry::Sphere(_) => "sphere",
836                        _ => "other",
837                    });
838                let edges =
839                    ogeom_topo::explore_unique(model, f, ShapeType::Edge).map_or(0, |e| e.len());
840                format!("{kind}/{edges}")
841            })
842            .collect();
843        eprintln!(
844            "CORNER tool for start {start} reverse {reverse}: {} faces {kinds:?}",
845            faces.len()
846        );
847    }
848    Ok(tool)
849}
850
851/// The band along edge `k` of a one-ball corner carried back to the
852/// corner's block, where a band another edge of the corner ran out left it
853/// short.
854///
855/// Edge `k`'s band is the cylinder of `radius` about the line through the
856/// ball's centre `far` along the edge's direction, between the two host
857/// planes with inward normals `planes`; the block ends it on the plane
858/// through `far` square to the edge. Rounded one at a time, another edge's
859/// band may run out past the vertex and across this edge's crease, and
860/// this band then stands flush against that one, further out than the
861/// block reaches. In the section square to the edge, the material outside
862/// the cylinder and within the two host planes is what no ball a radius in
863/// from both planes reaches, so the band's wedge is cut back to the block,
864/// `from_vertex` starting it at the vertex rather than on the block's
865/// plane: behind that plane the block's cut takes the stretch anyway.
866///
867/// Only that band is carried: its near end stands against the band of
868/// another edge of the corner (the crease's point there lies on that
869/// band's cylinder), and no stretch of the crease between the vertex and
870/// the band is still a sharp edge of the solid. A band ending against
871/// anything else (a notch across the crease, a piece of the crease nobody
872/// rounded) is left as it stands. `None` where no band is carried.
873#[allow(clippy::too_many_arguments, reason = "one question, all its data")]
874fn band_gap(
875    model: &mut Model,
876    solid: &Shape,
877    corner: Point,
878    far: Point,
879    axes: &[Direction],
880    k: usize,
881    planes: [Vector; 2],
882    radius: f64,
883    from_vertex: bool,
884    tol: Tolerances,
885) -> OgeomResult<Option<Built>> {
886    let along = axes[k].vector();
887    let on_line = tol.confusion() * 100.0;
888    let point_of = |model: &Model, v: &Shape| -> OgeomResult<Option<Point>> {
889        let Some(p) = model
890            .node(v)
891            .and_then(|n| n.data().as_vertex().map(|d| d.point))
892        else {
893            return Ok(None);
894        };
895        Ok(Some(v.transform(model.datums())?.apply(p)))
896    };
897    // Where the band starts along the edge, from the block's plane: the
898    // nearest of its vertices.
899    let mut ends: Vec<Point> = Vec::new();
900    for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
901        if !on_band(model, &face, far, along, radius, tol) {
902            continue;
903        }
904        for v in ogeom_topo::explore_unique(model, &face, ShapeType::Vertex)? {
905            if let Some(p) = point_of(model, &v)? {
906                ends.push(p);
907            }
908        }
909    }
910    let Some(start) = ends
911        .iter()
912        .map(|p| (*p - far).dot(along))
913        .min_by(f64::total_cmp)
914    else {
915        return Ok(None);
916    };
917    if start <= tol.confusion() {
918        return Ok(None);
919    }
920    // The band's near end stands against another edge's band of this
921    // corner: where its section there meets the crease, a point on that
922    // band's cylinder.
923    let from = (corner - far).dot(along);
924    let gap = |p: Point, axis: Vector| {
925        let w = p - far;
926        ((w - axis * w.dot(axis)).magnitude() - radius).abs()
927    };
928    let crease_end = corner + along * (start - from);
929    if !axes
930        .iter()
931        .enumerate()
932        .any(|(j, axis)| j != k && gap(crease_end, axis.vector()) <= on_line)
933    {
934        return Ok(None);
935    }
936    // No stretch of the crease between the vertex and the band is still a
937    // sharp edge of the solid. The crease leaves the other band at its rail,
938    // where the band is tangent to the host plane the crease lies in, so
939    // the two stand apart only quadratically there and the boolean places
940    // the crossing within its vertices' tolerances. A piece each of whose
941    // ends lies on one other band's cylinder within the end's own
942    // tolerance lies on it within those tolerances all along (a line's
943    // distance from a cylinder it stays outside is convex along it): it is
944    // that band's boundary, not a stretch of sharp crease.
945    for edge in ogeom_topo::explore_unique(model, solid, ShapeType::Edge)? {
946        let Some((a, b)) = ogeom_algo::edge_vertices(model, &edge)? else {
947            continue;
948        };
949        let (Some(pa), Some(pb)) = (point_of(model, &a)?, point_of(model, &b)?) else {
950            continue;
951        };
952        let crease = |p: Point| {
953            let w = p - corner;
954            (w - along * w.dot(along)).magnitude() <= on_line
955        };
956        if !crease(pa) || !crease(pb) {
957            continue;
958        }
959        let reach = |v: &Shape| {
960            model
961                .node(v)
962                .and_then(|n| n.data().as_vertex())
963                .map_or(0.0, |d| d.tolerance.get())
964        };
965        let (reach_a, reach_b) = (reach(&a), reach(&b));
966        let on_band_seam = axes.iter().enumerate().any(|(j, axis)| {
967            j != k && gap(pa, axis.vector()) <= reach_a && gap(pb, axis.vector()) <= reach_b
968        });
969        if on_band_seam {
970            continue;
971        }
972        let (ta, tb) = ((pa - far).dot(along), (pb - far).dot(along));
973        if ta.max(tb) > from + on_line && ta.min(tb) < start - on_line {
974            return Ok(None);
975        }
976    }
977    let face_on = |model: &Model, inward: Vector| -> OgeomResult<Option<Shape>> {
978        for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
979            let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
980                continue;
981            };
982            if !matches!(
983                model.geometry().surface(data.surface),
984                Some(ogeom_geom::SurfaceGeometry::Plane(_))
985            ) {
986                continue;
987            }
988            let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
989            if (corner - origin).dot(outward).abs() <= on_line
990                && outward.cross(inward).magnitude() < tol.angular() * 10.0
991                && outward.dot(inward) < 0.0
992            {
993                return Ok(Some(face));
994            }
995        }
996        Ok(None)
997    };
998    let (Some(first), Some(second)) = (face_on(model, planes[0])?, face_on(model, planes[1])?)
999    else {
1000        return Ok(None);
1001    };
1002    let foot = corner + along * (far - corner).dot(along);
1003    let seat = crate::support::Seat {
1004        start: if from_vertex { corner } else { foot },
1005        end: foot + along * start,
1006        along,
1007        normals: [-planes[0], -planes[1]],
1008        faces: [first, second],
1009        convex: true,
1010    };
1011    let cut = crate::fillet::seated_fillet(model, solid, &seat, radius, None, &[], tol)?;
1012    // The carried band and the band it reaches lie on one cylinder: one
1013    // face, as the band the corner closes in one call is.
1014    let on_axis: Vec<Shape> = ogeom_topo::explore_unique(model, &cut.shape, ShapeType::Face)?
1015        .into_iter()
1016        .filter(|face| on_band(model, face, far, along, radius, tol))
1017        .collect();
1018    let (unified, _) = ogeom_heal::unify_same_domain_around(model, &cut.shape, &on_axis, tol)?;
1019    Ok(Some(Built {
1020        shape: unified.shape,
1021        history: cut.history.then(&unified.history),
1022    }))
1023}
1024
1025/// Whether `face` lies on the cylinder of `radius` about the line through
1026/// `far` along `along`.
1027fn on_band(
1028    model: &Model,
1029    face: &Shape,
1030    far: Point,
1031    along: Vector,
1032    radius: f64,
1033    tol: Tolerances,
1034) -> bool {
1035    let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1036        return false;
1037    };
1038    let Some(ogeom_geom::SurfaceGeometry::Cylinder(c)) = model.geometry().surface(data.surface)
1039    else {
1040        return false;
1041    };
1042    let frame = c.cylinder().frame();
1043    let axis = frame.z().vector();
1044    let off = far - frame.origin();
1045    (c.cylinder().radius() - radius).abs() <= tol.confusion()
1046        && axis.cross(along).magnitude() <= tol.angular()
1047        && (off - axis * off.dot(axis)).magnitude() <= tol.confusion()
1048}
1049
1050/// A vertex of the region the ball's centre may occupy: a point a radius
1051/// in from three or more of the host planes and at least a radius from the
1052/// rest, with the planes it touches.
1053struct TipVertex {
1054    centre: Point,
1055    planes: Vec<usize>,
1056}
1057
1058/// An edge of that region's tip between two of its vertices: the ball
1059/// rolling from one to the other touches two planes all the way, and
1060/// sweeps a cylinder.
1061struct Ridge {
1062    from: usize,
1063    to: usize,
1064    planes: [usize; 2],
1065}
1066
1067/// Round a convex planar vertex whose faces no single ball touches.
1068///
1069/// The ball's centre may sit anywhere a radius in from every host plane:
1070/// a convex region whose tip, at a vertex one ball touches, is a single
1071/// point, and otherwise a few points joined by short edges: a rectangular
1072/// pyramid's apex has two, joined along the two long slopes. The rounded
1073/// corner is the envelope of every ball centred in that region: a sphere
1074/// at each tip vertex, a cylinder along each edge between them, and the
1075/// host planes themselves elsewhere. Exact, constant-radius, and what the
1076/// rolling ball leaves, where a plate would be fitted through the bands'
1077/// ends instead.
1078///
1079/// Each piece is cut with its own block: at a tip vertex, the corner
1080/// bounded by its three planes and the three planes through the centre
1081/// square to its edges, less the ball (the one-ball tool exactly) and
1082/// along an edge between two centres, the prism over the kite of the
1083/// virtual crease, the two touch points and the centre, between the two
1084/// planes square to the edge, less the cylinder. The compartments tile
1085/// the corner and meet on the planes square to the edges, where each
1086/// sphere's rim and the cylinder's end coincide, so the cuts consume one
1087/// another's flush faces cap to cap. An original edge leaves its tip
1088/// vertex along a ray, and the plane square to it there is where the
1089/// edge's band ends when the flush fillets follow.
1090fn setback_corner(
1091    model: &mut Model,
1092    solid: &Shape,
1093    vertex: &Shape,
1094    corner: Point,
1095    m: &[Vector],
1096    radius: f64,
1097    tol: Tolerances,
1098) -> OgeomResult<Built> {
1099    let n = m.len();
1100    let slack = tol.confusion() * 10.0;
1101    // The tip's vertices: every triple that meets at a point a radius in
1102    // from all the planes, merged where several triples name one point.
1103    let mut tips: Vec<TipVertex> = Vec::new();
1104    for i in 0..n {
1105        for j in i + 1..n {
1106            for k in j + 1..n {
1107                let Some(centre) = ball_centre(corner, &[m[i], m[j], m[k]], radius) else {
1108                    continue;
1109                };
1110                let feasible = m.iter().all(|mk| mk.dot(centre - corner) >= radius - slack);
1111                if !feasible {
1112                    continue;
1113                }
1114                if tips.iter().any(|t| t.centre.distance(centre) <= slack) {
1115                    continue;
1116                }
1117                let planes: Vec<usize> = (0..n)
1118                    .filter(|&l| (m[l].dot(centre - corner) - radius).abs() <= slack)
1119                    .collect();
1120                tips.push(TipVertex { centre, planes });
1121            }
1122        }
1123    }
1124    if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1125        for tip in &tips {
1126            eprintln!("TIP {:?} planes {:?}", tip.centre, tip.planes);
1127        }
1128    }
1129    if tips.is_empty() {
1130        ogeom_bail!(
1131            Construction,
1132            "the {n} planes through this vertex hold no ball a radius in from \
1133             all of them"
1134        );
1135    }
1136    // Each tip vertex's edges: along every pair of its planes, the way the
1137    // rest of its planes allow; bounded where another plane becomes
1138    // tangent (a ridge to the tip vertex there) and a ray otherwise, the
1139    // way an original edge's band runs. A ray that lies along no original
1140    // edge, or a vertex with other than three edges, is a corner this tool
1141    // does not speak.
1142    let mut ridges: Vec<Ridge> = Vec::new();
1143    let mut rays: Vec<Vec<(Vector, [usize; 2])>> = vec![Vec::new(); tips.len()];
1144    for (index, tip) in tips.iter().enumerate() {
1145        for (a, b) in tip
1146            .planes
1147            .iter()
1148            .flat_map(|&a| tip.planes.iter().map(move |&b| (a, b)))
1149        {
1150            if a >= b {
1151                continue;
1152            }
1153            let cross = m[a].cross(m[b]);
1154            let length = cross.magnitude();
1155            if length <= tol.angular() * 10.0 {
1156                continue;
1157            }
1158            let Some(direction) = [1.0, -1.0]
1159                .into_iter()
1160                .map(|sign| cross * (sign / length))
1161                .find(|d| {
1162                    tip.planes
1163                        .iter()
1164                        .filter(|&&l| l != a && l != b)
1165                        .all(|&l| m[l].dot(*d) >= -tol.angular() * 10.0)
1166                })
1167            else {
1168                continue;
1169            };
1170            // The first other plane the ball meets rolling this way.
1171            let mut nearest: Option<(f64, usize)> = None;
1172            for (l, ml) in m.iter().enumerate() {
1173                if tip.planes.contains(&l) {
1174                    continue;
1175                }
1176                let rate = ml.dot(direction);
1177                if rate >= -tol.angular() {
1178                    continue;
1179                }
1180                let t = (radius - ml.dot(tip.centre - corner)) / rate;
1181                if t > slack && nearest.is_none_or(|(held, _)| t < held) {
1182                    nearest = Some((t, l));
1183                }
1184            }
1185            match nearest {
1186                Some((t, _)) => {
1187                    let end = tip.centre + direction * t;
1188                    let Some(to) = tips.iter().position(|o| o.centre.distance(end) <= slack) else {
1189                        ogeom_bail!(
1190                            Construction,
1191                            "the ball rolling between two of this vertex's faces meets a \
1192                             third where no tip vertex stands"
1193                        );
1194                    };
1195                    if index < to {
1196                        ridges.push(Ridge {
1197                            from: index,
1198                            to,
1199                            planes: [a, b],
1200                        });
1201                    }
1202                }
1203                None => rays[index].push((direction, [a, b])),
1204            }
1205        }
1206    }
1207    let concave = {
1208        let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
1209        let mut concave = false;
1210        for tip in &tips {
1211            if boundary.holds(model, tip.centre, tol)? != ogeom_algo::Containment::In {
1212                concave = true;
1213            }
1214        }
1215        concave
1216    };
1217    if concave {
1218        ogeom_bail!(
1219            Construction,
1220            "no material a radius in from every face at this vertex; a \
1221             concave vertex gains a ball instead of shedding one, and this \
1222             tool cannot round it"
1223        );
1224    }
1225
1226    model.begin_operation();
1227    let mut rounded = solid.clone();
1228    let mut history = ogeom_algo::History::new();
1229    // The sphere at each tip vertex, with its own compartment: the wedge of
1230    // its planes, cut by the plane square to each of its edges through the
1231    // centre. A ridge's plane faces the corner, and the corner itself lies
1232    // in the ridge's compartment, not this one.
1233    for (index, tip) in tips.iter().enumerate() {
1234        // The vertex's edges, each on two of its planes: its rays and the
1235        // ridges that start or end here.
1236        let mut edges: Vec<(Vector, [usize; 2])> = rays[index].clone();
1237        for ridge in &ridges {
1238            let other = if ridge.from == index {
1239                ridge.to
1240            } else if ridge.to == index {
1241                ridge.from
1242            } else {
1243                continue;
1244            };
1245            let direction = (tips[other].centre - tip.centre).normalized(tol)?;
1246            edges.push((direction, ridge.planes));
1247        }
1248        let k = tip.planes.len();
1249        if edges.len() != k {
1250            ogeom_bail!(
1251                Construction,
1252                "a tip vertex of this corner touches {k} planes along {} edges, not \
1253                 {k}; the corner is not the simple convex one this tool speaks",
1254                edges.len()
1255            );
1256        }
1257        // In ring order, consecutive edges sharing a plane, so that host t
1258        // holds edges t and t+1: the labelling the ball's frame is read
1259        // off, its pole along a host and its seam out through an edge.
1260        let shared = |x: &[usize; 2], y: &[usize; 2]| -> Option<usize> {
1261            x.iter().copied().find(|p| y.contains(p))
1262        };
1263        let mut ring: Vec<(Vector, [usize; 2])> = vec![edges[0]];
1264        let mut used = vec![false; k];
1265        used[0] = true;
1266        while ring.len() < k {
1267            let last = ring[ring.len() - 1];
1268            let Some(next) = (0..k).find(|&i| {
1269                !used[i]
1270                    && shared(&edges[i].1, &last.1).is_some_and(|p| {
1271                        // The plane shared with the previous edge is not
1272                        // the one shared with the edge before that.
1273                        ring.len() < 2 || shared(&ring[ring.len() - 2].1, &last.1) != Some(p)
1274                    })
1275            }) else {
1276                ogeom_bail!(Construction, "the tip vertex's edges do not chain")
1277            };
1278            used[next] = true;
1279            ring.push(edges[next]);
1280        }
1281        let hosts: Vec<Vector> = (0..k)
1282            .map(|t| {
1283                shared(&ring[t].1, &ring[(t + 1) % k].1)
1284                    .map(|p| m[p])
1285                    .ok_or_else(|| {
1286                        ogeom_core::ogeom_err!(Construction, "the tip vertex's edges do not chain")
1287                    })
1288            })
1289            .collect::<OgeomResult<_>>()?;
1290        let directions: Vec<Vector> = ring.iter().map(|(d, _)| *d).collect();
1291        // Whether edge t is a ridge, whose rim plane a later cut's cap
1292        // stands in: a pole along a plane holding a ridge would put both
1293        // poles in that cap, and the cap's circle would have no chart
1294        // image. Labellings whose pole host holds no ridge go first.
1295        let is_ridge: Vec<bool> = ring
1296            .iter()
1297            .map(|(d, _)| !rays[index].iter().any(|(r, _)| r.dot(*d) > 1.0 - 1e-9))
1298            .collect();
1299        let mut walls: Vec<(Vector, Point)> = tip.planes.iter().map(|&p| (m[p], corner)).collect();
1300        for direction in &directions {
1301            walls.push((-*direction, tip.centre));
1302        }
1303        let tool = ball_block(
1304            model,
1305            &walls,
1306            corner,
1307            tip.centre,
1308            &hosts,
1309            &directions,
1310            &is_ridge,
1311            radius,
1312            tol,
1313        )?;
1314        let cut = ogeom_bool::cut(model, &rounded, &tool.shape, tol).map_err(|e| {
1315            ogeom_core::ogeom_err!(
1316                NotDone,
1317                "the cut by the ball's block at tip vertex {index} failed: {e}"
1318            )
1319        })?;
1320        history = history.then(&tool.history).then(&cut.history);
1321        rounded = cut.shape;
1322    }
1323    // The cylinder along each ridge: the flush fillet of a virtual crease (
1324    // the line the two planes it touches would meet along) between the
1325    // planes square to the ridge through its two centres, which are the
1326    // caps' own planes. The planar fillet builds that wedge face by face,
1327    // band, legs and caps, and melts it; the caps meet the spheres' rims
1328    // on the planes the vertex compartments already cut.
1329    for ridge in &ridges {
1330        let (v1, v2) = (tips[ridge.from].centre, tips[ridge.to].centre);
1331        let along = (v2 - v1).normalized(tol)?;
1332        let [a, c] = ridge.planes;
1333        let on_crease = |v: Point| corner + along * (v - corner).dot(along);
1334        let face_on = |model: &Model, inward: Vector| -> OgeomResult<Shape> {
1335            for face in ogeom_topo::explore_unique(model, &rounded, ShapeType::Face)? {
1336                let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
1337                    continue;
1338                };
1339                if !matches!(
1340                    model.geometry().surface(data.surface),
1341                    Some(ogeom_geom::SurfaceGeometry::Plane(_))
1342                ) {
1343                    continue;
1344                }
1345                let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
1346                if (corner - origin).dot(outward).abs() <= tol.confusion() * 100.0
1347                    && outward.cross(inward).magnitude() < tol.angular() * 10.0
1348                    && outward.dot(inward) < 0.0
1349                {
1350                    return Ok(face);
1351                }
1352            }
1353            ogeom_bail!(
1354                Construction,
1355                "the ridge's host plane is no longer a face of the solid"
1356            )
1357        };
1358        let faces = [face_on(model, m[a])?, face_on(model, m[c])?];
1359        let seat = crate::support::Seat {
1360            start: on_crease(v1),
1361            end: on_crease(v2),
1362            along,
1363            normals: [-m[a], -m[c]],
1364            faces,
1365            convex: true,
1366        };
1367        let cut = crate::fillet::seated_fillet(model, &rounded, &seat, radius, None, &[], tol)
1368            .map_err(|e| ogeom_core::ogeom_err!(NotDone, "the ridge's flush fillet failed: {e}"))?;
1369        history = history.then(&cut.history);
1370        rounded = cut.shape;
1371    }
1372    history.modify(vertex, rounded.clone());
1373    Ok(Built {
1374        shape: rounded,
1375        history,
1376    })
1377}
1378
1379/// A convex polytope from the half-spaces that bound it, each given by
1380/// its inward normal and a point on its plane.
1381///
1382/// Its corners are the feasible meetings of three planes; each plane's
1383/// face is those of its corners that lie on it, walked round the face's
1384/// centroid. The polyhedron builder checks what this hands it (planarity,
1385/// every edge shared by two faces) so a set of half-spaces that bounds
1386/// nothing, or bounds a sliver, is refused rather than built.
1387pub(crate) fn convex_block(
1388    model: &mut Model,
1389    walls: &[(Vector, Point)],
1390    tol: Tolerances,
1391) -> OgeomResult<Shape> {
1392    let slack = tol.confusion() * 100.0;
1393    let n = walls.len();
1394    let mut points: Vec<Point> = Vec::new();
1395    for i in 0..n {
1396        for j in i + 1..n {
1397            for k in j + 1..n {
1398                let Some(p) = planes_meet(&walls[i], &walls[j], &walls[k]) else {
1399                    continue;
1400                };
1401                if walls
1402                    .iter()
1403                    .any(|(normal, on)| normal.dot(p - *on) < -slack)
1404                {
1405                    continue;
1406                }
1407                if points.iter().any(|q| q.distance(p) <= slack) {
1408                    continue;
1409                }
1410                points.push(p);
1411            }
1412        }
1413    }
1414    let mut rings: Vec<Vec<usize>> = Vec::new();
1415    for (normal, on) in walls {
1416        let mine: Vec<usize> = (0..points.len())
1417            .filter(|&i| normal.dot(points[i] - *on).abs() <= slack)
1418            .collect();
1419        if mine.len() < 3 {
1420            continue;
1421        }
1422        let centroid = mine
1423            .iter()
1424            .fold(Vector::ZERO, |acc, &i| acc + (points[i] - Point::ORIGIN))
1425            * (1.0 / f64::from(u32::try_from(mine.len()).unwrap_or(u32::MAX)));
1426        let centroid = Point::ORIGIN + centroid;
1427        let axis = normal.normalized(tol)?;
1428        let first = (points[mine[0]] - centroid).normalized(tol)?;
1429        let second = axis.cross(first);
1430        let mut ordered: Vec<(f64, usize)> = mine
1431            .iter()
1432            .map(|&i| {
1433                let v = points[i] - centroid;
1434                (v.dot(second).atan2(v.dot(first)), i)
1435            })
1436            .collect();
1437        ordered.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
1438        rings.push(ordered.into_iter().map(|(_, i)| i).collect());
1439    }
1440    Ok(ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape)
1441}
1442
1443/// Where three planes meet, or `None` where they do not span.
1444pub(crate) fn planes_meet(
1445    a: &(Vector, Point),
1446    b: &(Vector, Point),
1447    c: &(Vector, Point),
1448) -> Option<Point> {
1449    let m = [a.0, b.0, c.0];
1450    let rhs = [
1451        a.0.dot(a.1 - Point::ORIGIN),
1452        b.0.dot(b.1 - Point::ORIGIN),
1453        c.0.dot(c.1 - Point::ORIGIN),
1454    ];
1455    let x = solve3(&m, rhs)?;
1456    Some(Point::ORIGIN + x)
1457}
1458
1459/// `m_k · x = rhs_k` for three rows, or `None` where they do not span.
1460fn solve3(m: &[Vector; 3], rhs: [f64; 3]) -> Option<Vector> {
1461    let a: [[f64; 3]; 3] = std::array::from_fn(|i| [m[i].x, m[i].y, m[i].z]);
1462    let det3 = |a: &[[f64; 3]; 3]| -> f64 {
1463        a[0][0].mul_add(
1464            a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
1465            -a[0][1].mul_add(
1466                a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
1467                -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
1468            ),
1469        )
1470    };
1471    let det = det3(&a);
1472    if !det.is_finite() || det.abs() <= 1e-12 {
1473        return None;
1474    }
1475    let mut x = [0.0_f64; 3];
1476    for (k, xk) in x.iter_mut().enumerate() {
1477        let mut ak = a;
1478        for (row, r) in ak.iter_mut().zip(rhs) {
1479            row[k] = r;
1480        }
1481        *xk = det3(&ak) / det;
1482    }
1483    Some(Vector::new(x[0], x[1], x[2]))
1484}
1485
1486/// The point a radius in from three planes through `corner`, or `None`
1487/// where they do not span.
1488fn ball_centre(corner: Point, m: &[Vector; 3], radius: f64) -> Option<Point> {
1489    solve3(m, [radius; 3]).map(|x| corner + x)
1490}
1491
1492/// A compartment less the ball centred in it: the block from its walls,
1493/// cut by the sphere at `far`.
1494///
1495/// Host `t` holds edges `t` and `t + 1`. The ball's pole stands along a
1496/// host's normal and its seam meridian runs out through the first of that
1497/// host's edges (in a rim plane, so the seam doubles as a trim rather
1498/// than crossing a patch) and every labelling is offered in turn, the
1499/// first that closes standing. A cut that closes on a tool reaching past
1500/// its own block is a wrong tool, not a closed one, and is passed over
1501/// too.
1502#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
1503fn ball_block(
1504    model: &mut Model,
1505    walls: &[(Vector, Point)],
1506    corner: Point,
1507    far: Point,
1508    hosts: &[Vector],
1509    directions: &[Vector],
1510    is_ridge: &[bool],
1511    radius: f64,
1512    tol: Tolerances,
1513) -> OgeomResult<Built> {
1514    let n = directions.len();
1515    let mut last: Option<ogeom_core::OgeomError> = None;
1516    // The ball's frames, best first. Along a ridge, the ridge's rim plane
1517    // is the sphere's equator, both poles stand outside the patch (the
1518    // patch lies on the corner's side of every rim plane, the poles on
1519    // the ridge's own axis either side of it) and a seam meridian turned
1520    // away from the corner never crosses it; the ridge fillet's cap, in
1521    // that same rim plane, then meets the sphere on a circle the chart
1522    // images exactly. Then the classic labellings: the pole along host t,
1523    // the seam out through edge t, hosts holding no ridge first.
1524    // The patch's corners are the touch points, a radius from the centre
1525    // against each host; a frame is judged by how far its seam meridian
1526    // keeps from every corner in longitude about the pole, and a pole the
1527    // patch itself contains (inside every rim plane's kept side) is no
1528    // frame at all, since a chart's degenerate point cannot stand inside
1529    // a face. Either sense of every ridge is offered, with the seam turned
1530    // from the corner or from the corners' mean, the clearest first.
1531    let touches: Vec<Vector> = hosts.iter().map(|m| -*m).collect();
1532    let inside_patch = |direction: Vector| -> bool {
1533        directions
1534            .iter()
1535            .all(|e| e.dot(direction) <= tol.angular() * 10.0)
1536    };
1537    let mut scored: Vec<(f64, Vector, Vector)> = Vec::new();
1538    for t in 0..n {
1539        if !is_ridge[t] {
1540            continue;
1541        }
1542        for pole in [directions[t], -directions[t]] {
1543            if inside_patch(pole) || inside_patch(-pole) {
1544                continue;
1545            }
1546            let flat = |v: Vector| v - pole * v.dot(pole);
1547            let mean = touches.iter().fold(Vector::ZERO, |acc, t| acc + *t);
1548            for seam in [flat(-mean), flat(far - corner)] {
1549                if seam.magnitude() <= tol.angular() * 10.0 {
1550                    continue;
1551                }
1552                let x = seam / seam.magnitude();
1553                let y = pole.cross(x);
1554                let clearance = touches
1555                    .iter()
1556                    .map(|t| t.dot(y).atan2(t.dot(x)).abs())
1557                    .fold(f64::INFINITY, f64::min);
1558                scored.push((clearance, pole, seam));
1559            }
1560        }
1561    }
1562    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
1563    if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1564        for (clearance, pole, seam) in &scored {
1565            eprintln!("FRAME clearance {clearance:.3} pole {pole:?} seam {seam:?}");
1566        }
1567    }
1568    let mut frames: Vec<(Vector, Vector)> = scored.into_iter().map(|(_, p, s)| (p, s)).collect();
1569    // Host t holds edges t and t+1; a labelling's pole host is `start`
1570    // forward and `start − 1` reversed.
1571    let pole_host = |index: usize| -> usize {
1572        let (start, reverse) = (index % n, index >= n);
1573        if reverse {
1574            (start + 2 * n - 1) % n
1575        } else {
1576            start
1577        }
1578    };
1579    let clean = |index: usize| -> bool {
1580        let h = pole_host(index);
1581        !is_ridge[h] && !is_ridge[(h + 1) % n]
1582    };
1583    let mut order: Vec<usize> = (0..2 * n).collect();
1584    order.sort_by_key(|&index| !clean(index));
1585    frames.extend(
1586        order
1587            .iter()
1588            .map(|&index| (hosts[pole_host(index)], directions[index % n])),
1589    );
1590    for (index, (pole, seam)) in frames.into_iter().enumerate() {
1591        model.begin_operation();
1592        let attempt = (|| -> OgeomResult<Built> {
1593            let block = convex_block(model, walls, tol)?;
1594            let block_bound = ogeom_algo::shape_bounds(model, &block, tol)?;
1595            let ball_frame = Frame::new(
1596                far,
1597                Direction::new(pole, tol)?,
1598                Direction::new(seam, tol)?,
1599                tol,
1600            )?;
1601            let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
1602            let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
1603            let tool_bound = ogeom_algo::shape_bounds(model, &tool.shape, tol)?;
1604            let reach = tol.confusion() * 1e3;
1605            let (Some(block_lo), Some(block_hi), Some(tool_lo), Some(tool_hi)) = (
1606                block_bound.low(),
1607                block_bound.high(),
1608                tool_bound.low(),
1609                tool_bound.high(),
1610            ) else {
1611                ogeom_bail!(NotDone, "the ball's cut left no tool");
1612            };
1613            if tool_lo.x < block_lo.x - reach
1614                || tool_lo.y < block_lo.y - reach
1615                || tool_lo.z < block_lo.z - reach
1616                || tool_hi.x > block_hi.x + reach
1617                || tool_hi.y > block_hi.y + reach
1618                || tool_hi.z > block_hi.z + reach
1619            {
1620                ogeom_bail!(
1621                    NotDone,
1622                    "the ball's cut left a tool reaching past its own block"
1623                );
1624            }
1625            Ok(tool)
1626        })();
1627        match attempt {
1628            Ok(tool) => {
1629                if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1630                    eprintln!("BALL frame {index} closed");
1631                }
1632                return Ok(tool);
1633            }
1634            Err(err) => {
1635                if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1636                    eprintln!("BALL frame {index} pole {pole:?} seam {seam:?}: {err}");
1637                }
1638                last = Some(err);
1639            }
1640        }
1641    }
1642    ogeom_bail!(
1643        NotDone,
1644        "the corner tool's block closed on none of its {} frames; the last said: {}",
1645        2 * n,
1646        last.map_or_else(String::new, |e| e.to_string())
1647    )
1648}
1649
1650#[cfg(test)]
1651mod tests {
1652    #![allow(clippy::unwrap_used, reason = "test code")]
1653    use super::{round_vertex_with, round_vertices_together};
1654    use ogeom_core::Tolerances;
1655    use ogeom_math::{Point, Vector};
1656    use ogeom_topo::{Model, Shape, ShapeType};
1657
1658    const T: Tolerances = Tolerances::millimetres();
1659
1660    fn vertex_at(model: &Model, shape: &Shape, at: Point) -> Shape {
1661        ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)
1662            .unwrap()
1663            .into_iter()
1664            .find(|v| {
1665                model
1666                    .node(v)
1667                    .and_then(|n| n.data().as_vertex())
1668                    .is_some_and(|d| d.point.distance(at) < 1e-9)
1669            })
1670            .unwrap()
1671    }
1672
1673    fn volume(model: &Model, shape: &Shape) -> f64 {
1674        ogeom_algo::volume_properties(model, shape, ogeom_mesh::Deflection::default(), T)
1675            .unwrap()
1676            .mass
1677    }
1678
1679    /// Every labelling of a corner closes in the cut and builds one solid:
1680    /// the charts differ, the construction does not.
1681    fn every_labelling_agrees(model: &mut Model, solid: &Shape, vertex: &Shape, count: usize) {
1682        let mut volumes = Vec::with_capacity(count);
1683        for index in 0..count {
1684            let rounded = round_vertex_with(model, solid, vertex, 2.0, Some(index), T)
1685                .unwrap_or_else(|e| panic!("labelling {index} did not close: {e}"));
1686            let diagnosis = ogeom_algo::check(model, &rounded.shape, T).unwrap();
1687            assert!(
1688                diagnosis.is_valid(),
1689                "labelling {index}: {:?}",
1690                diagnosis.problems
1691            );
1692            volumes.push(volume(model, &rounded.shape));
1693        }
1694        // A labelling decides where a rim is exact and where fitted, and a
1695        // rim fitted to a tenth of a micron moves a few square millimetres
1696        // of patch by a few millionths of a cubic millimetre: one part in
1697        // ten million of these solids covers it.
1698        for (index, v) in volumes.iter().enumerate() {
1699            assert!(
1700                (v - volumes[0]).abs() < 1e-7 * volumes[0],
1701                "labelling {index} builds {v}, labelling 0 {}",
1702                volumes[0]
1703            );
1704        }
1705    }
1706
1707    /// The box's eight corners and the solid each rounds to, the corners
1708    /// rounded one after another.
1709    fn box_corners_in_turn(model: &mut Model, radius: f64) -> (Shape, Vec<Shape>, Shape) {
1710        let solid = ogeom_algo::make_box(model, ogeom_math::Frame::WORLD, (10.0, 10.0, 10.0), T)
1711            .unwrap()
1712            .shape;
1713        let vertices = ogeom_topo::explore_unique(model, &solid, ShapeType::Vertex).unwrap();
1714        let mut rounded = solid.clone();
1715        for vertex in &vertices {
1716            rounded = round_vertex_with(model, &rounded, vertex, radius, None, T)
1717                .unwrap()
1718                .shape;
1719        }
1720        (solid, vertices, rounded)
1721    }
1722
1723    fn counts(model: &Model, shape: &Shape) -> [usize; 3] {
1724        [ShapeType::Face, ShapeType::Edge, ShapeType::Vertex].map(|kind| {
1725            ogeom_topo::explore_unique(model, shape, kind)
1726                .unwrap()
1727                .len()
1728        })
1729    }
1730
1731    /// Corners whose tools and balls stand clear of each other are cut in
1732    /// one boolean, which gathers fewer faces than cutting them in turn and
1733    /// builds the same solid.
1734    #[test]
1735    fn corners_clear_of_each_other_round_in_one_cut_as_in_turn() {
1736        for radius in [1.0, 2.0] {
1737            let mut model = Model::new();
1738            let ((solid, vertices, in_turn), one_by_one) =
1739                ogeom_bool::work::counted(|| box_corners_in_turn(&mut model, radius));
1740            let (together, at_once) = ogeom_bool::work::counted(|| {
1741                round_vertices_together(&mut model, &solid, &vertices, radius, T).unwrap()
1742            });
1743            let together = together.unwrap();
1744            assert!(
1745                ogeom_algo::check(&model, &together.shape, T)
1746                    .unwrap()
1747                    .is_valid()
1748            );
1749            let (want, got) = (volume(&model, &in_turn), volume(&model, &together.shape));
1750            assert!(
1751                (got - want).abs() <= want * 1e-9,
1752                "r {radius}: {got} against {want}"
1753            );
1754            assert_eq!(counts(&model, &together.shape), counts(&model, &in_turn));
1755            assert!(
1756                at_once.faces_gathered < one_by_one.faces_gathered,
1757                "r {radius}: {at_once:?} at once against {one_by_one:?} in turn"
1758            );
1759            for vertex in &vertices {
1760                assert_eq!(
1761                    together.history.modified(vertex),
1762                    core::slice::from_ref(&together.shape)
1763                );
1764            }
1765        }
1766    }
1767
1768    /// Balls within the boolean's touching distance of each other are left
1769    /// to be cut in turn: there a patch cut later is recharted against the
1770    /// ball of one cut before it, which one cut never does.
1771    #[test]
1772    fn corners_whose_balls_come_close_round_in_turn() {
1773        let mut model = Model::new();
1774        let solid =
1775            ogeom_algo::make_box(&mut model, ogeom_math::Frame::WORLD, (10.0, 10.0, 10.0), T)
1776                .unwrap()
1777                .shape;
1778        let vertices = ogeom_topo::explore_unique(&model, &solid, ShapeType::Vertex).unwrap();
1779        let together = round_vertices_together(&mut model, &solid, &vertices, 4.9, T).unwrap();
1780        assert!(together.is_none());
1781    }
1782
1783    #[test]
1784    fn an_oblique_corner_rounds_the_same_under_every_labelling() {
1785        let mut model = Model::new();
1786        let (a, b, c) = (
1787            Vector::new(20.0, 0.0, 0.0),
1788            Vector::new(6.0, 20.0, 0.0),
1789            Vector::new(3.6, 6.0, 20.0),
1790        );
1791        let block = ogeom_algo::make_parallelepiped(&mut model, Point::ORIGIN, [a, b, c], T)
1792            .unwrap()
1793            .shape;
1794        let vertex = vertex_at(&model, &block, Point::ORIGIN);
1795        every_labelling_agrees(&mut model, &block, &vertex, 6);
1796    }
1797
1798    #[test]
1799    fn a_square_pyramid_apex_rounds_the_same_under_every_labelling() {
1800        let mut model = Model::new();
1801        let apex = Point::new(0.0, 0.0, 15.0);
1802        let base = [
1803            Point::new(-10.0, -10.0, 0.0),
1804            Point::new(10.0, -10.0, 0.0),
1805            Point::new(10.0, 10.0, 0.0),
1806            Point::new(-10.0, 10.0, 0.0),
1807        ];
1808        // Four slopes and the base, each wound about its outward normal.
1809        let mut faces = Vec::new();
1810        for k in 0..4 {
1811            let (p, q) = (base[k], base[(k + 1) % 4]);
1812            let outward = (q - p).cross(apex - p);
1813            faces.push(crate::support::planar_face(&mut model, &[p, q, apex], outward, T).unwrap());
1814        }
1815        let reversed: Vec<Point> = base.iter().rev().copied().collect();
1816        faces.push(crate::support::planar_face(&mut model, &reversed, -Vector::Z, T).unwrap());
1817        let sewn = ogeom_algo::sew(&mut model, &faces, T).unwrap();
1818        let pyramid = ogeom_algo::make_solid(&mut model, &sewn.shells[..1])
1819            .unwrap()
1820            .shape;
1821        let vertex = vertex_at(&model, &pyramid, apex);
1822        every_labelling_agrees(&mut model, &pyramid, &vertex, 8);
1823    }
1824}