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//!
11//! *Elsewhere:* the vertex blend of `ChFi3d`'s setback family.
12
13use ogeom_algo::Built;
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15use ogeom_math::{Direction, Frame, Point, Vector};
16use ogeom_topo::{Model, Shape, ShapeType};
17
18/// Round a solid's vertex with a ball of `radius`.
19///
20/// The construction is the corner family's centre of gravity, promoted from
21/// the B2 proof: the corner block spanned by the edges less the ball seated
22/// a radius in from every host plane is exactly the spike a rounded corner
23/// sheds, and the general boolean does the shedding. The block's faces
24/// through the ball's centre are square to the edges, so each edge's flush
25/// band ends on the ball's rim: three sequential fillets at a box corner
26/// followed by this call round the vertex the setback way, and the
27/// `b2_three_fillets_and_the_corner_tool_round_the_vertex` pin measures
28/// the result against a closed form. At a vertex of more edges the corner
29/// goes first and the fillets follow (four bands built before the corner
30/// crash into each other at a pyramid's apex) and the block is the
31/// polyhedron of the N host planes and the N planes square to the edges.
32///
33/// A vertex whose planes share no tangent ball (a rectangular pyramid's
34/// apex, any general N-edged vertex) is rounded by the envelope of every
35/// ball a radius in from all of them: a sphere at each vertex of the
36/// region the ball's centre may occupy and a cylinder along each ridge
37/// between two of them, each cut with its own compartment, the spheres
38/// by the one-ball tool on their three planes and the ridges by the flush
39/// fillet of a virtual crease. The compartments meet on the planes
40/// square to the ridges, cap to cap.
41///
42/// A vertex where a curved face meets (fewer than three planes pass
43/// through it) is rounded by the one ball touching the three surfaces that
44/// do, wherever they curve: its centre walked to a radius in from each,
45/// the compartment bounded by the three planes through the centre and two
46/// touch points, clipped to the solid, less the ball.
47///
48/// # Errors
49///
50/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
51/// vertex is not a vertex of the solid; if fewer than three planes pass
52/// through it and other than three surfaces do, or the ball does not seat
53/// inside every face; if the region the ball's centre may occupy has a tip vertex touching more than three planes
54/// without one ball touching all of the corner's, or with other than three
55/// edges leaving it; or if the corner turns out concave, where a ball adds
56/// material instead of shedding it and a tool built from a cut cannot say
57/// so. The corner may be oblique: the block is then the hexahedron bounded
58/// by the host planes and the three planes through the ball's centre
59/// square to the edges.
60pub fn round_vertex(
61    model: &mut Model,
62    solid: &Shape,
63    vertex: &Shape,
64    radius: f64,
65    tol: Tolerances,
66) -> OgeomResult<Built> {
67    round_vertex_with(model, solid, vertex, radius, None, tol)
68}
69
70/// [`round_vertex`], with the one-ball corner held to the labelling
71/// `forced` names (by index, the ring's start then its direction) instead
72/// of offered each in turn.
73pub(crate) fn round_vertex_with(
74    model: &mut Model,
75    solid: &Shape,
76    vertex: &Shape,
77    radius: f64,
78    forced: Option<usize>,
79    tol: Tolerances,
80) -> OgeomResult<Built> {
81    if model.kind_of(vertex)? != ShapeType::Vertex {
82        ogeom_bail!(Construction, "round_vertex rounds a vertex");
83    }
84    if radius <= tol.confusion() {
85        ogeom_bail!(Construction, "a blend radius must be a positive distance");
86    }
87    let Some(raw) = model
88        .node(vertex)
89        .and_then(|n| n.data().as_vertex().map(|d| d.point))
90    else {
91        ogeom_bail!(Construction, "the vertex holds no point");
92    };
93    // Where the vertex stands, not where its node was built: a prism's far
94    // end is its near end moved, and read unplaced the far corner is the
95    // near one: the tool rounded the wrong corner of the solid.
96    let corner = vertex.transform(model.datums())?.apply(raw);
97
98    // The corner's frame comes from the planes that pass through the
99    // vertex's point, not from the vertex's own adjacency, which the very
100    // sequence this tool serves destroys: after three fillets the tip is
101    // consumed, but the three shrunk planes still contain the corner, and
102    // still say exactly which corner it was. The vertex argument may
103    // therefore come from an earlier state of the solid (the sharp box's
104    // corner captured before the fillets) and anchors the history. Each
105    // plane's material side is read off its face, which the fillets shrink
106    // but never turn over.
107    let mut m: Vec<Vector> = Vec::new();
108    for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
109        let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
110            continue;
111        };
112        let Some(surface) = model.geometry().surface(data.surface) else {
113            continue;
114        };
115        if !matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)) {
116            continue;
117        }
118        let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
119        if (corner - origin).dot(outward).abs() > tol.confusion() * 100.0 {
120            continue;
121        }
122        // One vote per plane: coplanar trims share it.
123        if m.iter()
124            .any(|n| n.cross(outward).magnitude() < tol.angular() * 10.0)
125        {
126            continue;
127        }
128        m.push(-outward);
129    }
130    let n = m.len();
131    if n < 3 {
132        // A curved face meets here: the one-ball corner on whatever
133        // surfaces pass through the vertex.
134        return crate::corner_curved::curved_corner(model, solid, vertex, corner, radius, tol);
135    }
136    // The ball's centre: the point a radius in from every plane. Three
137    // planes that span always hold one; more only when they share a
138    // tangent ball, which the least-squares fit's residual tells: a
139    // square pyramid's apex does, a general N-edged vertex does not, and
140    // that vertex is owed the general setback patch instead.
141    let centre_for = |m: &[Vector]| -> Option<(Point, f64)> {
142        // m_k · (c − corner) = radius over all k: solved directly for
143        // three planes, through the normal equations for more. The direct
144        // solve is kept for three not for speed but for its last bit: the
145        // boolean's paving at the touch points is still sensitive to an
146        // ulp of the centre, and the oblique corner closes on the direct
147        // solve's value.
148        let mut a = [[0.0_f64; 3]; 3];
149        let mut b = [0.0_f64; 3];
150        if m.len() == 3 {
151            for (i, mk) in m.iter().enumerate() {
152                a[i] = [mk.x, mk.y, mk.z];
153                b[i] = radius;
154            }
155        } else {
156            for mk in m {
157                let v = [mk.x, mk.y, mk.z];
158                for i in 0..3 {
159                    b[i] += radius * v[i];
160                    for j in 0..3 {
161                        a[i][j] += v[i] * v[j];
162                    }
163                }
164            }
165        }
166        let det3 = |a: &[[f64; 3]; 3]| -> f64 {
167            a[0][0].mul_add(
168                a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
169                -a[0][1].mul_add(
170                    a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
171                    -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
172                ),
173            )
174        };
175        let det = det3(&a);
176        if !det.is_finite() || det.abs() <= 1e-12 {
177            return None;
178        }
179        let mut x = [0.0_f64; 3];
180        for (k, xk) in x.iter_mut().enumerate() {
181            let mut ak = a;
182            for (row, bi) in ak.iter_mut().zip(b) {
183                row[k] = bi;
184            }
185            *xk = det3(&ak) / det;
186        }
187        let centre = corner + Vector::new(x[0], x[1], x[2]);
188        let residual = m
189            .iter()
190            .map(|mk| (mk.dot(centre - corner) - radius).abs())
191            .fold(0.0, f64::max);
192        Some((centre, residual))
193    };
194    let Some((far, residual)) = centre_for(&m) else {
195        ogeom_bail!(
196            Construction,
197            "the {n} planes through this vertex do not span a corner"
198        );
199    };
200    if residual > tol.confusion() * 10.0 {
201        // No one ball touches every face: the rounded corner is the
202        // envelope of every ball a radius in from all of them, which is
203        // more than one sphere. Its pieces are read off the region the
204        // ball's centre may occupy.
205        return setback_corner(model, solid, vertex, corner, &m, radius, tol);
206    }
207    // A concave vertex puts the ball's centre outside the material: a ball
208    // there adds material instead of shedding it, and a tool built from a
209    // cut cannot say so.
210    let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
211    if boundary.holds(model, far, tol)? != ogeom_algo::Containment::In {
212        ogeom_bail!(
213            Construction,
214            "no material a radius in from every face at this vertex; a \
215             concave vertex gains a ball instead of shedding one, and this \
216             tool cannot round it"
217        );
218    }
219    // The corner's edges: where two of the planes meet along a ray that
220    // lies inside all the others, directed from the vertex into the solid.
221    // A simple convex corner has one edge per plane, and the edges chain the
222    // planes into a ring round the vertex.
223    let mut edges: Vec<(usize, usize, Direction)> = Vec::new();
224    for i in 0..n {
225        for j in i + 1..n {
226            let cross = m[i].cross(m[j]);
227            let length = cross.magnitude();
228            if length <= tol.angular() * 10.0 {
229                continue;
230            }
231            for sign in [1.0, -1.0] {
232                let d = cross * (sign / length);
233                let inside = (0..n)
234                    .filter(|&k| k != i && k != j)
235                    .all(|k| m[k].dot(d) >= -tol.angular() * 10.0);
236                if inside {
237                    edges.push((i, j, Direction::new(d, tol)?));
238                    break;
239                }
240            }
241        }
242    }
243    if edges.len() != n {
244        ogeom_bail!(
245            Construction,
246            "the {n} planes through this vertex meet along {} edges, not {n}; \
247             the corner is not the simple convex one this tool speaks",
248            edges.len()
249        );
250    }
251    let mut ring: Vec<usize> = vec![0];
252    let mut last_edge: Option<usize> = None;
253    loop {
254        let here = *ring.last().unwrap_or(&0);
255        let Some((e, other)) = edges.iter().enumerate().find_map(|(e, &(i, j, _))| {
256            if Some(e) == last_edge {
257                None
258            } else if i == here {
259                Some((e, j))
260            } else if j == here {
261                Some((e, i))
262            } else {
263                None
264            }
265        }) else {
266            ogeom_bail!(
267                Construction,
268                "the planes through this vertex do not chain into a ring round it"
269            );
270        };
271        last_edge = Some(e);
272        if other == 0 {
273            break;
274        }
275        if ring.contains(&other) || ring.len() >= n {
276            ogeom_bail!(
277                Construction,
278                "the planes through this vertex do not chain into a ring round it"
279            );
280        }
281        ring.push(other);
282    }
283    if ring.len() != n {
284        ogeom_bail!(
285            Construction,
286            "the planes through this vertex do not chain into a ring round it"
287        );
288    }
289    // Plane `ring[k]` and plane `ring[k+1]` meet along edge k.
290    let d: Vec<Direction> = (0..n)
291        .map(|k| {
292            let (p, q) = (ring[k], ring[(k + 1) % n]);
293            edges
294                .iter()
295                .find(|&&(i, j, _)| (i == p && j == q) || (i == q && j == p))
296                .map(|&(_, _, dir)| dir)
297        })
298        .collect::<Option<_>>()
299        .ok_or_else(|| {
300            ogeom_core::ogeom_err!(Construction, "the corner's edges lost their ring")
301        })?;
302    let inward_of = |k: usize| m[ring[k % n]];
303
304    // The block: the corner bounded by its N host planes and, through the
305    // ball's centre, the N planes square to its edges, where each band's
306    // circle and the ball's own rim coincide, so the cut ends the band and
307    // starts the patch on one curve. On a square corner it is the box of
308    // side `radius`; on an oblique one a hexahedron; at a pyramid's apex a
309    // polyhedron of eight faces. Its corners: the vertex, the foot of each
310    // edge on its cutting plane, on each host plane the point where the
311    // ball touches it, and the centre itself.
312    //
313    // The ball on the corner's axes: a pole at one corner of the patch it
314    // leaves and its seam meridian out past the block through the first
315    // edge: the pole axis is the inward normal of the host plane holding
316    // the first two edges, the third edge itself on a square corner.
317    //
318    // Which edge is first and which way the ring runs is the tool's
319    // labelling, and the solid it builds is the same for all 2N. The
320    // charts the block's faces and the ball wear differ between them (where
321    // a rim is exact and where fitted, where a seam falls against a patch
322    // arc) and the boolean closes every one: the unit tests below round an
323    // oblique corner under all six and a pyramid's apex under all eight,
324    // one solid each time. The tool still offers each labelling in turn
325    // and the first that closes stands, so a corner no labelling closes is
326    // refused by name. A failed attempt's nodes stay in the model
327    // unreferenced, under their own operation.
328    let attempt = |model: &mut Model, start: usize, reverse: bool| -> OgeomResult<Built> {
329        // Edge t of the labelling and the host plane holding edges t and t+1.
330        let edge_at = |t: usize| -> usize {
331            if reverse {
332                (start + n - t % n) % n
333            } else {
334                (start + t) % n
335            }
336        };
337        let dl: Vec<Direction> = (0..n).map(|t| d[edge_at(t)]).collect();
338        let ml: Vec<Vector> = (0..n)
339            .map(|t| {
340                // Forward: edges t, t+1 are E_s+t, E_s+t+1, both on plane
341                // ring[s+t+1]. Reverse: E_s−t, E_s−t−1, both on ring[s−t].
342                if reverse {
343                    inward_of((start + n - t % n) % n)
344                } else {
345                    inward_of(edge_at(t) + 1)
346                }
347            })
348            .collect();
349        let along =
350            |t: usize| -> Point { corner + dl[t].vector() * (far - corner).dot(dl[t].vector()) };
351        let touch = |t: usize| -> Point { far - ml[t] * radius };
352        if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
353            eprintln!(
354                "CORNER attempt start {start} reverse {reverse} far {far:?} d {:?} m {ml:?}",
355                dl.iter().map(|x| x.vector()).collect::<Vec<_>>()
356            );
357        }
358        model.begin_operation();
359        let mut points: Vec<Point> = vec![corner];
360        points.extend((0..n).map(along));
361        points.extend((0..n).map(touch));
362        points.push(far);
363        let mut rings: Vec<Vec<usize>> = Vec::with_capacity(2 * n);
364        for t in 0..n {
365            // The host face on plane t: vertex, edge t's foot, the touch
366            // point, edge t+1's foot.
367            rings.push(vec![0, 1 + t, 1 + n + t, 1 + (t + 1) % n]);
368            // The face square to edge t: its foot, the touch points either
369            // side, the centre.
370            rings.push(vec![1 + t, 1 + n + (t + n - 1) % n, 1 + 2 * n, 1 + n + t]);
371        }
372        let block = ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape;
373        let ball_frame = Frame::new(far, Direction::new(ml[0], tol)?, dl[0], tol)?;
374        let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
375        let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
376        if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
377            let faces = ogeom_topo::explore_unique(model, &tool.shape, ShapeType::Face)?;
378            let kinds: Vec<String> = faces
379                .iter()
380                .map(|f| {
381                    let kind = model
382                        .node(f)
383                        .and_then(|n| n.data().as_face())
384                        .and_then(|d| model.geometry().surface(d.surface))
385                        .map_or("?", |sg| match sg {
386                            ogeom_geom::SurfaceGeometry::Plane(_) => "plane",
387                            ogeom_geom::SurfaceGeometry::Sphere(_) => "sphere",
388                            _ => "other",
389                        });
390                    let edges = ogeom_topo::explore_unique(model, f, ShapeType::Edge)
391                        .map_or(0, |e| e.len());
392                    format!("{kind}/{edges}")
393                })
394                .collect();
395            eprintln!(
396                "CORNER tool for start {start} reverse {reverse}: {} faces {kinds:?}",
397                faces.len()
398            );
399        }
400        let rounded = ogeom_bool::cut(model, solid, &tool.shape, tol)?;
401        Ok(Built {
402            shape: rounded.shape,
403            history: tool.history.then(&rounded.history),
404        })
405    };
406    let mut outcome: Option<Built> = None;
407    let mut last: Option<ogeom_core::OgeomError> = None;
408    let labellings = (0..2 * n).map(|index| (index % n, index >= n));
409    for (index, (start, reverse)) in labellings.enumerate() {
410        if forced.is_some_and(|f| f != index) {
411            continue;
412        }
413        match attempt(model, start, reverse) {
414            Ok(built) => {
415                outcome = Some(built);
416                break;
417            }
418            Err(err) => last = Some(err),
419        }
420    }
421    let Some(rounded) = outcome else {
422        ogeom_bail!(
423            NotDone,
424            "the corner tool's cut closed on none of the corner's {} \
425             labellings; the last said: {}",
426            2 * n,
427            last.map_or_else(String::new, |e| e.to_string())
428        );
429    };
430    let mut built = rounded;
431    built.history.modify(vertex, built.shape.clone());
432    Ok(built)
433}
434
435/// A vertex of the region the ball's centre may occupy: a point a radius
436/// in from three or more of the host planes and at least a radius from the
437/// rest, with the planes it touches.
438struct TipVertex {
439    centre: Point,
440    planes: Vec<usize>,
441}
442
443/// An edge of that region's tip between two of its vertices: the ball
444/// rolling from one to the other touches two planes all the way, and
445/// sweeps a cylinder.
446struct Ridge {
447    from: usize,
448    to: usize,
449    planes: [usize; 2],
450}
451
452/// Round a convex planar vertex whose faces no single ball touches.
453///
454/// The ball's centre may sit anywhere a radius in from every host plane:
455/// a convex region whose tip, at a vertex one ball touches, is a single
456/// point, and otherwise a few points joined by short edges: a rectangular
457/// pyramid's apex has two, joined along the two long slopes. The rounded
458/// corner is the envelope of every ball centred in that region: a sphere
459/// at each tip vertex, a cylinder along each edge between them, and the
460/// host planes themselves elsewhere. Exact, constant-radius, and what the
461/// rolling ball leaves, where a plate would be fitted through the bands'
462/// ends instead.
463///
464/// Each piece is cut with its own block: at a tip vertex, the corner
465/// bounded by its three planes and the three planes through the centre
466/// square to its edges, less the ball (the one-ball tool exactly) and
467/// along an edge between two centres, the prism over the kite of the
468/// virtual crease, the two touch points and the centre, between the two
469/// planes square to the edge, less the cylinder. The compartments tile
470/// the corner and meet on the planes square to the edges, where each
471/// sphere's rim and the cylinder's end coincide, so the cuts consume one
472/// another's flush faces cap to cap. An original edge leaves its tip
473/// vertex along a ray, and the plane square to it there is where the
474/// edge's band ends when the flush fillets follow.
475fn setback_corner(
476    model: &mut Model,
477    solid: &Shape,
478    vertex: &Shape,
479    corner: Point,
480    m: &[Vector],
481    radius: f64,
482    tol: Tolerances,
483) -> OgeomResult<Built> {
484    let n = m.len();
485    let slack = tol.confusion() * 10.0;
486    // The tip's vertices: every triple that meets at a point a radius in
487    // from all the planes, merged where several triples name one point.
488    let mut tips: Vec<TipVertex> = Vec::new();
489    for i in 0..n {
490        for j in i + 1..n {
491            for k in j + 1..n {
492                let Some(centre) = ball_centre(corner, &[m[i], m[j], m[k]], radius) else {
493                    continue;
494                };
495                let feasible = m.iter().all(|mk| mk.dot(centre - corner) >= radius - slack);
496                if !feasible {
497                    continue;
498                }
499                if tips.iter().any(|t| t.centre.distance(centre) <= slack) {
500                    continue;
501                }
502                let planes: Vec<usize> = (0..n)
503                    .filter(|&l| (m[l].dot(centre - corner) - radius).abs() <= slack)
504                    .collect();
505                tips.push(TipVertex { centre, planes });
506            }
507        }
508    }
509    if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
510        for tip in &tips {
511            eprintln!("TIP {:?} planes {:?}", tip.centre, tip.planes);
512        }
513    }
514    if tips.is_empty() {
515        ogeom_bail!(
516            Construction,
517            "the {n} planes through this vertex hold no ball a radius in from \
518             all of them"
519        );
520    }
521    // Each tip vertex's edges: along every pair of its planes, the way the
522    // rest of its planes allow; bounded where another plane becomes
523    // tangent (a ridge to the tip vertex there) and a ray otherwise, the
524    // way an original edge's band runs. A ray that lies along no original
525    // edge, or a vertex with other than three edges, is a corner this tool
526    // does not speak.
527    let mut ridges: Vec<Ridge> = Vec::new();
528    let mut rays: Vec<Vec<(Vector, [usize; 2])>> = vec![Vec::new(); tips.len()];
529    for (index, tip) in tips.iter().enumerate() {
530        for (a, b) in tip
531            .planes
532            .iter()
533            .flat_map(|&a| tip.planes.iter().map(move |&b| (a, b)))
534        {
535            if a >= b {
536                continue;
537            }
538            let cross = m[a].cross(m[b]);
539            let length = cross.magnitude();
540            if length <= tol.angular() * 10.0 {
541                continue;
542            }
543            let Some(direction) = [1.0, -1.0]
544                .into_iter()
545                .map(|sign| cross * (sign / length))
546                .find(|d| {
547                    tip.planes
548                        .iter()
549                        .filter(|&&l| l != a && l != b)
550                        .all(|&l| m[l].dot(*d) >= -tol.angular() * 10.0)
551                })
552            else {
553                continue;
554            };
555            // The first other plane the ball meets rolling this way.
556            let mut nearest: Option<(f64, usize)> = None;
557            for (l, ml) in m.iter().enumerate() {
558                if tip.planes.contains(&l) {
559                    continue;
560                }
561                let rate = ml.dot(direction);
562                if rate >= -tol.angular() {
563                    continue;
564                }
565                let t = (radius - ml.dot(tip.centre - corner)) / rate;
566                if t > slack && nearest.is_none_or(|(held, _)| t < held) {
567                    nearest = Some((t, l));
568                }
569            }
570            match nearest {
571                Some((t, _)) => {
572                    let end = tip.centre + direction * t;
573                    let Some(to) = tips.iter().position(|o| o.centre.distance(end) <= slack) else {
574                        ogeom_bail!(
575                            Construction,
576                            "the ball rolling between two of this vertex's faces meets a \
577                             third where no tip vertex stands"
578                        );
579                    };
580                    if index < to {
581                        ridges.push(Ridge {
582                            from: index,
583                            to,
584                            planes: [a, b],
585                        });
586                    }
587                }
588                None => rays[index].push((direction, [a, b])),
589            }
590        }
591    }
592    let concave = {
593        let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
594        let mut concave = false;
595        for tip in &tips {
596            if boundary.holds(model, tip.centre, tol)? != ogeom_algo::Containment::In {
597                concave = true;
598            }
599        }
600        concave
601    };
602    if concave {
603        ogeom_bail!(
604            Construction,
605            "no material a radius in from every face at this vertex; a \
606             concave vertex gains a ball instead of shedding one, and this \
607             tool cannot round it"
608        );
609    }
610
611    model.begin_operation();
612    let mut rounded = solid.clone();
613    let mut history = ogeom_algo::History::new();
614    // The sphere at each tip vertex, with its own compartment: the wedge of
615    // its planes, cut by the plane square to each of its edges through the
616    // centre. A ridge's plane faces the corner, and the corner itself lies
617    // in the ridge's compartment, not this one.
618    for (index, tip) in tips.iter().enumerate() {
619        // The vertex's edges, each on two of its planes: its rays and the
620        // ridges that start or end here.
621        let mut edges: Vec<(Vector, [usize; 2])> = rays[index].clone();
622        for ridge in &ridges {
623            let other = if ridge.from == index {
624                ridge.to
625            } else if ridge.to == index {
626                ridge.from
627            } else {
628                continue;
629            };
630            let direction = (tips[other].centre - tip.centre).normalized(tol)?;
631            edges.push((direction, ridge.planes));
632        }
633        let k = tip.planes.len();
634        if edges.len() != k {
635            ogeom_bail!(
636                Construction,
637                "a tip vertex of this corner touches {k} planes along {} edges, not \
638                 {k}; the corner is not the simple convex one this tool speaks",
639                edges.len()
640            );
641        }
642        // In ring order, consecutive edges sharing a plane, so that host t
643        // holds edges t and t+1: the labelling the ball's frame is read
644        // off, its pole along a host and its seam out through an edge.
645        let shared = |x: &[usize; 2], y: &[usize; 2]| -> Option<usize> {
646            x.iter().copied().find(|p| y.contains(p))
647        };
648        let mut ring: Vec<(Vector, [usize; 2])> = vec![edges[0]];
649        let mut used = vec![false; k];
650        used[0] = true;
651        while ring.len() < k {
652            let last = ring[ring.len() - 1];
653            let Some(next) = (0..k).find(|&i| {
654                !used[i]
655                    && shared(&edges[i].1, &last.1).is_some_and(|p| {
656                        // The plane shared with the previous edge is not
657                        // the one shared with the edge before that.
658                        ring.len() < 2 || shared(&ring[ring.len() - 2].1, &last.1) != Some(p)
659                    })
660            }) else {
661                ogeom_bail!(Construction, "the tip vertex's edges do not chain")
662            };
663            used[next] = true;
664            ring.push(edges[next]);
665        }
666        let hosts: Vec<Vector> = (0..k)
667            .map(|t| {
668                shared(&ring[t].1, &ring[(t + 1) % k].1)
669                    .map(|p| m[p])
670                    .ok_or_else(|| {
671                        ogeom_core::ogeom_err!(Construction, "the tip vertex's edges do not chain")
672                    })
673            })
674            .collect::<OgeomResult<_>>()?;
675        let directions: Vec<Vector> = ring.iter().map(|(d, _)| *d).collect();
676        // Whether edge t is a ridge, whose rim plane a later cut's cap
677        // stands in: a pole along a plane holding a ridge would put both
678        // poles in that cap, and the cap's circle would have no chart
679        // image. Labellings whose pole host holds no ridge go first.
680        let is_ridge: Vec<bool> = ring
681            .iter()
682            .map(|(d, _)| !rays[index].iter().any(|(r, _)| r.dot(*d) > 1.0 - 1e-9))
683            .collect();
684        let mut walls: Vec<(Vector, Point)> = tip.planes.iter().map(|&p| (m[p], corner)).collect();
685        for direction in &directions {
686            walls.push((-*direction, tip.centre));
687        }
688        let tool = ball_block(
689            model,
690            &walls,
691            corner,
692            tip.centre,
693            &hosts,
694            &directions,
695            &is_ridge,
696            radius,
697            tol,
698        )?;
699        let cut = ogeom_bool::cut(model, &rounded, &tool.shape, tol).map_err(|e| {
700            ogeom_core::ogeom_err!(
701                NotDone,
702                "the cut by the ball's block at tip vertex {index} failed: {e}"
703            )
704        })?;
705        history = history.then(&tool.history).then(&cut.history);
706        rounded = cut.shape;
707    }
708    // The cylinder along each ridge: the flush fillet of a virtual crease (
709    // the line the two planes it touches would meet along) between the
710    // planes square to the ridge through its two centres, which are the
711    // caps' own planes. The planar fillet builds that wedge face by face,
712    // band, legs and caps, and melts it; the caps meet the spheres' rims
713    // on the planes the vertex compartments already cut.
714    for ridge in &ridges {
715        let (v1, v2) = (tips[ridge.from].centre, tips[ridge.to].centre);
716        let along = (v2 - v1).normalized(tol)?;
717        let [a, c] = ridge.planes;
718        let on_crease = |v: Point| corner + along * (v - corner).dot(along);
719        let face_on = |model: &Model, inward: Vector| -> OgeomResult<Shape> {
720            for face in ogeom_topo::explore_unique(model, &rounded, ShapeType::Face)? {
721                let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
722                    continue;
723                };
724                if !matches!(
725                    model.geometry().surface(data.surface),
726                    Some(ogeom_geom::SurfaceGeometry::Plane(_))
727                ) {
728                    continue;
729                }
730                let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
731                if (corner - origin).dot(outward).abs() <= tol.confusion() * 100.0
732                    && outward.cross(inward).magnitude() < tol.angular() * 10.0
733                    && outward.dot(inward) < 0.0
734                {
735                    return Ok(face);
736                }
737            }
738            ogeom_bail!(
739                Construction,
740                "the ridge's host plane is no longer a face of the solid"
741            )
742        };
743        let faces = [face_on(model, m[a])?, face_on(model, m[c])?];
744        let seat = crate::support::Seat {
745            start: on_crease(v1),
746            end: on_crease(v2),
747            along,
748            normals: [-m[a], -m[c]],
749            faces,
750            convex: true,
751        };
752        let cut = crate::fillet::seated_fillet(model, &rounded, &seat, radius, None, tol)
753            .map_err(|e| ogeom_core::ogeom_err!(NotDone, "the ridge's flush fillet failed: {e}"))?;
754        history = history.then(&cut.history);
755        rounded = cut.shape;
756    }
757    history.modify(vertex, rounded.clone());
758    Ok(Built {
759        shape: rounded,
760        history,
761    })
762}
763
764/// A convex polytope from the half-spaces that bound it, each given by
765/// its inward normal and a point on its plane.
766///
767/// Its corners are the feasible meetings of three planes; each plane's
768/// face is those of its corners that lie on it, walked round the face's
769/// centroid. The polyhedron builder checks what this hands it (planarity,
770/// every edge shared by two faces) so a set of half-spaces that bounds
771/// nothing, or bounds a sliver, is refused rather than built.
772fn convex_block(
773    model: &mut Model,
774    walls: &[(Vector, Point)],
775    tol: Tolerances,
776) -> OgeomResult<Shape> {
777    let slack = tol.confusion() * 100.0;
778    let n = walls.len();
779    let mut points: Vec<Point> = Vec::new();
780    for i in 0..n {
781        for j in i + 1..n {
782            for k in j + 1..n {
783                let Some(p) = planes_meet(&walls[i], &walls[j], &walls[k]) else {
784                    continue;
785                };
786                if walls
787                    .iter()
788                    .any(|(normal, on)| normal.dot(p - *on) < -slack)
789                {
790                    continue;
791                }
792                if points.iter().any(|q| q.distance(p) <= slack) {
793                    continue;
794                }
795                points.push(p);
796            }
797        }
798    }
799    let mut rings: Vec<Vec<usize>> = Vec::new();
800    for (normal, on) in walls {
801        let mine: Vec<usize> = (0..points.len())
802            .filter(|&i| normal.dot(points[i] - *on).abs() <= slack)
803            .collect();
804        if mine.len() < 3 {
805            continue;
806        }
807        let centroid = mine
808            .iter()
809            .fold(Vector::ZERO, |acc, &i| acc + (points[i] - Point::ORIGIN))
810            * (1.0 / f64::from(u32::try_from(mine.len()).unwrap_or(u32::MAX)));
811        let centroid = Point::ORIGIN + centroid;
812        let axis = normal.normalized(tol)?;
813        let first = (points[mine[0]] - centroid).normalized(tol)?;
814        let second = axis.cross(first);
815        let mut ordered: Vec<(f64, usize)> = mine
816            .iter()
817            .map(|&i| {
818                let v = points[i] - centroid;
819                (v.dot(second).atan2(v.dot(first)), i)
820            })
821            .collect();
822        ordered.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
823        rings.push(ordered.into_iter().map(|(_, i)| i).collect());
824    }
825    Ok(ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape)
826}
827
828/// Where three planes meet, or `None` where they do not span.
829fn planes_meet(a: &(Vector, Point), b: &(Vector, Point), c: &(Vector, Point)) -> Option<Point> {
830    let m = [a.0, b.0, c.0];
831    let rhs = [
832        a.0.dot(a.1 - Point::ORIGIN),
833        b.0.dot(b.1 - Point::ORIGIN),
834        c.0.dot(c.1 - Point::ORIGIN),
835    ];
836    let x = solve3(&m, rhs)?;
837    Some(Point::ORIGIN + x)
838}
839
840/// `m_k · x = rhs_k` for three rows, or `None` where they do not span.
841fn solve3(m: &[Vector; 3], rhs: [f64; 3]) -> Option<Vector> {
842    let a: [[f64; 3]; 3] = std::array::from_fn(|i| [m[i].x, m[i].y, m[i].z]);
843    let det3 = |a: &[[f64; 3]; 3]| -> f64 {
844        a[0][0].mul_add(
845            a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
846            -a[0][1].mul_add(
847                a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
848                -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
849            ),
850        )
851    };
852    let det = det3(&a);
853    if !det.is_finite() || det.abs() <= 1e-12 {
854        return None;
855    }
856    let mut x = [0.0_f64; 3];
857    for (k, xk) in x.iter_mut().enumerate() {
858        let mut ak = a;
859        for (row, r) in ak.iter_mut().zip(rhs) {
860            row[k] = r;
861        }
862        *xk = det3(&ak) / det;
863    }
864    Some(Vector::new(x[0], x[1], x[2]))
865}
866
867/// The point a radius in from three planes through `corner`, or `None`
868/// where they do not span.
869fn ball_centre(corner: Point, m: &[Vector; 3], radius: f64) -> Option<Point> {
870    solve3(m, [radius; 3]).map(|x| corner + x)
871}
872
873/// A compartment less the ball centred in it: the block from its walls,
874/// cut by the sphere at `far`.
875///
876/// Host `t` holds edges `t` and `t + 1`. The ball's pole stands along a
877/// host's normal and its seam meridian runs out through the first of that
878/// host's edges (in a rim plane, so the seam doubles as a trim rather
879/// than crossing a patch) and every labelling is offered in turn, the
880/// first that closes standing. A cut that closes on a tool reaching past
881/// its own block is a wrong tool, not a closed one, and is passed over
882/// too.
883#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
884fn ball_block(
885    model: &mut Model,
886    walls: &[(Vector, Point)],
887    corner: Point,
888    far: Point,
889    hosts: &[Vector],
890    directions: &[Vector],
891    is_ridge: &[bool],
892    radius: f64,
893    tol: Tolerances,
894) -> OgeomResult<Built> {
895    let n = directions.len();
896    let mut last: Option<ogeom_core::OgeomError> = None;
897    // The ball's frames, best first. Along a ridge, the ridge's rim plane
898    // is the sphere's equator, both poles stand outside the patch (the
899    // patch lies on the corner's side of every rim plane, the poles on
900    // the ridge's own axis either side of it) and a seam meridian turned
901    // away from the corner never crosses it; the ridge fillet's cap, in
902    // that same rim plane, then meets the sphere on a circle the chart
903    // images exactly. Then the classic labellings: the pole along host t,
904    // the seam out through edge t, hosts holding no ridge first.
905    // The patch's corners are the touch points, a radius from the centre
906    // against each host; a frame is judged by how far its seam meridian
907    // keeps from every corner in longitude about the pole, and a pole the
908    // patch itself contains (inside every rim plane's kept side) is no
909    // frame at all, since a chart's degenerate point cannot stand inside
910    // a face. Either sense of every ridge is offered, with the seam turned
911    // from the corner or from the corners' mean, the clearest first.
912    let touches: Vec<Vector> = hosts.iter().map(|m| -*m).collect();
913    let inside_patch = |direction: Vector| -> bool {
914        directions
915            .iter()
916            .all(|e| e.dot(direction) <= tol.angular() * 10.0)
917    };
918    let mut scored: Vec<(f64, Vector, Vector)> = Vec::new();
919    for t in 0..n {
920        if !is_ridge[t] {
921            continue;
922        }
923        for pole in [directions[t], -directions[t]] {
924            if inside_patch(pole) || inside_patch(-pole) {
925                continue;
926            }
927            let flat = |v: Vector| v - pole * v.dot(pole);
928            let mean = touches.iter().fold(Vector::ZERO, |acc, t| acc + *t);
929            for seam in [flat(-mean), flat(far - corner)] {
930                if seam.magnitude() <= tol.angular() * 10.0 {
931                    continue;
932                }
933                let x = seam / seam.magnitude();
934                let y = pole.cross(x);
935                let clearance = touches
936                    .iter()
937                    .map(|t| t.dot(y).atan2(t.dot(x)).abs())
938                    .fold(f64::INFINITY, f64::min);
939                scored.push((clearance, pole, seam));
940            }
941        }
942    }
943    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
944    if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
945        for (clearance, pole, seam) in &scored {
946            eprintln!("FRAME clearance {clearance:.3} pole {pole:?} seam {seam:?}");
947        }
948    }
949    let mut frames: Vec<(Vector, Vector)> = scored.into_iter().map(|(_, p, s)| (p, s)).collect();
950    // Host t holds edges t and t+1; a labelling's pole host is `start`
951    // forward and `start − 1` reversed.
952    let pole_host = |index: usize| -> usize {
953        let (start, reverse) = (index % n, index >= n);
954        if reverse {
955            (start + 2 * n - 1) % n
956        } else {
957            start
958        }
959    };
960    let clean = |index: usize| -> bool {
961        let h = pole_host(index);
962        !is_ridge[h] && !is_ridge[(h + 1) % n]
963    };
964    let mut order: Vec<usize> = (0..2 * n).collect();
965    order.sort_by_key(|&index| !clean(index));
966    frames.extend(
967        order
968            .iter()
969            .map(|&index| (hosts[pole_host(index)], directions[index % n])),
970    );
971    for (index, (pole, seam)) in frames.into_iter().enumerate() {
972        model.begin_operation();
973        let attempt = (|| -> OgeomResult<Built> {
974            let block = convex_block(model, walls, tol)?;
975            let block_bound = ogeom_algo::shape_bounds(model, &block, tol)?;
976            let ball_frame = Frame::new(
977                far,
978                Direction::new(pole, tol)?,
979                Direction::new(seam, tol)?,
980                tol,
981            )?;
982            let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
983            let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
984            let tool_bound = ogeom_algo::shape_bounds(model, &tool.shape, tol)?;
985            let reach = tol.confusion() * 1e3;
986            let (Some(block_lo), Some(block_hi), Some(tool_lo), Some(tool_hi)) = (
987                block_bound.low(),
988                block_bound.high(),
989                tool_bound.low(),
990                tool_bound.high(),
991            ) else {
992                ogeom_bail!(NotDone, "the ball's cut left no tool");
993            };
994            if tool_lo.x < block_lo.x - reach
995                || tool_lo.y < block_lo.y - reach
996                || tool_lo.z < block_lo.z - reach
997                || tool_hi.x > block_hi.x + reach
998                || tool_hi.y > block_hi.y + reach
999                || tool_hi.z > block_hi.z + reach
1000            {
1001                ogeom_bail!(
1002                    NotDone,
1003                    "the ball's cut left a tool reaching past its own block"
1004                );
1005            }
1006            Ok(tool)
1007        })();
1008        match attempt {
1009            Ok(tool) => {
1010                if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1011                    eprintln!("BALL frame {index} closed");
1012                }
1013                return Ok(tool);
1014            }
1015            Err(err) => {
1016                if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1017                    eprintln!("BALL frame {index} pole {pole:?} seam {seam:?}: {err}");
1018                }
1019                last = Some(err);
1020            }
1021        }
1022    }
1023    ogeom_bail!(
1024        NotDone,
1025        "the corner tool's block closed on none of its {} frames; the last said: {}",
1026        2 * n,
1027        last.map_or_else(String::new, |e| e.to_string())
1028    )
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    #![allow(clippy::unwrap_used, reason = "test code")]
1034    use super::round_vertex_with;
1035    use ogeom_core::Tolerances;
1036    use ogeom_math::{Point, Vector};
1037    use ogeom_topo::{Model, Shape, ShapeType};
1038
1039    const T: Tolerances = Tolerances::millimetres();
1040
1041    fn vertex_at(model: &Model, shape: &Shape, at: Point) -> Shape {
1042        ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)
1043            .unwrap()
1044            .into_iter()
1045            .find(|v| {
1046                model
1047                    .node(v)
1048                    .and_then(|n| n.data().as_vertex())
1049                    .is_some_and(|d| d.point.distance(at) < 1e-9)
1050            })
1051            .unwrap()
1052    }
1053
1054    fn volume(model: &Model, shape: &Shape) -> f64 {
1055        ogeom_algo::volume_properties(model, shape, ogeom_mesh::Deflection::default(), T)
1056            .unwrap()
1057            .mass
1058    }
1059
1060    /// Every labelling of a corner closes in the cut and builds one solid:
1061    /// the charts differ, the construction does not.
1062    fn every_labelling_agrees(model: &mut Model, solid: &Shape, vertex: &Shape, count: usize) {
1063        let mut volumes = Vec::with_capacity(count);
1064        for index in 0..count {
1065            let rounded = round_vertex_with(model, solid, vertex, 2.0, Some(index), T)
1066                .unwrap_or_else(|e| panic!("labelling {index} did not close: {e}"));
1067            let diagnosis = ogeom_algo::check(model, &rounded.shape, T).unwrap();
1068            assert!(
1069                diagnosis.is_valid(),
1070                "labelling {index}: {:?}",
1071                diagnosis.problems
1072            );
1073            volumes.push(volume(model, &rounded.shape));
1074        }
1075        // A labelling decides where a rim is exact and where fitted, and a
1076        // rim fitted to a tenth of a micron moves a few square millimetres
1077        // of patch by a few millionths of a cubic millimetre: one part in
1078        // ten million of these solids covers it.
1079        for (index, v) in volumes.iter().enumerate() {
1080            assert!(
1081                (v - volumes[0]).abs() < 1e-7 * volumes[0],
1082                "labelling {index} builds {v}, labelling 0 {}",
1083                volumes[0]
1084            );
1085        }
1086    }
1087
1088    #[test]
1089    fn an_oblique_corner_rounds_the_same_under_every_labelling() {
1090        let mut model = Model::new();
1091        let (a, b, c) = (
1092            Vector::new(20.0, 0.0, 0.0),
1093            Vector::new(6.0, 20.0, 0.0),
1094            Vector::new(3.6, 6.0, 20.0),
1095        );
1096        let block = ogeom_algo::make_parallelepiped(&mut model, Point::ORIGIN, [a, b, c], T)
1097            .unwrap()
1098            .shape;
1099        let vertex = vertex_at(&model, &block, Point::ORIGIN);
1100        every_labelling_agrees(&mut model, &block, &vertex, 6);
1101    }
1102
1103    #[test]
1104    fn a_square_pyramid_apex_rounds_the_same_under_every_labelling() {
1105        let mut model = Model::new();
1106        let apex = Point::new(0.0, 0.0, 15.0);
1107        let base = [
1108            Point::new(-10.0, -10.0, 0.0),
1109            Point::new(10.0, -10.0, 0.0),
1110            Point::new(10.0, 10.0, 0.0),
1111            Point::new(-10.0, 10.0, 0.0),
1112        ];
1113        // Four slopes and the base, each wound about its outward normal.
1114        let mut faces = Vec::new();
1115        for k in 0..4 {
1116            let (p, q) = (base[k], base[(k + 1) % 4]);
1117            let outward = (q - p).cross(apex - p);
1118            faces.push(crate::support::planar_face(&mut model, &[p, q, apex], outward, T).unwrap());
1119        }
1120        let reversed: Vec<Point> = base.iter().rev().copied().collect();
1121        faces.push(crate::support::planar_face(&mut model, &reversed, -Vector::Z, T).unwrap());
1122        let sewn = ogeom_algo::sew(&mut model, &faces, T).unwrap();
1123        let pyramid = ogeom_algo::make_solid(&mut model, &sewn.shells[..1])
1124            .unwrap()
1125            .shape;
1126        let vertex = vertex_at(&model, &pyramid, apex);
1127        every_labelling_agrees(&mut model, &pyramid, &vertex, 8);
1128    }
1129}