Skip to main content

ogeom_fillet/
fillet.rs

1//! Constant-radius fillets, where the rolling ball's envelope is closed-form.
2//!
3//! A ball of constant radius rolling along a straight edge between two planar
4//! faces stays tangent to both, and its envelope is exactly a cylinder; the
5//! same ball rolling around the rim where a cylindrical wall meets its
6//! perpendicular cap traces a torus. These are the blends whose *new* surface
7//! costs nothing, and both are built the same way: the chamfer's wedge with
8//! the bevel exchanged for the envelope (legs running along the faces to the
9//! tangency lines, and the envelope between them, every pcurve exact). The
10//! boolean then does what it did for the chamfer: the legs melt into the
11//! solid's own faces by same-domain resolution, and the envelope stays as
12//! the blend.
13
14use crate::support::{
15    Seat, apply_wedge, edge_curve, face_from_edges, planar_face, planar_seat, revolved_flanks,
16    revolved_seat, segment_between,
17};
18use ogeom_algo::{Built, make_edge_between, make_revolution_band, make_vertex};
19use ogeom_core::{OgeomError, OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::{
21    CircleCurve, Curve, CylinderSurface, PlaneSurface, SurfaceGeometry, TorusSurface,
22};
23use ogeom_math::{Circle, Cylinder, Direction, Frame, Plane, Point, Torus, Vector};
24use ogeom_topo::{Model, Shape};
25
26/// Round a convex edge of a solid with a constant-radius blend tangent to
27/// both of its faces.
28///
29/// Two seats are recognised, and each has a closed-form envelope: a straight
30/// edge between two planar faces, where the rolling ball traces a cylinder,
31/// and a circular rim where a cylindrical wall meets a perpendicular planar
32/// cap, where it traces a torus. In both, the result is the boolean
33/// difference with a wedge whose curved face is that envelope, so the history
34/// reads as a cut: the faces are modified into their trimmed pieces, and the
35/// edge's neighbourhood gains the blend face.
36///
37/// # Errors
38///
39/// [`OgeomError::Construction`] if the edge is
40/// neither of the seats above, is concave, or `radius` is not a usable
41/// length.
42pub fn fillet_edge(
43    model: &mut Model,
44    solid: &Shape,
45    edge: &Shape,
46    radius: f64,
47    tol: Tolerances,
48) -> OgeomResult<Built> {
49    fillet_edge_meeting(model, solid, edge, radius, None, tol)
50}
51
52/// An edge of a multi-edge request, by its ends: where each end stands and
53/// which way the edge leaves it, so a later edge can tell a chain mate from
54/// a corner mate at a shared vertex.
55#[derive(Debug, Clone, Copy)]
56pub(crate) struct Mate {
57    /// `(point, unit tangent leaving the point along the edge)` per end.
58    pub ends: [(Point, Vector); 2],
59    /// Per end, whether the corner tool has already closed the vertex there:
60    /// the band stops flush against the corner's patch rather than running
61    /// out through it.
62    pub settled: [bool; 2],
63}
64
65impl Mate {
66    /// Whether any mate's end at `at` is a vertex the corner tool closed,
67    /// or `at` is an end of the request's own edge `index` whose vertex the
68    /// corner tool closed.
69    ///
70    /// The second reading is the one a band asks of itself: a corner tool
71    /// trims the edge back to the plane square to it through the ball's
72    /// centre, so the edge's end now stands a setback from the vertex it
73    /// was named at, and the nearer of its original ends says which one
74    /// it is.
75    pub(crate) fn settled_at(mates: &[Self], index: usize, at: Point, tol: Tolerances) -> bool {
76        let other = mates.iter().any(|mate| {
77            mate.ends
78                .iter()
79                .zip(mate.settled)
80                .any(|((p, _), settled)| settled && p.distance(at) <= tol.confusion() * 1e3)
81        });
82        let own = mates.get(index).is_some_and(|mate| {
83            let nearer = usize::from(mate.ends[1].0.distance(at) < mate.ends[0].0.distance(at));
84            mate.settled[nearer]
85        });
86        other || own
87    }
88}
89
90/// [`fillet_edge`], with the straight seat told what else was asked for.
91///
92/// One edge at a time stops flush against whatever it ends on: that is the
93/// state the corner tool (`round_vertex`) is built for, three flush blends
94/// and a vertex. Edges asked for together meet: where one of them ends at
95/// a vertex another leaves *tangentially*, the two are a chain and their
96/// caps stand flush in one plane; where it ends against the band of one
97/// that left the vertex some other way, the later seat runs on through
98/// that band and the cut trims the two against each other: two fillets
99/// meeting at a corner.
100fn fillet_edge_meeting(
101    model: &mut Model,
102    solid: &Shape,
103    edge: &Shape,
104    radius: f64,
105    mates: Option<(usize, &[Mate])>,
106    tol: Tolerances,
107) -> OgeomResult<Built> {
108    if !radius.is_finite() || radius <= tol.confusion() {
109        ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
110    }
111    let (curve, crange) = edge_curve(model, edge, tol)?;
112    let closed = ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
113    match curve {
114        Curve::Line(_) if crate::support::hosts_planar(model, solid, edge, tol)? => {
115            planar_fillet(model, solid, edge, radius, mates, tol)
116        }
117        Curve::Line(_) => match crate::ruled::ruled_fillet(model, solid, edge, radius, mates, tol)?
118        {
119            Some(built) => Ok(built),
120            None => crate::marched::marched_fillet(model, solid, edge, radius, mates, tol),
121        },
122        Curve::Circle(c) => {
123            // A rim on a planar cap and its coaxial wall is the revolved
124            // blend's, exact; a circle on any other pair of hosts (a bore
125            // down a ball's axis, a ring's own rim) is a seat like any
126            // other for the march.
127            let seat = match revolved_seat(model, solid, edge, &c, tol) {
128                Ok(seat) => seat,
129                Err(OgeomError::Construction(_)) => {
130                    return crate::marched::marched_fillet(model, solid, edge, radius, mates, tol);
131                }
132                Err(e) => return Err(e),
133            };
134            if closed {
135                return revolved_fillet(model, solid, edge, &c, radius, tol);
136            }
137            // An open arc is two different seats: a piece a boolean split
138            // off a full rim, whose blend must run the whole turn, or a rim
139            // that genuinely stops: a stadium's rounded end. The wall
140            // itself answers: probe it just below the arc's complement.
141            let frame = seat.frame_at(seat.centre, tol)?;
142            let angle_of = |t: f64| -> OgeomResult<f64> {
143                use ogeom_geom::Curve3d as _;
144                let local = frame.to_local(c.point_at(t, tol)?);
145                Ok(local.y.atan2(local.x))
146            };
147            let mid = f64::midpoint(
148                angle_of(crange.0)?,
149                angle_of(crange.0)?
150                    + (angle_of(crange.1)? - angle_of(crange.0)?)
151                        .rem_euclid(core::f64::consts::TAU),
152            );
153            let complement = mid + core::f64::consts::PI;
154            let dir = frame.x().vector() * complement.cos() + frame.y().vector() * complement.sin();
155            let eps = seat.radius * 1e-2;
156            let probe = seat.centre + dir * seat.radius - seat.up * (seat.tau * eps);
157            let deflection = ogeom_mesh::Deflection {
158                chord: eps * 0.1,
159                ..ogeom_mesh::Deflection::default()
160            };
161            let whole_turn =
162                ogeom_algo::classify_on_face(model, &seat.wall_face, probe, deflection, tol)?
163                    == ogeom_algo::Containment::In;
164            if whole_turn {
165                revolved_fillet(model, solid, edge, &c, radius, tol)
166            } else {
167                revolved_arc_fillet(model, solid, edge, &c, radius, tol)
168            }
169        }
170        _ => crate::marched::marched_fillet(model, solid, edge, radius, mates, tol),
171    }
172}
173
174/// Round a chain of edges in one call, each blend applied in turn and every
175/// later edge resolved through the history the earlier blends wrote.
176///
177/// At a tangent junction the neighbouring wedges' end caps stand in one
178/// plane with one cross-section, and the boolean's same-domain resolution
179/// melts them: the blends join without a seam face between them. Where
180/// two straight edges of the chain meet at a corner, the later seat runs
181/// on through the earlier blend's band and the cut trims the two bands
182/// against each other along their own intersection: two fillets meeting
183/// at a corner, the way one edge at a time ([`fillet_edge`]) deliberately
184/// does not, since the flush-ended state is what the corner tool
185/// ([`round_vertex`](crate::round_vertex)) is built for. Where three or
186/// more edges of the chain meet at one vertex, the corner tool closes it
187/// with the rolling ball's own patch (the octant of a sphere at a box
188/// corner, the envelope of spheres and cylinders at a vertex no single
189/// ball touches) rather than leaving the bands' caps standing. The corner
190/// goes first and the bands stop flush against its patch: bands built
191/// first crash into each other at an apex. A corner the tool does not
192/// speak (a concave vertex, more than three surfaces with no shared
193/// ball when one of them is curved) keeps its caps,
194/// which is the honest picture of a corner no ball rolls around. Other
195/// junctions leave the wedges' caps standing likewise.
196///
197/// Only two blends that round the same way trim each other. A wedge's cut
198/// would eat a fill, and a fill cannot run on through a wedge's band, so
199/// where a convex edge meets a concave one (an L-bracket's front edge at
200/// its re-entrant edge) whichever is asked first takes the corner and the
201/// other stops flush against its rail. The two orders then land on
202/// different solids, each exact, because the corner is genuinely one
203/// rounding or the other and no ball rolls round both.
204///
205/// # Errors
206///
207/// As [`fillet_edge`] per edge, and additionally if an earlier blend
208/// consumed or split a later edge: a chain whose members interfere is
209/// refused rather than guessed at.
210pub fn fillet_edges(
211    model: &mut Model,
212    solid: &Shape,
213    edges: &[Shape],
214    radius: f64,
215    tol: Tolerances,
216) -> OgeomResult<Built> {
217    if edges.is_empty() {
218        ogeom_bail!(Construction, "a chain of no edges rounds nothing");
219    }
220    // The ball touches each face a setback back from the edge; set back
221    // past a face's far side, the band would cut through the face.
222    for edge in edges {
223        crate::support::ball_fits(model, solid, edge, radius, tol)?;
224    }
225    use ogeom_geom::Curve3d as _;
226    // The vertices three or more of the chain's edges meet at: the corners
227    // the ball rolls round, closed by the corner tool.
228    let mut corners: Vec<(Shape, usize)> = Vec::new();
229    for edge in edges {
230        let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? else {
231            continue;
232        };
233        for v in [a, b] {
234            if let Some(slot) = corners.iter_mut().find(|(held, _)| held.is_same(&v)) {
235                slot.1 += 1;
236            } else {
237                corners.push((v, 1));
238            }
239        }
240    }
241    corners.retain(|(_, count)| *count >= 3);
242    // A corner the tool rounds, or the solid unchanged where it does not
243    // speak the corner, which keeps the bands' caps, as before.
244    let round = |model: &mut Model, built: Built, vertex: &Shape| -> OgeomResult<Built> {
245        match crate::corner::round_vertex(model, &built.shape, vertex, radius, tol) {
246            Ok(rounded) => Ok(Built {
247                shape: rounded.shape,
248                history: built.history.then(&rounded.history),
249            }),
250            Err(OgeomError::Construction(_)) => Ok(built),
251            Err(other) => Err(other),
252        }
253    };
254    // The corner first, the bands after it: bands built first crash into
255    // each other at an apex, and the corner tool wants the sharp vertex's
256    // planes, not the bands' mitre.
257    let mut built: Option<Built> = None;
258    let mut rounded: Vec<Point> = Vec::new();
259    for (vertex, _) in &corners {
260        let start = built
261            .take()
262            .unwrap_or_else(|| Built::from_nothing(solid.clone()));
263        let before = start.shape.clone();
264        let after = round(model, start, vertex)?;
265        if !after.shape.is_same(&before)
266            && let Some(point) = model
267                .node(vertex)
268                .and_then(|n| n.data().as_vertex())
269                .map(|d| d.point)
270        {
271            rounded.push(vertex.transform(model.datums())?.apply(point));
272        }
273        built = Some(after);
274    }
275    let mates: Vec<Mate> = edges
276        .iter()
277        .map(|e| -> OgeomResult<Mate> {
278            let (curve, range) = edge_curve(model, e, tol)?;
279            let end_of = |t: f64, leaving: f64| -> OgeomResult<(Point, Vector)> {
280                let d = curve.d1_at(t, tol)?;
281                let m = d.magnitude();
282                let unit = if m > tol.confusion() {
283                    d / m
284                } else {
285                    Vector::ZERO
286                };
287                Ok((curve.point_at(t, tol)?, unit * leaving))
288            };
289            let ends = [end_of(range.0, 1.0)?, end_of(range.1, -1.0)?];
290            let settled = ends.map(|(p, _)| {
291                rounded
292                    .iter()
293                    .any(|q| q.distance(p) <= tol.confusion() * 1e3)
294            });
295            Ok(Mate { ends, settled })
296        })
297        .collect::<OgeomResult<_>>()?;
298    for (index, edge) in edges.iter().enumerate() {
299        // The edge as it stands on the current solid: itself on the first
300        // step, and afterwards whatever the earlier blends left of it: one
301        // re-found stand-in, or the pieces a blend running out across it
302        // split it into, each of which is a seat of its own ending against
303        // that blend's band, which is exactly the corner it then meets.
304        let (current, targets): (Shape, Vec<Shape>) = match &built {
305            None => (solid.clone(), vec![edge.clone()]),
306            Some(b) => {
307                let traced = b.history.trace(edge);
308                if traced.is_empty() {
309                    ogeom_bail!(
310                        Construction,
311                        "an earlier blend in the chain consumed this edge; \
312                         the chain's members interfere"
313                    );
314                }
315                let mut found = Vec::with_capacity(traced.len());
316                for one in traced {
317                    found.extend(refind_edges(model, &b.shape, one, tol)?);
318                }
319                (b.shape.clone(), found)
320            }
321        };
322        let mut current = current;
323        for target in &targets {
324            // Each piece's blend replaces the solid; the next piece is
325            // re-found on what that blend left.
326            let live = refind_edges(model, &current, target, tol)?;
327            let [target] = live.as_slice() else {
328                ogeom_bail!(
329                    Construction,
330                    "a blend in the chain split a piece of a later edge again; \
331                     the chain's members interfere"
332                );
333            };
334            let mut step =
335                fillet_edge_meeting(model, &current, target, radius, Some((index, &mates)), tol)?;
336            // The blend consumed the re-found stand-in; the caller's edge is
337            // the same fact under its original name.
338            if !target.is_same(edge) {
339                step.history.delete(edge);
340            }
341            current = step.shape.clone();
342            built = Some(match built {
343                None => step,
344                Some(prev) => Built {
345                    shape: step.shape.clone(),
346                    history: prev.history.then(&step.history),
347                },
348            });
349        }
350    }
351    Ok(built.unwrap_or_else(|| unreachable!()))
352}
353
354/// Round a straight convex edge with a blend whose radius runs linearly from
355/// `start_radius` at the edge's start to `end_radius` at its end.
356///
357/// For a linear law on a straight edge between planes the rolling ball's
358/// envelope is *exactly* a rational B-spline surface (degree one along the
359/// edge, a rational quadratic arc across it, the control net affine in the
360/// radius), so nothing here is fitted. The tangency lines are straight, the
361/// legs stay planar, and the wedge subtracts through the boolean like its
362/// constant-radius siblings.
363///
364/// # Errors
365///
366/// As [`fillet_edge`], for the straight planar seat only.
367pub fn fillet_edge_variable(
368    model: &mut Model,
369    solid: &Shape,
370    edge: &Shape,
371    start_radius: f64,
372    end_radius: f64,
373    tol: Tolerances,
374) -> OgeomResult<Built> {
375    for radius in [start_radius, end_radius] {
376        if !radius.is_finite() || radius <= tol.confusion() {
377            ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
378        }
379    }
380    if (start_radius - end_radius).abs() <= tol.confusion() {
381        return fillet_edge(model, solid, edge, start_radius, tol);
382    }
383    let seat = planar_seat(model, solid, edge, tol)?;
384    let (first, second) = if seat
385        .along
386        .dot(seat.normals[0].cross(seat.normals[1]))
387        .is_sign_positive()
388    {
389        (0, 1)
390    } else {
391        (1, 0)
392    };
393    let sign = if seat.convex { 1.0 } else { -1.0 };
394    let n1 = seat.normals[first] * sign;
395    let n2 = seat.normals[second] * sign;
396    let a = seat.leg(first, tol)? * sign;
397    let b = seat.leg(second, tol)? * sign;
398    let bisector = {
399        let u = a + b;
400        let m = u.magnitude();
401        if m <= tol.angular() {
402            ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
403        }
404        u / m
405    };
406    let depth = -n1.dot(bisector);
407    if depth <= tol.angular() {
408        ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
409    }
410    let sweep = seat.along.dot(n1.cross(n2)).atan2(n1.dot(n2)) * sign;
411    if sweep <= tol.angular() {
412        ogeom_bail!(Construction, "the faces are parallel; there is no corner");
413    }
414
415    // The sections at the two ends: everything else is affine between them.
416    // On a concave edge every direction above is already mirrored: the
417    // arithmetic below cannot tell which case it serves.
418    let radii = [start_radius, end_radius];
419    let apex = [seat.start, seat.end];
420    let centre = [
421        apex[0] + bisector * (radii[0] / depth),
422        apex[1] + bisector * (radii[1] / depth),
423    ];
424    let contact_a = [centre[0] + n1 * radii[0], centre[1] + n1 * radii[1]];
425    let contact_b = [centre[0] + n2 * radii[0], centre[1] + n2 * radii[1]];
426
427    // The unit arc from n1 to n2 as a rational quadratic: the middle control
428    // point is where the end tangents meet, its weight the half-angle cosine.
429    let half_cos = (sweep / 2.0).cos();
430    let arc_mid = (n1 + n2) / (1.0 + n1.dot(n2));
431    let weights = [1.0, half_cos, 1.0];
432
433    // The blend: degree one by rational quadratic, the control net
434    // c_i + r_i * V_j, weights carried across the rows unchanged. Every
435    // section of the surface is that section's exact blend arc.
436    let u_knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
437    let v_knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
438    let mut net: Vec<ogeom_math::Weighted<Point>> = Vec::with_capacity(6);
439    for i in 0..2 {
440        let arms = [n1, arc_mid, n2];
441        for (j, arm) in arms.iter().enumerate() {
442            net.push(ogeom_math::Weighted::new(
443                centre[i] + *arm * radii[i],
444                weights[j],
445                tol,
446            )?);
447        }
448    }
449    let grid = ogeom_math::ControlGrid::new(net, 2, 3)?;
450    let surface: SurfaceGeometry =
451        ogeom_geom::BSplineSurface::rational(u_knots, v_knots, grid)?.into();
452    let surface_id = model.geometry_mut().add_surface(surface.clone());
453
454    // Shared vertices at the four tangency corners.
455    let va = [
456        make_vertex(model, contact_a[0]).shape,
457        make_vertex(model, contact_a[1]).shape,
458    ];
459    let vb = [
460        make_vertex(model, contact_b[0]).shape,
461        make_vertex(model, contact_b[1]).shape,
462    ];
463
464    // The tangency lines, straight because the law is linear, with
465    // degree-one pcurves mapping their arc length onto the chart's rows.
466    let rail = |model: &mut Model,
467                from: (&Shape, Point),
468                to: (&Shape, Point),
469                row: f64|
470     -> OgeomResult<Shape> {
471        let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
472        let curve = Curve::Line(line);
473        let domain = ogeom_geom::Curve3d::domain(&curve);
474        let built = make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape;
475        let knots = ogeom_math::KnotVector::new(vec![domain.0, domain.0, domain.1, domain.1], 1)?;
476        let pcurve = ogeom_geom::BSpline2d::new(
477            knots,
478            vec![
479                ogeom_math::Point2::new(0.0, row),
480                ogeom_math::Point2::new(1.0, row),
481            ],
482            tol,
483        )?;
484        ogeom_algo::attach_pcurve(
485            model,
486            &built,
487            pcurve.into(),
488            surface_id,
489            ogeom_topo::Location::identity(),
490            domain,
491        )?;
492        Ok(built)
493    };
494    let rail_a = rail(model, (&va[0], contact_a[0]), (&va[1], contact_a[1]), 0.0)?;
495    let rail_b = rail(model, (&vb[0], contact_b[0]), (&vb[1], contact_b[1]), 1.0)?;
496
497    // The end arcs as the surface's own rational sections, so the cap edges
498    // and the blend's chart speak the same parameter.
499    let arc_edge = |model: &mut Model, i: usize| -> OgeomResult<Shape> {
500        let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
501        let control = vec![
502            ogeom_math::Weighted::new(contact_a[i], 1.0, tol)?,
503            ogeom_math::Weighted::new(centre[i] + arc_mid * radii[i], half_cos, tol)?,
504            ogeom_math::Weighted::new(contact_b[i], 1.0, tol)?,
505        ];
506        let curve = Curve::BSpline(ogeom_geom::BSplineCurve::rational(knots, control)?);
507        let built = make_edge_between(model, curve, (0.0, 1.0), &va[i], &vb[i], tol)?.shape;
508        #[allow(clippy::cast_precision_loss)]
509        let column = ogeom_geom::Line2d::over(
510            ogeom_math::Axis2::new(
511                ogeom_math::Point2::new(i as f64, 0.0),
512                ogeom_math::Direction2::Y,
513            ),
514            0.0,
515            1.0,
516        )?;
517        ogeom_algo::attach_pcurve(
518            model,
519            &built,
520            column.into(),
521            surface_id,
522            ogeom_topo::Location::identity(),
523            (0.0, 1.0),
524        )?;
525        Ok(built)
526    };
527    let arc0 = arc_edge(model, 0)?;
528    let arc1 = arc_edge(model, 1)?;
529
530    // The blend face on the registered surface, oriented so its outward side
531    // leaves the wedge, decided by measurement at the middle rather than by
532    // convention.
533    let blend = {
534        let wire = ogeom_algo::make_wire(
535            model,
536            &[
537                rail_a.clone(),
538                arc1.clone(),
539                rail_b.reversed(),
540                arc0.reversed(),
541            ],
542            tol,
543        )?
544        .shape;
545        let face =
546            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
547        use ogeom_geom::Surface as _;
548        let s_mid = surface.point_at(0.5, 0.5, tol)?;
549        let (du, dv) = surface.d1_at(0.5, 0.5, tol)?;
550        let apex_mid = apex[0].midpoint(apex[1]);
551        let outward = s_mid - apex_mid.midpoint(s_mid);
552        if du.cross(dv).dot(outward) >= 0.0 {
553            face
554        } else {
555            face.reversed()
556        }
557    };
558
559    // Legs along the two faces, and caps closed by the end arcs. The caps go
560    // through the generic builder: the rational arc projects into a plane
561    // exactly, control point by control point.
562    let leg_a = planar_face(
563        model,
564        &[apex[0], contact_a[0], contact_a[1], apex[1]],
565        n1 * sign,
566        tol,
567    )?;
568    let leg_b = planar_face(
569        model,
570        &[apex[0], contact_b[0], contact_b[1], apex[1]],
571        n2 * sign,
572        tol,
573    )?;
574    let cap = |model: &mut Model, i: usize, outward: Vector| -> OgeomResult<Built> {
575        let plane = Plane::through(apex[i], Direction::new(outward, tol)?);
576        let reach = (apex[i].distance(contact_a[i]) + apex[i].distance(contact_b[i]) + 1.0) * 2.0;
577        let cap_surface: SurfaceGeometry =
578            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
579        let apex_v = make_vertex(model, apex[i]).shape;
580        let edges = vec![
581            crate::support::segment_between(
582                model,
583                (&apex_v, apex[i]),
584                (&va[i], contact_a[i]),
585                tol,
586            )?,
587            if i == 0 { arc0.clone() } else { arc1.clone() },
588            crate::support::segment_between(
589                model,
590                (&vb[i], contact_b[i]),
591                (&apex_v, apex[i]),
592                tol,
593            )?,
594        ];
595        ogeom_algo::make_face_with_pcurves(model, cap_surface, &[edges], tol)
596    };
597    let cap0 = cap(model, 0, -seat.along)?.shape;
598    let cap1 = cap(model, 1, seat.along)?.shape;
599
600    let faces = [leg_a, leg_b, cap0, cap1, blend];
601    apply_wedge(model, solid, Some(edge), &faces, !seat.convex, tol)
602}
603
604/// The straight-edge fillet: the rolling ball's envelope is a cylinder.
605fn planar_fillet(
606    model: &mut Model,
607    solid: &Shape,
608    edge: &Shape,
609    radius: f64,
610    mates: Option<(usize, &[Mate])>,
611    tol: Tolerances,
612) -> OgeomResult<Built> {
613    let mut seat = planar_seat(model, solid, edge, tol)?;
614    // A straight seat asked to meet runs out. Where the edge ends against
615    // a neighbouring blend (a band tangent to one of the hosts at the end
616    // vertex), the material past the end is that blend's own rounding, and
617    // the seat carries on through it until the ball has left the solid; the
618    // cut then trims the two bands against each other along their own
619    // intersection, which is what two fillets meeting at a corner are. At
620    // a plain wall the cap already stands in the wall's own plane and
621    // stays; at a step the seat stops where the edge does.
622    for end in [true, false] {
623        let Some((index, mates)) = mates else {
624            break;
625        };
626        let at = if end { seat.start } else { seat.end };
627        let outward = if end { -seat.along } else { seat.along };
628        // A mate leaving this vertex the way this edge arrives is a chain
629        // mate: the caps stand flush and the melt joins the blends.
630        let chain = mates.iter().enumerate().any(|(i, mate)| {
631            i != index
632                && mate.ends.iter().any(|(p, leaving)| {
633                    p.distance(at) <= tol.confusion() * 1e3
634                        && leaving.cross(outward).magnitude() <= 1e-2
635                        && leaving.dot(outward) > 0.0
636                })
637        });
638        if chain {
639            continue;
640        }
641        if Mate::settled_at(mates, index, at, tol) {
642            continue;
643        }
644        let hosts = [&seat.faces[0], &seat.faces[1]];
645        if !crate::marched::crease_terminates_at(model, solid, edge, hosts, at, tol)?
646            || !crate::marched::neighbour_blend_at(
647                model,
648                solid,
649                hosts,
650                at,
651                seat.convex,
652                radius,
653                tol,
654            )?
655        {
656            continue;
657        }
658        // The ball's centre line, a radius in from both hosts.
659        let a = seat.leg(0, tol)? * if seat.convex { 1.0 } else { -1.0 };
660        let b = seat.leg(1, tol)? * if seat.convex { 1.0 } else { -1.0 };
661        let bisector = {
662            let u = a + b;
663            let m = u.magnitude();
664            if m <= tol.angular() {
665                continue;
666            }
667            u / m
668        };
669        let depth = seat.normals[0].dot(bisector).abs();
670        if depth <= tol.angular() {
671            continue;
672        }
673        let centre_at = |s: f64| at + outward * s + bisector * (radius / depth);
674        let deflection = ogeom_mesh::Deflection {
675            chord: (radius * 1e-2).max(tol.confusion() * 1e3),
676            ..ogeom_mesh::Deflection::default()
677        };
678        let step = radius / 8.0;
679        let mut reach: Option<f64> = None;
680        for k in 1..=32 {
681            let s = step * f64::from(k);
682            let inside =
683                ogeom_algo::classify_in_solid(model, solid, centre_at(s), deflection, tol)?
684                    == ogeom_algo::Containment::In;
685            if inside != seat.convex {
686                reach = Some(s + radius * 0.25);
687                break;
688            }
689        }
690        if std::env::var_os("OGEOM_DEBUG_RUNOUT").is_some() {
691            eprintln!("RUNOUT straight seat end {end}: reach {reach:?}");
692        }
693        let Some(reach) = reach else {
694            continue;
695        };
696        if end {
697            seat.start -= seat.along * reach;
698        } else {
699            seat.end += seat.along * reach;
700        }
701    }
702    seated_fillet(model, solid, &seat, radius, Some(edge), tol)
703}
704
705/// The cylindrical blend of a seat, whatever found the seat.
706///
707/// A blend's construction cares about the seat (where the ball rolls and
708/// which way the two planes face) and not at all about whether an edge of
709/// the solid runs along it. An edge gives one; two faces that share nothing
710/// give the same one through their planes' own intersection, and everything
711/// from here down is common to both.
712pub(crate) fn seated_fillet(
713    model: &mut Model,
714    solid: &Shape,
715    seat: &Seat,
716    radius: f64,
717    edge: Option<&Shape>,
718    tol: Tolerances,
719) -> OgeomResult<Built> {
720    // Order the two faces so the blend arc sweeps positively about the edge
721    // direction: the cylinder's parameterization and every arc below then
722    // run from the first face's tangency line to the second's.
723    let (first, second) = if seat
724        .along
725        .dot(seat.normals[0].cross(seat.normals[1]))
726        .is_sign_positive()
727    {
728        (0, 1)
729    } else {
730        (1, 0)
731    };
732    let n1 = seat.normals[first];
733    let n2 = seat.normals[second];
734    // On a concave edge the legs mirror into the open dihedral, the ball
735    // rolls there instead of in the material, and the contacts sit on the
736    // faces' other side of the centre.
737    let sign = if seat.convex { 1.0 } else { -1.0 };
738    let a = seat.leg(first, tol)? * sign;
739    let b = seat.leg(second, tol)? * sign;
740
741    // The rolling ball's centre line: along the bisector, at the distance
742    // where the ball touches both planes.
743    let bisector = {
744        let u = a + b;
745        let m = u.magnitude();
746        if m <= tol.angular() {
747            ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
748        }
749        u / m
750    };
751    let depth = (n1.dot(bisector)).abs();
752    if depth <= tol.angular() {
753        ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
754    }
755    let centre = seat.start + bisector * (radius / depth);
756    let sweep = seat.along.dot(n1.cross(n2)).atan2(n1.dot(n2));
757    if sweep <= tol.angular() {
758        ogeom_bail!(Construction, "the faces are parallel; there is no corner");
759    }
760
761    let travel = seat.end - seat.start;
762    let length = travel.magnitude();
763    let apex0 = seat.start;
764    let apex1 = seat.end;
765    let contact_a0 = centre + n1 * (radius * sign);
766    let contact_b0 = centre + n2 * (radius * sign);
767    let contact_a1 = contact_a0 + travel;
768    let contact_b1 = contact_b0 + travel;
769
770    let along_dir = Direction::new(seat.along, tol)?;
771    let radial_dir = Direction::new(n1 * sign, tol)?;
772
773    // An arc of the blend circle at height `h` along the edge, running from
774    // the first face's contact vertex to the second's. Its frame is the
775    // cylinder's frame translated up the axis, which is what makes its pcurve
776    // on the cylinder a straight line in `(u, v)` with the arc's own
777    // parameter.
778    let arc = |model: &mut Model, h: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
779        let frame = Frame::new(centre + seat.along * h, along_dir, radial_dir, tol)?;
780        let circle = Circle::new(frame, radius, tol)?;
781        let curve = Curve::Circle(CircleCurve::new(circle));
782        Ok(make_edge_between(model, curve, (0.0, sweep), from, to, tol)?.shape)
783    };
784
785    // The legs, coplanar with the solid's own faces: aligned with them on a
786    // convex edge, where the melt keeps one copy, opposed on a concave one,
787    // where the fuse cancels both.
788    let leg_a = planar_face(
789        model,
790        &[apex0, contact_a0, contact_a1, apex1],
791        n1 * sign,
792        tol,
793    )?;
794    let leg_b = planar_face(
795        model,
796        &[apex0, contact_b0, contact_b1, apex1],
797        n2 * sign,
798        tol,
799    )?;
800
801    // The caps: two segments to the tangency points, closed by the arc.
802    let cap = |model: &mut Model,
803               apex: Point,
804               ca: Point,
805               cb: Point,
806               h: f64,
807               outward: Vector|
808     -> OgeomResult<Shape> {
809        let plane = Plane::through(apex, Direction::new(outward, tol)?);
810        let reach = (apex.distance(ca).max(apex.distance(cb)) + radius).max(1.0) * 2.0;
811        let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
812        let apex_v = make_vertex(model, apex).shape;
813        let ca_v = make_vertex(model, ca).shape;
814        let cb_v = make_vertex(model, cb).shape;
815        let edges = vec![
816            segment_between(model, (&apex_v, apex), (&ca_v, ca), tol)?,
817            arc(model, h, &ca_v, &cb_v)?,
818            segment_between(model, (&cb_v, cb), (&apex_v, apex), tol)?,
819        ];
820        face_from_edges(model, surface.into(), &edges, tol)
821    };
822    let cap0 = cap(model, apex0, contact_a0, contact_b0, 0.0, -seat.along)?;
823    let cap1 = cap(model, apex1, contact_a1, contact_b1, length, seat.along)?;
824
825    // The blend face itself. A cylinder's natural normal points away from its
826    // axis (into this wedge, whose material lies between the cylinder and
827    // the apex), so the face enters the shell reversed.
828    let blend = {
829        let frame = Frame::new(centre, along_dir, radial_dir, tol)?;
830        let surface = CylinderSurface::new(Cylinder::new(frame, radius, tol)?, (0.0, length))?;
831        let a0_v = make_vertex(model, contact_a0).shape;
832        let b0_v = make_vertex(model, contact_b0).shape;
833        let a1_v = make_vertex(model, contact_a1).shape;
834        let b1_v = make_vertex(model, contact_b1).shape;
835        let edges = vec![
836            arc(model, 0.0, &a0_v, &b0_v)?,
837            segment_between(model, (&b0_v, contact_b0), (&b1_v, contact_b1), tol)?,
838            arc(model, length, &a1_v, &b1_v)?.reversed(),
839            segment_between(model, (&a0_v, contact_a0), (&a1_v, contact_a1), tol)?.reversed(),
840        ];
841        face_from_edges(model, surface.into(), &edges, tol)?
842    };
843
844    let faces = [leg_a, leg_b, cap0, cap1, blend.reversed()];
845    apply_wedge(model, solid, edge, &faces, !seat.convex, tol)
846}
847
848/// The circular-rim fillet: a ball rolling around a rim where a cylindrical
849/// wall meets a perpendicular planar cap traces a torus.
850///
851/// Four seats, one parameterization. With `sigma` the wall's outward radial
852/// sign and `tau` telling whether the wall extends away from the cap's
853/// outward side, the tube's centre circle sits at radius `R − sigma tau r`,
854/// lifted `tau r` against the cap's normal, and `tau` alone decides whether
855/// the wedge subtracts (the external rim and the hole's rim, both convex) or
856/// fuses (the boss base and the blind hole's floor, both concave). The wedge
857/// is always the same three revolved faces: a band of the wall, an annulus
858/// of the cap, and the quarter-tube between them.
859fn revolved_fillet(
860    model: &mut Model,
861    solid: &Shape,
862    edge: &Shape,
863    rim: &CircleCurve,
864    radius: f64,
865    tol: Tolerances,
866) -> OgeomResult<Built> {
867    let seat = revolved_seat(model, solid, edge, rim, tol)?;
868
869    let tube_rho = seat.sigma.mul_add(-(seat.tau * radius), seat.radius);
870    if tube_rho <= tol.confusion() {
871        ogeom_bail!(
872            Construction,
873            "a fillet of radius {radius} swallows the axis of a rim of \
874             radius {}",
875            seat.radius
876        );
877    }
878    let tube_level = seat.centre - seat.up * (seat.tau * radius);
879    let flanks = revolved_flanks(model, &seat, radius, tube_rho, tol)?;
880
881    // The blend: the quarter-tube between the tangencies, its natural normal
882    // away from the tube's centre (into the wedge), so always reversed.
883    let blend_band = {
884        let surface: SurfaceGeometry = TorusSurface::new(Torus::new(
885            seat.frame_at(tube_level, tol)?,
886            tube_rho,
887            radius,
888            tol,
889        )?)
890        .into();
891        make_revolution_band(model, &surface, &flanks.wall_ring, &flanks.cap_ring, tol)?.reversed()
892    };
893
894    let faces = [flanks.wall_band, flanks.annulus, blend_band];
895    apply_wedge(model, solid, Some(edge), &faces, seat.additive(), tol)
896}
897
898/// The edge of `solid` standing where `edge` stood.
899///
900/// A boolean rebuilds every face it splits with fresh edges and its history
901/// speaks of faces, not of them, so a chain's later edge is re-found by
902/// geometry: the solid's edges whose own samples all lie on the sought
903/// edge's curve. A trimmed survivor qualifies, and so does each piece an
904/// earlier blend running out across the edge left of it; every one is a
905/// part of the edge still there to blend.
906///
907/// A corner, though, and not merely a line. An earlier blend's end cap can
908/// stand in one of the hosts' own planes and leave a fresh edge along the
909/// very line the sought one runs on (an L-bracket's front blend caps in
910/// the wall's plane, along the wall's own top line) and there the two
911/// faces are tangent: a seam across one flat, no corner to round, and no
912/// part of what was asked for.
913fn refind_edges(
914    model: &Model,
915    solid: &Shape,
916    edge: &Shape,
917    tol: Tolerances,
918) -> OgeomResult<Vec<Shape>> {
919    use ogeom_geom::Curve3d as _;
920    use ogeom_topo::ShapeType;
921    let (curve, range) = edge_curve(model, edge, tol)?;
922    // The edge's own stretch of its curve, for a candidate to lie within:
923    // the wall's bottom edge is two pieces either side of a scoop, on one
924    // line, and asking for the left one must not blend the right.
925    let within = |t: f64| -> bool {
926        let slack = (range.1 - range.0).abs() * 1e-3;
927        let t = if curve.is_periodic() {
928            let (lo, hi) = curve.domain();
929            let period = hi - lo;
930            if period > 0.0 {
931                range.0 + (t - range.0).rem_euclid(period)
932            } else {
933                t
934            }
935        } else {
936            t
937        };
938        t >= range.0.min(range.1) - slack && t <= range.0.max(range.1) + slack
939    };
940    let mut matches: Vec<Shape> = Vec::new();
941    for candidate in ogeom_topo::explore_unique(model, solid, ShapeType::Edge)? {
942        if candidate.is_same(edge) {
943            return Ok(vec![candidate]);
944        }
945        let Ok((c_curve, c_range)) = edge_curve(model, &candidate, tol) else {
946            continue;
947        };
948        let mut all_on = true;
949        for i in 0..3 {
950            let t = c_range.0 + (c_range.1 - c_range.0) * f64::from(i) / 2.0;
951            let p = c_curve.point_at(t, tol)?;
952            let projected = ogeom_algo::project_on_curve(&curve, p, 64, tol)?;
953            if projected.distance > tol.confusion() * 100.0
954                || (i == 1 && !within(projected.parameter))
955            {
956                all_on = false;
957                break;
958            }
959        }
960        if !all_on || matches.iter().any(|m| m.is_same(&candidate)) {
961            continue;
962        }
963        let mid = c_curve.point_at(f64::midpoint(c_range.0, c_range.1), tol)?;
964        let mut normals: Vec<Vector> = Vec::new();
965        for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
966            let touches = ogeom_topo::explore_unique(model, &face, ShapeType::Edge)?
967                .iter()
968                .any(|e| crate::support::same_occurrence(model, e, &candidate, tol));
969            if let (true, Some(normal)) = (
970                touches,
971                crate::marched::face_normal_near(model, &face, mid, tol)?,
972            ) {
973                normals.push(normal);
974            }
975        }
976        // Two faces that agree on their normal there are one smooth
977        // surface; anything else the seat machinery speaks to in its own
978        // words.
979        if matches!(normals.as_slice(), [first, second]
980            if first.cross(*second).magnitude() <= 1e-2)
981        {
982            continue;
983        }
984        matches.push(candidate);
985    }
986    if matches.is_empty() {
987        ogeom_bail!(
988            Construction,
989            "an earlier blend in the chain consumed this edge; the chain's \
990             members interfere"
991        );
992    }
993    Ok(matches)
994}
995
996/// The rim fillet over an arc of the rim rather than its whole turn.
997///
998/// The same four-seat torus geometry as [`revolved_fillet`], restricted to
999/// the arc's angular window and closed by two planar end caps in the
1000/// meridian half-planes at its ends. Every face is a chart rectangle or a
1001/// planar triangle, every edge an arc or a segment, every pcurve exact,
1002/// which is what lets a chain's neighbouring wedges melt at their shared
1003/// caps.
1004fn revolved_arc_fillet(
1005    model: &mut Model,
1006    solid: &Shape,
1007    edge: &Shape,
1008    rim: &CircleCurve,
1009    radius: f64,
1010    tol: Tolerances,
1011) -> OgeomResult<Built> {
1012    use ogeom_geom::Curve3d as _;
1013    let seat = revolved_seat(model, solid, edge, rim, tol)?;
1014
1015    let tube_rho = seat.sigma.mul_add(-(seat.tau * radius), seat.radius);
1016    if tube_rho <= tol.confusion() {
1017        ogeom_bail!(
1018            Construction,
1019            "a fillet of radius {radius} swallows the axis of a rim of \
1020             radius {}",
1021            seat.radius
1022        );
1023    }
1024    let tube_level = seat.centre - seat.up * (seat.tau * radius);
1025
1026    // The arc's window in the seat's own parameterization: the edge's stored
1027    // circle may be framed anyhow, so the ends are read back through the
1028    // seat frame and the window is the one that holds the arc's midpoint.
1029    let seat_frame = seat.frame_at(seat.centre, tol)?;
1030    let (curve, crange) = edge_curve(model, edge, tol)?;
1031    let angle_of = |t: f64| -> OgeomResult<f64> {
1032        let local = seat_frame.to_local(curve.point_at(t, tol)?);
1033        Ok(local.y.atan2(local.x))
1034    };
1035    let tau_turn = core::f64::consts::TAU;
1036    let raw0 = angle_of(crange.0)?;
1037    let raw1 = angle_of(crange.1)?;
1038    let mid = angle_of(f64::midpoint(crange.0, crange.1))?;
1039    let (theta0, theta1) = {
1040        let span = (raw1 - raw0).rem_euclid(tau_turn);
1041        let inside = (mid - raw0).rem_euclid(tau_turn) <= span;
1042        if inside {
1043            (raw0, raw0 + span)
1044        } else {
1045            (raw1, raw1 + (tau_turn - span))
1046        }
1047    };
1048    if theta1 - theta0 <= tol.angular() {
1049        ogeom_bail!(Construction, "the rim arc has no length to blend along");
1050    }
1051    let sweep = theta1 - theta0;
1052
1053    // Corner points at each end: A on the rim, W down the wall, C in on the
1054    // cap; and the frames the arcs run on.
1055    let dir_at = |theta: f64| -> Vector {
1056        seat_frame.x().vector() * theta.cos() + seat_frame.y().vector() * theta.sin()
1057    };
1058    // Every wedge chart is anchored at the arc's start: the window then
1059    // lives in (0, sweep), clear of the chart's own seam whatever part of
1060    // the rim the arc covers.
1061    let wedge_frame_at = |origin: Point| -> OgeomResult<Frame> {
1062        Frame::new(
1063            origin,
1064            Direction::new(seat.up, tol)?,
1065            Direction::new(dir_at(theta0), tol)?,
1066            tol,
1067        )
1068    };
1069    let apex_at = |theta: f64| seat.centre + dir_at(theta) * seat.radius;
1070    let wall_at = |theta: f64| tube_level + dir_at(theta) * seat.radius;
1071    let cap_at = |theta: f64| seat.centre + dir_at(theta) * tube_rho;
1072
1073    // Six shared corner vertices, nine shared edges: a wire chains through
1074    // vertex objects, and neighbouring faces sew through shared edges.
1075    let va0 = make_vertex(model, apex_at(theta0)).shape;
1076    let va1 = make_vertex(model, apex_at(theta1)).shape;
1077    let vw0 = make_vertex(model, wall_at(theta0)).shape;
1078    let vw1 = make_vertex(model, wall_at(theta1)).shape;
1079    let vc0 = make_vertex(model, cap_at(theta0)).shape;
1080    let vc1 = make_vertex(model, cap_at(theta1)).shape;
1081
1082    let ring_arc = |model: &mut Model,
1083                    origin: Point,
1084                    r: f64,
1085                    from: &Shape,
1086                    to: &Shape|
1087     -> OgeomResult<Shape> {
1088        let circle = Circle::new(wedge_frame_at(origin)?, r, tol)?;
1089        let curve = Curve::Circle(CircleCurve::new(circle));
1090        Ok(make_edge_between(model, curve, (0.0, sweep), from, to, tol)?.shape)
1091    };
1092    // The quarter meridian at `theta`, from the wall contact to the cap
1093    // contact: the tube's own circle, in the meridian plane.
1094    let quarter = |model: &mut Model, theta: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
1095        let dir = dir_at(theta);
1096        let centre_m = tube_level + dir * tube_rho;
1097        let x_dir = Direction::new(dir * (seat.sigma * seat.tau), tol)?;
1098        let z_dir = Direction::new(dir.cross(seat.up) * seat.sigma, tol)?;
1099        let frame = Frame::new(centre_m, z_dir, x_dir, tol)?;
1100        let circle = Circle::new(frame, radius, tol)?;
1101        let curve = Curve::Circle(CircleCurve::new(circle));
1102        Ok(make_edge_between(
1103            model,
1104            curve,
1105            (0.0, core::f64::consts::FRAC_PI_2),
1106            from,
1107            to,
1108            tol,
1109        )?
1110        .shape)
1111    };
1112
1113    let arc_apex = ring_arc(model, seat.centre, seat.radius, &va0, &va1)?;
1114    let arc_wall = ring_arc(model, tube_level, seat.radius, &vw0, &vw1)?;
1115    let arc_cap = ring_arc(model, seat.centre, tube_rho, &vc0, &vc1)?;
1116    let ruling0 = segment_between(model, (&va0, apex_at(theta0)), (&vw0, wall_at(theta0)), tol)?;
1117    let ruling1 = segment_between(model, (&vw1, wall_at(theta1)), (&va1, apex_at(theta1)), tol)?;
1118    let radial0 = segment_between(model, (&vc0, cap_at(theta0)), (&va0, apex_at(theta0)), tol)?;
1119    let radial1 = segment_between(model, (&va1, apex_at(theta1)), (&vc1, cap_at(theta1)), tol)?;
1120    let quarter0 = quarter(model, theta0, &vw0, &vc0)?;
1121    let quarter1 = quarter(model, theta1, &vw1, &vc1)?;
1122
1123    // The wall patch: the solid wall's own chart between the contact ring
1124    // and the rim, over the window.
1125    let wall_patch = {
1126        let below = radius + 1.0;
1127        let origin = seat.centre - seat.up * (seat.tau * below);
1128        let surface: SurfaceGeometry = CylinderSurface::new(
1129            Cylinder::new(wedge_frame_at(origin)?, seat.radius, tol)?,
1130            (0.0, 2.0 * below),
1131        )?
1132        .into();
1133        let edges = vec![
1134            arc_wall.clone(),
1135            ruling1.clone(),
1136            arc_apex.reversed(),
1137            ruling0.clone(),
1138        ];
1139        let face = face_from_edges(model, surface, &edges, tol)?;
1140        if seat.sigma * seat.tau < 0.0 {
1141            face.reversed()
1142        } else {
1143            face
1144        }
1145    };
1146
1147    // The cap sector: the cap plane between the rim and the contact ring.
1148    let cap_sector = {
1149        let plane = Plane::through(seat.centre, Direction::new(seat.up * seat.tau, tol)?);
1150        let reach = (seat.radius + radius + 1.0) * 2.0;
1151        let surface: SurfaceGeometry =
1152            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1153        let edges = vec![
1154            arc_apex.clone(),
1155            radial1.clone(),
1156            arc_cap.reversed(),
1157            radial0.clone(),
1158        ];
1159        face_from_edges(model, surface, &edges, tol)?
1160    };
1161
1162    // The blend: the quarter-tube patch over the window, reversed as the
1163    // full rim's band is: its natural normal points into the wedge.
1164    let blend_patch = {
1165        let surface: SurfaceGeometry = TorusSurface::new(Torus::new(
1166            wedge_frame_at(tube_level)?,
1167            tube_rho,
1168            radius,
1169            tol,
1170        )?)
1171        .into();
1172        let edges = vec![
1173            arc_wall.clone(),
1174            quarter1.clone(),
1175            arc_cap.reversed(),
1176            quarter0.reversed(),
1177        ];
1178        face_from_edges(model, surface, &edges, tol)?.reversed()
1179    };
1180
1181    // The end caps: planar triangles in the meridian half-planes, outward
1182    // along the rim's travel: behind it at the start, ahead at the end.
1183    let tangent_at = |theta: f64| seat.up.cross(dir_at(theta));
1184    let end_cap =
1185        |model: &mut Model, theta: f64, outward: Vector, edges: Vec<Shape>| -> OgeomResult<Shape> {
1186            let plane = Plane::through(apex_at(theta), Direction::new(outward, tol)?);
1187            let reach = (seat.radius + radius + 1.0) * 2.0;
1188            let surface: SurfaceGeometry =
1189                PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1190            face_from_edges(model, surface, &edges, tol)
1191        };
1192    let cap_start = end_cap(
1193        model,
1194        theta0,
1195        -tangent_at(theta0),
1196        vec![ruling0.clone(), quarter0.clone(), radial0.clone()],
1197    )?;
1198    let cap_end = end_cap(
1199        model,
1200        theta1,
1201        tangent_at(theta1),
1202        vec![ruling1.clone(), radial1.clone(), quarter1.reversed()],
1203    )?;
1204
1205    let faces = [wall_patch, cap_sector, blend_patch, cap_start, cap_end];
1206    apply_wedge(model, solid, Some(edge), &faces, seat.additive(), tol)
1207}