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 or conical 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 does 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::asym::Section;
15use crate::law::{Profile, RadiusLaw};
16use crate::support::{
17    Seat, apply_wedge, apply_wedge_solid, edge_curve, face_from_edges, planar_face, planar_seat,
18    revolved_flanks, revolved_seat, segment_between, trimmed_wedge, wedge_solid,
19};
20use ogeom_algo::{Built, make_edge_between, make_revolution_band, make_vertex};
21use ogeom_core::{OgeomError, OgeomResult, Tolerances, ogeom_bail};
22use ogeom_geom::{
23    CircleCurve, Curve, CylinderSurface, PlaneSurface, SurfaceGeometry, TorusSurface,
24};
25use ogeom_math::{Circle, Cylinder, Direction, Frame, Plane, Point, Torus, Vector};
26use ogeom_topo::{Model, Shape, ShapeType, explore_unique};
27
28/// Round a convex edge of a solid with a constant-radius blend tangent to
29/// both of its faces.
30///
31/// Two seats are recognised, and each has a closed-form envelope: a straight
32/// edge between two planar faces, where the rolling ball traces a cylinder,
33/// and a circular rim where a cylindrical or conical wall meets a
34/// perpendicular planar cap, where it traces a torus. In both, the result is the boolean
35/// difference with a wedge whose curved face is that envelope, so the history
36/// reads as a cut: the faces are modified into their trimmed pieces, and the
37/// edge's neighbourhood gains the blend face.
38///
39/// The blend is the one [`fillet_edges`] builds on this one edge. Where a
40/// face cuts the corner away past an end (a floor giving way to a ramp
41/// under a wall that carries on, or a boss's cylinder past the end of a
42/// straight edge beside it), both run it out onto that face until its whole
43/// section has left the material. Where a
44/// host face narrows along the edge and the ball's contact runs off it
45/// out of the solid, both cut the band off on the face that follows. Where
46/// a convex edge's face is a planar strip narrower than the setback all
47/// along, its far edge convex too and its ends on faces square to the edge
48/// (a chamfer, a narrow top), the ball rolls between the planes either
49/// side of the strip, and where the strip lies wholly in the corner that
50/// ball takes off, the round is that ball's cylinder and the strip goes
51/// with the corner. Where a concave edge's face is such a strip and its
52/// far edge is straight and convex (the seam where two solids are fused
53/// at a slant), the ball touches the other face and rests on that far
54/// edge, and where it does so alone over the whole crease, clear of every
55/// other face, the round is the band fitted through those balls' arcs.
56/// Where it rests there over part of the crease, coming down on a face
57/// either side or rolling round a corner of the strip onto its next edge,
58/// the round is a chain of such bands meeting tangent where the ball
59/// changes what it holds. One thing differs: where the edge ends against a neighbouring blend, this
60/// blend stops flush there instead of running on through it. Three such
61/// flush blends and their vertex are what
62/// [`round_vertex`](crate::round_vertex) closes.
63///
64/// What `solid` shares with other shapes is copied before it is
65/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
66///
67/// # Errors
68///
69/// [`OgeomError::Construction`] if the edge is
70/// neither of the seats above, is concave, or `radius` is not a usable
71/// length, or the ball sets back past the far side of a face into the
72/// material, or past it all along the edge where no strip is taken off and
73/// no far edge holds the ball as above; naming why where the ball rests
74/// on such a far edge but comes onto the face beyond it, rests on far
75/// edges either side at once, or leaves a face or comes within its radius
76/// of another along the crease.
77pub fn fillet_edge(
78    model: &mut Model,
79    solid: &Shape,
80    edge: &Shape,
81    radius: f64,
82    tol: Tolerances,
83) -> OgeomResult<Built> {
84    ogeom_algo::on_own_nodes(
85        model,
86        solid,
87        std::slice::from_ref(edge),
88        |model, solid, parts| {
89            cut_off_bands(
90                model,
91                solid,
92                |model| fillet_edge_own(model, solid, &parts[0], radius, tol),
93                tol,
94            )
95        },
96    )
97}
98
99/// Build with `build`, and cut each concave band on a curved edge that ran
100/// past the side of the face it rolls on off at that side's plane: what
101/// stands in front of the plane, within twice the band's radius of where
102/// it ran past, goes, so the band's fill ends flush with the face's side.
103/// Refused by name where `solid` itself holds anything there.
104fn cut_off_bands(
105    model: &mut Model,
106    solid: &Shape,
107    build: impl FnOnce(&mut Model) -> OgeomResult<Built>,
108    tol: Tolerances,
109) -> OgeomResult<Built> {
110    let (built, cuts) = crate::support::with_cut_offs(|| build(model));
111    let mut built = built?;
112    for cut in cuts {
113        let reach = 2.0 * cut.radius;
114        let (mut lo, mut hi) = (cut.points[0], cut.points[0]);
115        for p in &cut.points {
116            lo = Point::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
117            hi = Point::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
118        }
119        let (lo, hi) = (
120            lo - Vector::new(reach, reach, reach),
121            hi + Vector::new(reach, reach, reach),
122        );
123        let walls = [
124            (cut.normal, cut.origin),
125            (Vector::new(1.0, 0.0, 0.0), lo),
126            (Vector::new(0.0, 1.0, 0.0), lo),
127            (Vector::new(0.0, 0.0, 1.0), lo),
128            (Vector::new(-1.0, 0.0, 0.0), hi),
129            (Vector::new(0.0, -1.0, 0.0), hi),
130            (Vector::new(0.0, 0.0, -1.0), hi),
131        ];
132        let tool = crate::corner::convex_block(model, &walls, tol)?;
133        // The solid itself stands behind the plane there: the cut takes the
134        // band's overhang and nothing it was given.
135        let shared = ogeom_bool::common(model, solid, &tool, tol)?.shape;
136        let held = if explore_unique(model, &shared, ShapeType::Face)?.is_empty() {
137            0.0
138        } else {
139            ogeom_algo::volume_properties(model, &shared, ogeom_mesh::Deflection::default(), tol)?
140                .mass
141                .abs()
142        };
143        if held > tol.confusion() {
144            ogeom_bail!(
145                Construction,
146                "a concave blend of radius {} runs past the side of the face it rolls on \
147                 where the solid stands beyond that side, so its fill cannot be cut off \
148                 there; see docs/CAPABILITIES.md, fillet.edge-blends",
149                cut.radius
150            );
151        }
152        let trimmed = ogeom_bool::cut(model, &built.shape, &tool, tol)?;
153        built = Built::new(trimmed.shape, built.history.then(&trimmed.history));
154    }
155    Ok(built)
156}
157
158/// [`fillet_edge`] on a shape every node below which is its own.
159fn fillet_edge_own(
160    model: &mut Model,
161    solid: &Shape,
162    edge: &Shape,
163    radius: f64,
164    tol: Tolerances,
165) -> OgeomResult<Built> {
166    if !radius.is_finite() || radius <= tol.confusion() {
167        ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
168    }
169    // The ball touches each face a setback back from the edge; set back
170    // past a face's far side into the material, or all along the edge,
171    // the band would cut through the face. Past a strip's far side all
172    // along, the ball may roll between the faces either side of the strip
173    // and take it off whole.
174    if let Err(refusal) = crate::support::ball_fits(model, solid, edge, radius, false, tol) {
175        return consuming(model, solid, edge, radius, refusal, tol);
176    }
177    let edges = std::slice::from_ref(edge);
178    or_rim_consumed(
179        model,
180        solid,
181        edge,
182        radius,
183        |model| {
184            with_fitted_rims(
185                model,
186                solid,
187                edges,
188                |model, rims| fillet_edge_meeting(model, solid, edge, radius, None, rims, tol),
189                tol,
190            )
191        },
192        tol,
193    )
194}
195
196/// The round of `edge` that takes a narrow strip beside it off whole, where
197/// [`crate::consumed::consumed_seat`] finds one, and `refusal` otherwise.
198fn consuming(
199    model: &mut Model,
200    solid: &Shape,
201    edge: &Shape,
202    radius: f64,
203    refusal: OgeomError,
204    tol: Tolerances,
205) -> OgeomResult<Built> {
206    if !matches!(refusal, OgeomError::Construction(_)) {
207        return Err(refusal);
208    }
209    let Some(seat) = crate::consumed::consumed_seat(model, solid, edge, radius, tol)? else {
210        let rolled = match crate::rolled::rolled_fillet(model, solid, edge, radius, tol)? {
211            Some(built) => Some(built),
212            None => crate::consumed::consumed_slanted(model, solid, edge, radius, tol)?,
213        };
214        let Some(mut built) = rolled else {
215            return match crate::consumed_rim::consumed_rim(model, solid, edge, radius, tol)? {
216                Some(rim) => rim_consuming(model, solid, edge, rim, radius, tol),
217                None => crate::consumed_marched::consumed_marched(model, solid, edge, radius, tol)?
218                    .ok_or(refusal),
219            };
220        };
221        crate::support::credit_new_faces(model, solid, edge, &mut built)?;
222        return Ok(built);
223    };
224    let mut built = seated_fillet(model, solid, &seat, radius, Some(edge), &[], tol)?;
225    crate::support::credit_new_faces(model, solid, edge, &mut built)?;
226    Ok(built)
227}
228
229/// The round of a rim on `rim`, the seat of the cap and wall either side of
230/// a ring beside it that the round takes off whole.
231fn rim_consuming(
232    model: &mut Model,
233    solid: &Shape,
234    edge: &Shape,
235    rim: crate::support::RevolvedSeat,
236    radius: f64,
237    tol: Tolerances,
238) -> OgeomResult<Built> {
239    let mut built = revolved_fillet_on(model, solid, edge, rim, radius, tol)?;
240    crate::support::credit_new_faces(model, solid, edge, &mut built)?;
241    Ok(built)
242}
243
244/// `round`, the rounding of the one `edge`, and where it refuses, the round
245/// that takes a ring beside a rim off whole, where
246/// [`crate::consumed_rim::consumed_rim`] finds one: a rim beside a small
247/// round meets it tangentially, so no seat of the rim's own builds. What
248/// the refused attempt widened is put back first; elsewhere the attempt
249/// stands as it is.
250fn or_rim_consumed(
251    model: &mut Model,
252    solid: &Shape,
253    edge: &Shape,
254    radius: f64,
255    round: impl FnOnce(&mut Model) -> OgeomResult<Built>,
256    tol: Tolerances,
257) -> OgeomResult<Built> {
258    let mark = model.note_widened();
259    match round(model) {
260        Err(refusal @ OgeomError::Construction(_)) => {
261            match crate::consumed_rim::consumed_rim(model, solid, edge, radius, tol)? {
262                Some(rim) => {
263                    model.undo_widened(mark);
264                    rim_consuming(model, solid, edge, rim, radius, tol)
265                }
266                None => {
267                    // The tolerances the refused attempt widened stand, as
268                    // they do where the refusal stands.
269                    model.keep_widened(mark);
270                    crate::consumed_marched::consumed_marched(model, solid, edge, radius, tol)?
271                        .ok_or(refusal)
272                }
273            }
274        }
275        // A strip beside a marched seat can leave the edge's own round
276        // nothing to stand on short of a construction refusal.
277        Err(refusal @ OgeomError::NotDone(_)) => {
278            model.keep_widened(mark);
279            crate::consumed_marched::consumed_marched(model, solid, edge, radius, tol)?
280                .ok_or(refusal)
281        }
282        other => {
283            model.keep_widened(mark);
284            other
285        }
286    }
287}
288
289/// An edge of a multi-edge request, by its ends: where each end stands and
290/// which way the edge leaves it, so a later edge can tell a chain mate from
291/// a corner mate at a shared vertex.
292#[derive(Debug, Clone, Copy)]
293pub(crate) struct Mate {
294    /// `(point, unit tangent leaving the point along the edge)` per end.
295    pub ends: [(Point, Vector); 2],
296    /// Per end, whether the corner tool has already closed the vertex there:
297    /// the band stops flush against the corner's patch rather than running
298    /// out through it.
299    pub settled: [bool; 2],
300    /// Per end, whether a junction of the request meets the band there in
301    /// a plane: the band stops at the vertex and the junction closes the
302    /// corner.
303    pub meets: [bool; 2],
304    /// Per end, whether a crossed junction of the request across a convex
305    /// turn stands there: the band, built from the solid as given, runs out
306    /// there as it does alone.
307    pub alone: [bool; 2],
308}
309
310impl Mate {
311    /// Whether any mate's end at `at` is a vertex the corner tool closed,
312    /// or `at` is an end of the request's own edge `index` whose vertex the
313    /// corner tool closed.
314    ///
315    /// The second reading is the one a band asks of itself: a corner tool
316    /// trims the edge back to the plane square to it through the ball's
317    /// centre, so the edge's end now stands a setback from the vertex it
318    /// was named at, and the nearer of its original ends says which one
319    /// it is.
320    pub(crate) fn settled_at(mates: &[Self], index: usize, at: Point, tol: Tolerances) -> bool {
321        let other = mates.iter().any(|mate| {
322            mate.ends
323                .iter()
324                .zip(mate.settled)
325                .any(|((p, _), settled)| settled && p.distance(at) <= tol.confusion() * 1e3)
326        });
327        let own = mates.get(index).is_some_and(|mate| {
328            let nearer = usize::from(mate.ends[1].0.distance(at) < mate.ends[0].0.distance(at));
329            mate.settled[nearer]
330        });
331        other || own
332    }
333
334    /// Whether the band of edge `index` runs out at its end nearest `at`
335    /// as it does alone: no other edge of the request ends there, or a
336    /// crossed junction across a convex turn stands there.
337    pub(crate) fn alone_at(mates: &[Self], index: usize, at: Point, tol: Tolerances) -> bool {
338        let near = |p: &Point| p.distance(at) <= tol.confusion() * 1e3;
339        let shared = mates
340            .iter()
341            .enumerate()
342            .any(|(i, mate)| i != index && mate.ends.iter().any(|(p, _)| near(p)));
343        let crossed = mates.get(index).is_some_and(|mate| {
344            mate.ends
345                .iter()
346                .zip(mate.alone)
347                .any(|((p, _), alone)| alone && near(p))
348        });
349        !shared || crossed
350    }
351
352    /// Whether a junction of the request meets edge `index` at its end
353    /// nearest `at`.
354    pub(crate) fn meets_at(mates: &[Self], index: usize, at: Point, tol: Tolerances) -> bool {
355        mates.get(index).is_some_and(|mate| {
356            mate.ends
357                .iter()
358                .zip(mate.meets)
359                .any(|((p, _), meets)| meets && p.distance(at) <= tol.confusion() * 1e3)
360        })
361    }
362}
363
364/// [`fillet_edge`], with the straight seat told what else was asked for.
365///
366/// One edge at a time stops flush against a neighbouring blend it ends on:
367/// that is the state the corner tool (`round_vertex`) is built for, three
368/// flush blends and a vertex. Where no blend follows, the seat is the same
369/// alone and in a request, and runs out the same way onto a face cutting
370/// its corner away. Edges asked for together meet: where one of them ends at
371/// a vertex another leaves *tangentially*, the two are a chain and their
372/// caps stand flush in one plane; where it ends against the band of one
373/// that left the vertex some other way, the later seat runs on through
374/// that band and the cut trims the two against each other: two fillets
375/// meeting at a corner.
376fn fillet_edge_meeting(
377    model: &mut Model,
378    solid: &Shape,
379    edge: &Shape,
380    radius: f64,
381    mates: Option<(usize, &[Mate])>,
382    rims: Rims,
383    tol: Tolerances,
384) -> OgeomResult<Built> {
385    let mut built = fillet_edge_seat(model, solid, edge, radius, mates, rims, tol)?;
386    crate::support::credit_new_faces(model, solid, edge, &mut built)?;
387    Ok(built)
388}
389
390/// [`fillet_edge_meeting`] by seat, before the blend is credited to the
391/// edge.
392fn fillet_edge_seat(
393    model: &mut Model,
394    solid: &Shape,
395    edge: &Shape,
396    radius: f64,
397    mates: Option<(usize, &[Mate])>,
398    rims: Rims,
399    tol: Tolerances,
400) -> OgeomResult<Built> {
401    if !radius.is_finite() || radius <= tol.confusion() {
402        ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
403    }
404    match route(model, solid, edge, rims, tol)? {
405        Route::Planar => planar_fillet(model, solid, edge, Section::Ball(radius), mates, tol),
406        Route::Ruled(seat) => {
407            crate::ruled::ruled_fillet(model, solid, edge, radius, mates, seat, tol)
408        }
409        // A cone's ball whose tube would reach its own axis takes the march:
410        // the torus would cross the axis.
411        Route::Revolved(_, _, seat)
412            if seat.slope != 0.0 && seat.ball(radius).rho - radius <= tol.confusion() =>
413        {
414            crate::marched::marched_fillet(model, solid, edge, radius, mates, tol)
415        }
416        Route::Revolved(c, crange, seat) => {
417            let closed =
418                ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
419            if closed {
420                return revolved_fillet(model, solid, edge, &c, radius, tol);
421            }
422            let whole_turn = crate::support::runs_whole_turn(model, &seat, &c, crange, tol)?;
423            if whole_turn {
424                revolved_fillet(model, solid, edge, &c, radius, tol)
425            } else {
426                revolved_arc_fillet(model, solid, edge, &c, radius, None, mates, tol)
427            }
428        }
429        Route::Marched => crate::marched::marched_fillet(model, solid, edge, radius, mates, tol),
430    }
431}
432
433/// Whether an open rim arc's torus band, capped in the meridian half-plane
434/// through its end `at` (the crease leaving along `ahead` past it), would
435/// stop short of a face the arc ends on: the crease terminates there, no
436/// mate of the request ends there, and a planar face there stands oblique
437/// to the crease, its normal neither square to the crease's direction (a
438/// wall continuing the crease tangentially, which meets the cap square) nor
439/// along it (a wall square to the crease, which is the cap). Past such an
440/// end the ball still rolls through material between the meridian cap and
441/// the face while its contact on the cap stands on it. The face's plane,
442/// facing back over the arc, where it does.
443#[allow(clippy::too_many_arguments, reason = "one question, all its data")]
444fn rim_end_stops_short(
445    model: &Model,
446    solid: &Shape,
447    edge: &Shape,
448    seat: &crate::support::RevolvedSeat,
449    mates: Option<(usize, &[Mate])>,
450    at: Point,
451    ahead: Vector,
452    tol: Tolerances,
453) -> OgeomResult<Option<(Point, Vector)>> {
454    let hosts = [&seat.cap_face, &seat.wall_face];
455    let met = mates.is_some_and(|(index, mates)| {
456        mates.iter().enumerate().any(|(i, mate)| {
457            i != index
458                && mate
459                    .ends
460                    .iter()
461                    .any(|(p, _)| p.distance(at) <= tol.confusion() * 1e3)
462        })
463    });
464    if met || !crate::marched::crease_terminates_at(model, solid, edge, hosts, at, tol)? {
465        return Ok(None);
466    }
467    for face in explore_unique(model, solid, ShapeType::Face)? {
468        if hosts.iter().any(|h| h.is_same(&face)) {
469            continue;
470        }
471        let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
472            continue;
473        };
474        let Some(SurfaceGeometry::Plane(plane)) = model.geometry().surface(data.surface) else {
475            continue;
476        };
477        use ogeom_geom::Transformable as _;
478        let SurfaceGeometry::Plane(placed) =
479            SurfaceGeometry::Plane(*plane).transformed(&face.transform(model.datums())?, tol)?
480        else {
481            continue;
482        };
483        let plane = placed.plane();
484        if plane.signed_distance_to(at).abs() > tol.confusion() * 10.0 {
485            continue;
486        }
487        let meets = explore_unique(model, &face, ShapeType::Vertex)?
488            .iter()
489            .any(|v| {
490                model
491                    .node(v)
492                    .and_then(|n| n.data().as_vertex())
493                    .zip(v.transform(model.datums()).ok())
494                    .is_some_and(|(d, place)| {
495                        place.apply(d.point).distance(at) <= tol.confusion() * 1e3
496                    })
497            });
498        let normal = plane.normal().vector();
499        let lean = normal.dot(ahead).abs();
500        if meets && lean > 1e-6 && lean < 1.0 - 1e-6 {
501            // Facing back over the arc: the side of the face the arc lies on.
502            let back = if normal.dot(ahead) > 0.0 {
503                -normal
504            } else {
505                normal
506            };
507            return Ok(Some((plane.origin(), back)));
508        }
509    }
510    Ok(None)
511}
512
513/// Which construction rounds a rim on a planar cap and a coaxial cone.
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
515enum Rims {
516    /// The exact torus band.
517    Exact,
518    /// The marched band, fitted through the ball's arcs.
519    Fitted,
520}
521
522/// The construction that rounds an edge, chosen by its curve and the two
523/// faces it lies between.
524enum Route {
525    /// A straight edge between two planes.
526    Planar,
527    /// A straight edge between planes or drums parallel to it.
528    Ruled(crate::ruled::RuledSeat),
529    /// A circle between a planar cap and its coaxial wall, a cylinder or
530    /// a cone, with the edge's range on it.
531    Revolved(CircleCurve, (f64, f64), crate::support::RevolvedSeat),
532    /// Any other seat: the march.
533    Marched,
534}
535
536/// How `edge` of `solid` is rounded. A rim on a planar cap and its
537/// coaxial wall is the revolved blend's, exact, but where `rims` asks a
538/// cone's for the march; a circle on any other pair of hosts (a bore down
539/// a ball's axis, a ring's own rim) is a seat like any other for the march.
540fn route(
541    model: &Model,
542    solid: &Shape,
543    edge: &Shape,
544    rims: Rims,
545    tol: Tolerances,
546) -> OgeomResult<Route> {
547    let (curve, crange) = edge_curve(model, edge, tol)?;
548    Ok(match curve {
549        Curve::Line(_) if crate::support::hosts_planar(model, solid, edge, tol)? => Route::Planar,
550        Curve::Line(_) => match crate::ruled::ruled_seat(model, solid, edge, tol)? {
551            Some(seat) => Route::Ruled(seat),
552            None => Route::Marched,
553        },
554        Curve::Circle(c) => match revolved_seat(model, solid, edge, &c, tol) {
555            Ok(seat) if seat.slope != 0.0 && rims == Rims::Fitted => Route::Marched,
556            Ok(seat) => Route::Revolved(c, crange, seat),
557            Err(OgeomError::Construction(_)) => Route::Marched,
558            Err(e) => return Err(e),
559        },
560        _ => Route::Marched,
561    })
562}
563
564/// Round a chain of edges in one call, each blend applied in turn and every
565/// later edge resolved through the history the earlier blends wrote.
566///
567/// A solid bounded by planes, each face one ring of straight edges, goes
568/// first through the blend engine (docs/adr/0001-blend-architecture.md),
569/// which builds the whole request in one pass, the same solid in any order.
570/// Every face is set back to where its rounds' contact lines meet. Every
571/// round is its exact cylinder between them, ended through its faces'
572/// setback corners (skew on the cylinder where they stand at different
573/// stations). A vertex closes as a sphere where one ball touches its three
574/// faces there, flush in its third face where one round ends square to it,
575/// and otherwise with one transfinite patch tangent to the three rounds'
576/// ends within the bands' fit target. Every face is proved within the
577/// material it rounds. A request the engine does not take (curved faces,
578/// a face set back to a line, a vertex of four edges, an edge's two ends
579/// set back past each other) is rounded as follows.
580///
581/// At a tangent junction the neighbouring wedges' end caps stand in one
582/// plane with one cross-section, and the boolean's same-domain resolution
583/// melts them: the blends join without a seam face between them. Two
584/// convex edges of the chain that share a face and turn at a vertex meet
585/// there, the way one edge at a time ([`fillet_edge`]) deliberately does
586/// not, since the flush-ended state is what the corner tool
587/// ([`round_vertex`](crate::round_vertex)) is built for. Carried on past
588/// the vertex, each band is a straight band of its end section, and two of
589/// one radius cross in the plane through the meeting of their centre lines
590/// that bisects them, where the reflection in that plane carries the one
591/// band's end section onto the other's (the shared host flat, the two
592/// walls' tangent planes at the vertex reflections of each other, to the
593/// fit a marched band is held to). Across a re-entrant turn (an L-shaped
594/// top's inside corner) both then run on past their caps to that plane,
595/// taking the corner cell they both hold, where the material holds that
596/// cell; across a convex turn of a curved crease both stop in it where the
597/// bands' own curvature keeps their sections there within that fit. Either
598/// way the two are built from the solid as it stands and joined across the
599/// plane into one wedge, each band's faces credited to its own edge. Where
600/// the end sections differ (a drafted wall beside a square one) and both
601/// creases are straight, across a re-entrant turn the corner takes what
602/// lies above both bands carried on, bounded by the boolean's own
603/// intersection of the two, where the material holds it all. Across a
604/// convex turn of two straight creases, whatever their sections, each band
605/// runs out past the vertex as it does alone and the cuts take their union
606/// (walls leaning alike with their feet out each reach past the bisecting
607/// plane under the other's band, which a meeting there would leave
608/// standing), as they do where one of the two is a drum's rim (a D-shaped
609/// block's arc beside its chord, and past where they meet flush, a rib's
610/// spline wall running into a boss's rim). Across a re-entrant turn one
611/// of the two may be a drum's rim, upright or drafted, the material within
612/// the drum, where the
613/// straight band carried on stands nowhere off the drum before the rim's
614/// cap. Such
615/// junctions stand beside flush ones in a loop or chain, a band crossed at
616/// both ends cut in two halfway along, and two bands meeting at both their
617/// ends meet at each.
618/// Elsewhere (a wider convex turn of two curved bands, or of a curved band
619/// other than a drum's rim beside one of another kind)
620/// and wherever the meeting does not close, the later seat runs on
621/// through the earlier blend's band and the cut trims the two bands
622/// against each other along their own intersection, or the caps stand, as
623/// without a junction. Where three or
624/// more edges of the chain meet at one vertex, the corner tool closes it
625/// with the rolling ball's own patch (the octant of a sphere at a box
626/// corner, the envelope of spheres and cylinders at a vertex no single
627/// ball touches) rather than leaving the bands' caps standing. The corner
628/// goes first and the bands stop flush against its patch: bands built
629/// first crash into each other at an apex. A corner the tool does not
630/// speak (a concave vertex, more than three surfaces with no shared
631/// ball when one of them is curved) keeps its caps,
632/// which is the honest picture of a corner no ball rolls around. Two
633/// creases meeting on no shared face leave the wedges' caps standing
634/// likewise.
635///
636/// Only two blends that round the same way trim each other. A wedge's cut
637/// would eat a fill, and a fill cannot run on through a wedge's band, so
638/// where a convex edge meets a concave one (an L-bracket's front edge at
639/// its re-entrant edge) no ball rolls round both. There the request is
640/// rounded in one order whatever its own, and a vertex of three planar
641/// faces and three straight edges, all of them asked, is closed as
642/// [`round_vertex_setback`](crate::round_vertex_setback) closes a corner:
643/// each band stopped one of its own widths past the least setback at which
644/// its section clears the others, and one patch G1 to every band end and
645/// face, the same solid in any order.
646///
647/// What `solid` shares with other shapes is copied before it is
648/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
649///
650/// # Errors
651///
652/// As [`fillet_edge`] per edge, and additionally if an earlier blend
653/// consumed or split a later edge: a chain whose members interfere is
654/// refused rather than guessed at. Where a convex and a concave edge of the
655/// request meet at a vertex that is not three planar faces and three
656/// straight edges, all asked or one left sharp and convex, the request is
657/// refused by name in any order, and so where a band's setback reaches its
658/// edge's far end or the corner's cell reaches through the solid behind
659/// the vertex. A request of two or more edges also
660/// refuses a ball that sets back past the far side of a face anywhere
661/// along an edge that shares a face or a vertex with another of the
662/// request, since each band's contacts are checked against the others'
663/// and a contact off its face holds none to check. An edge sharing
664/// neither is held to what [`fillet_edge`] holds it to.
665pub fn fillet_edges(
666    model: &mut Model,
667    solid: &Shape,
668    edges: &[Shape],
669    radius: f64,
670    tol: Tolerances,
671) -> OgeomResult<Built> {
672    let asked: Vec<(Shape, f64)> = edges.iter().map(|e| (e.clone(), radius)).collect();
673    if let Some(built) = rounds_by_engine(model, solid, &asked, tol)? {
674        return Ok(built);
675    }
676    let radii = vec![radius; edges.len()];
677    ogeom_algo::on_own_nodes(model, solid, edges, |model, solid, parts| {
678        cut_off_bands(
679            model,
680            solid,
681            |model| fillet_edges_meeting(model, solid, parts, &radii, tol),
682            tol,
683        )
684    })
685}
686
687/// [`fillet_edges`] with a radius per edge.
688///
689/// With every radius the same, exactly [`fillet_edges`] at that radius. A
690/// solid bounded by planes goes through the blend engine as there, a corner
691/// of three radii closed with its transfinite patch; the rest is rounded as
692/// follows.
693/// Each band is built at its own radius and checked against the others as
694/// [`fillet_edges`] checks them. Two edges of one radius meet as there.
695/// Two edges of different radii turning at a vertex are rounded in turn,
696/// the later band running on through the earlier and the cut trimming
697/// the two against each other along their own intersection. A tangent
698/// chain whose radius steps at a junction is rounded likewise, the later
699/// band's end standing against the earlier band.
700///
701/// # Errors
702///
703/// As [`fillet_edges`], and as [`round_vertex_setback`](crate::round_vertex_setback)
704/// where three or more of the edges meeting at a vertex do not share one
705/// radius. No ball rolls round such a corner: each band is stopped one of
706/// its own widths past the least setback at which its section clears every
707/// other band there, and one patch closes the opening G1 to every band end
708/// and face. [`OgeomError::NotDone`] where that patch misses the bands' fit
709/// target, and [`OgeomError::Construction`] where the corner is not a
710/// convex planar one every edge of which the request rounds. An edge asked
711/// twice at two radii is refused likewise.
712pub fn fillet_edges_with(
713    model: &mut Model,
714    solid: &Shape,
715    edges: &[(Shape, f64)],
716    tol: Tolerances,
717) -> OgeomResult<Built> {
718    let shapes: Vec<Shape> = edges.iter().map(|(edge, _)| edge.clone()).collect();
719    let radii: Vec<f64> = edges.iter().map(|&(_, radius)| radius).collect();
720    for (i, (edge, radius)) in edges.iter().enumerate() {
721        if !radius.is_finite() || *radius <= tol.confusion() {
722            ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
723        }
724        if edges[..i]
725            .iter()
726            .any(|(other, r)| other.is_same(edge) && r != radius)
727        {
728            ogeom_bail!(
729                Construction,
730                "an edge asked at two radii, {radius} and another, rounds neither"
731            );
732        }
733    }
734    if let Some(built) = rounds_by_engine(model, solid, edges, tol)? {
735        return Ok(built);
736    }
737    ogeom_algo::on_own_nodes(model, solid, &shapes, |model, solid, parts| {
738        cut_off_bands(
739            model,
740            solid,
741            |model| fillet_edges_meeting(model, solid, parts, &radii, tol),
742            tol,
743        )
744    })
745}
746
747/// The rounds of `edges` built by the blend engine in one pass, where the
748/// solid is bounded by planes and every vertex is one the engine closes;
749/// `None` leaves the request to the current path. An edge asked twice at
750/// one radius is asked once.
751fn rounds_by_engine(
752    model: &mut Model,
753    solid: &Shape,
754    edges: &[(Shape, f64)],
755    tol: Tolerances,
756) -> OgeomResult<Option<Built>> {
757    let mut once: Vec<(Shape, f64)> = Vec::with_capacity(edges.len());
758    for (edge, radius) in edges {
759        if !once.iter().any(|(e, _)| e.is_same(edge)) {
760            once.push((edge.clone(), *radius));
761        }
762    }
763    // Nothing asked, or a radius that rounds nothing, is refused by the
764    // current path, in its words.
765    if once.is_empty()
766        || once
767            .iter()
768            .any(|(_, r)| !(r.is_finite() && *r > tol.confusion()))
769    {
770        return Ok(None);
771    }
772    let request = crate::engine::Request::from_rounds(&once, tol)?;
773    crate::engine::run(model, solid, &request, tol)
774}
775
776/// [`fillet_edges_with`] on a shape every node below which is its own, its
777/// cone rims rounded exactly where that builds and fitted where it does
778/// not, as [`with_fitted_rims`] says.
779fn fillet_edges_meeting(
780    model: &mut Model,
781    solid: &Shape,
782    edges: &[Shape],
783    radii: &[f64],
784    tol: Tolerances,
785) -> OgeomResult<Built> {
786    // Where a convex and a concave band meet at a vertex, the bands are
787    // built in one order whatever the request's, and the corner closed
788    // after.
789    let request: Vec<(Shape, f64)> = edges.iter().cloned().zip(radii.iter().copied()).collect();
790    let signed = crate::setback::signed_corners(model, solid, &request, tol)?;
791    let cusps = crate::setback::cusp_corners(model, solid, &request, tol)?;
792    let (edges, radii): (Vec<Shape>, Vec<f64>) = if signed.is_empty() && cusps.is_empty() {
793        (edges.to_vec(), radii.to_vec())
794    } else {
795        let mut sorted = request.clone();
796        sorted.sort_by_key(|(edge, _)| edge.node());
797        sorted.into_iter().unzip()
798    };
799    let (edges, radii) = (edges.as_slice(), radii.as_slice());
800    let round = |model: &mut Model| {
801        with_fitted_rims(
802            model,
803            solid,
804            edges,
805            |model, rims| fillet_edges_meeting_with(model, solid, edges, radii, rims, tol),
806            tol,
807        )
808    };
809    let mut mixed = mixed_corners(model, edges, radii)?;
810    mixed.retain(|(vertex, _)| {
811        !signed.iter().any(|c| c.vertex.is_same(vertex))
812            && !cusps.iter().any(|c| c.vertex.is_same(vertex))
813    });
814    // A request of one edge is that edge alone, a ring beside a rim taken
815    // off whole as there.
816    let mut built = match (edges, radii) {
817        ([edge], [radius]) => or_rim_consumed(model, solid, edge, *radius, round, tol)?,
818        _ => round(model)?,
819    };
820    for (vertex, request) in &mixed {
821        built = crate::setback::close_mixed_corner(model, solid, built, vertex, request, tol)?;
822    }
823    for corner in &signed {
824        built = crate::setback::close_signed_corner(model, solid, built, corner, tol)?;
825    }
826    for corner in &cusps {
827        built = crate::cusp::close_cusp(model, solid, built, corner, tol)?;
828    }
829    Ok(built)
830}
831
832/// A vertex of the request and the request's edges there with their radii.
833type Corner = (Shape, Vec<(Shape, f64)>);
834
835/// The vertices where three or more edges of the request meet at
836/// different radii, each with the request's edges there and their radii.
837fn mixed_corners(model: &Model, edges: &[Shape], radii: &[f64]) -> OgeomResult<Vec<Corner>> {
838    let mut corners: Vec<Corner> = Vec::new();
839    for (edge, &radius) in edges.iter().zip(radii) {
840        let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? else {
841            continue;
842        };
843        for v in [a, b] {
844            if let Some(slot) = corners.iter_mut().find(|(held, _)| held.is_same(&v)) {
845                slot.1.push((edge.clone(), radius));
846            } else {
847                corners.push((v, vec![(edge.clone(), radius)]));
848            }
849        }
850    }
851    corners.retain(|(_, at)| at.len() >= 3 && at.iter().any(|&(_, r)| r != at[0].1));
852    Ok(corners)
853}
854
855/// Round with every cone rim's band an exact torus band, and where that
856/// refuses, again from the solid as it was given (its tolerances put back)
857/// with those bands fitted through the ball's arcs, as the march builds
858/// them. The fitted band stands within its fit of the torus, and the
859/// boolean resolves some meetings of it that it does not of the exact one,
860/// so an exact rim never turns a rounding into a refusal.
861fn with_fitted_rims(
862    model: &mut Model,
863    solid: &Shape,
864    edges: &[Shape],
865    mut round: impl FnMut(&mut Model, Rims) -> OgeomResult<Built>,
866    tol: Tolerances,
867) -> OgeomResult<Built> {
868    let mark = model.note_widened();
869    let exact = round(model, Rims::Exact);
870    if matches!(
871        exact,
872        Ok(_) | Err(OgeomError::Cancelled | OgeomError::Dangling(_))
873    ) || !has_cone_rim(model, solid, edges, tol)?
874    {
875        model.keep_widened(mark);
876        return exact;
877    }
878    model.undo_widened(mark);
879    round(model, Rims::Fitted)
880}
881
882/// Whether any of `edges` is a rim of `solid` on a planar cap and a
883/// coaxial cone.
884fn has_cone_rim(
885    model: &Model,
886    solid: &Shape,
887    edges: &[Shape],
888    tol: Tolerances,
889) -> OgeomResult<bool> {
890    for edge in edges {
891        if let Route::Revolved(_, _, seat) = route(model, solid, edge, Rims::Exact, tol)?
892            && seat.slope != 0.0
893        {
894            return Ok(true);
895        }
896    }
897    Ok(false)
898}
899
900/// [`fillet_edges_meeting`] with cone rims rounded as `rims` says, its
901/// bands meeting at the junctions where two of its creases turn. Where
902/// that meeting refuses, the request is rounded again from the solid as it
903/// was given (its tolerances and surfaces put back), every band capped or
904/// run on at its ends as it is without junctions: a junction never turns a
905/// rounding into a refusal.
906fn fillet_edges_meeting_with(
907    model: &mut Model,
908    solid: &Shape,
909    edges: &[Shape],
910    radii: &[f64],
911    rims: Rims,
912    tol: Tolerances,
913) -> OgeomResult<Built> {
914    let mut ends: Vec<Shape> = Vec::new();
915    let mut shared = false;
916    for edge in edges {
917        if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
918            for v in [a, b] {
919                if ends.iter().any(|w| w.is_same(&v)) {
920                    shared = true;
921                }
922                ends.push(v);
923            }
924        }
925    }
926    if !shared {
927        return fillet_edges_own(model, solid, edges, radii, None, rims, None, tol);
928    }
929    // What an attempt changes of the given solid where it stands: the
930    // tolerances it widens, which the model's journal notes, and, once a
931    // junction is taken, the surfaces a marched seat continues past its
932    // faces and writes back, which the attempt notes before it builds.
933    let mark = model.note_widened();
934    // Crossed junctions first; where one does not close, the request is
935    // rounded again with the flush junctions alone, and where that does
936    // not close either, with none.
937    for crossed in [true, false] {
938        let mut taken = Taken {
939            crossed,
940            ..Taken::default()
941        };
942        match fillet_edges_own(
943            model,
944            solid,
945            edges,
946            radii,
947            Some(&mut taken),
948            rims,
949            None,
950            tol,
951        ) {
952            Ok(built) => {
953                model.keep_widened(mark);
954                return Ok(built);
955            }
956            // A cancelled request stops, and a key that does not resolve
957            // says the caller's model is not the one the shapes are in:
958            // neither is the junctions' to answer for.
959            Err(e @ (OgeomError::Cancelled | OgeomError::Dangling(_))) => {
960                model.keep_widened(mark);
961                return Err(e);
962            }
963            // With no junction taken the attempt was the rounding without
964            // one.
965            Err(e) if !taken.any => {
966                model.keep_widened(mark);
967                return Err(e);
968            }
969            Err(_) => {
970                model.undo_widened(mark);
971                for (id, surface) in taken.surfaces {
972                    if let Some(held) = model.geometry_mut().surface_mut(id) {
973                        *held = surface;
974                    }
975                }
976                // Without a crossed junction taken, the next attempt would
977                // take the same flush ones again.
978                if !taken.any_crossed {
979                    break;
980                }
981            }
982        }
983    }
984    fillet_edges_own(model, solid, edges, radii, None, rims, None, tol)
985}
986
987/// The checks a request's bands pass before any is built: each ball sits
988/// on both faces of its edge, and no band overlaps another across a face
989/// they share. Which edges, sharing nothing with the rest of the request,
990/// had their ball rest on the far edge of a narrow face beside them, to
991/// be rounded as alone, comes back.
992///
993/// # Errors
994///
995/// [`OgeomError::Construction`] where a ball does not sit on its faces or
996/// two bands overlap, naming the edges refused.
997pub(crate) fn request_fits(
998    model: &Model,
999    solid: &Shape,
1000    edges: &[Shape],
1001    radii: &[f64],
1002    tol: Tolerances,
1003) -> Result<Vec<bool>, crate::support::Blamed> {
1004    use crate::support::Blamed;
1005    let blame = |i: usize| {
1006        move |error: OgeomError| Blamed {
1007            edges: vec![i],
1008            error,
1009        }
1010    };
1011    // The ball touches each face a setback back from the edge; set back
1012    // past a face's far side, the band would cut through the face, and set
1013    // back into another edge's band, the two balls overlap.
1014    let mut contacts = Vec::with_capacity(edges.len());
1015    // An edge sharing no face and no vertex with another of the request
1016    // stands alone: no corner or junction takes its ends, and its band
1017    // is built as it is alone.
1018    let mut touched: Vec<(Vec<ogeom_topo::TShapeId>, Vec<ogeom_topo::TShapeId>)> =
1019        Vec::with_capacity(edges.len());
1020    for edge in edges {
1021        let faces = ogeom_topo::ancestors_of(model, solid, edge, ShapeType::Face)?
1022            .iter()
1023            .map(Shape::node)
1024            .collect();
1025        let ends = ogeom_algo::edge_vertices(model, edge)?
1026            .map(|(a, b)| vec![a.node(), b.node()])
1027            .unwrap_or_default();
1028        touched.push((faces, ends));
1029    }
1030    let alone = |i: usize| {
1031        let (faces, ends) = &touched[i];
1032        touched.iter().enumerate().all(|(j, (f, e))| {
1033            j == i || (!f.iter().any(|x| faces.contains(x)) && !e.iter().any(|x| ends.contains(x)))
1034        })
1035    };
1036    // Edges sharing nothing with the rest of the request whose ball rests
1037    // on the far edge of a narrow face beside them, rounded as alone.
1038    let mut rolled = vec![false; edges.len()];
1039    crate::support::keeping_face_meshes(|| -> Result<(), Blamed> {
1040        // A band checked against no other is held to what one edge alone
1041        // is.
1042        for (i, edge) in edges.iter().enumerate() {
1043            match crate::support::ball_fits(model, solid, edge, radii[i], !alone(i), tol) {
1044                Ok(found) => contacts.push(found),
1045                Err(refusal @ OgeomError::Construction(_)) if edges.len() > 1 && alone(i) => {
1046                    if crate::rolled::rolled_run(model, solid, edge, radii[i], tol)
1047                        .map_err(blame(i))?
1048                        .is_none()
1049                    {
1050                        return Err(blame(i)(refusal));
1051                    }
1052                    rolled[i] = true;
1053                    contacts.push(Vec::new());
1054                }
1055                Err(other) => return Err(blame(i)(other)),
1056            }
1057        }
1058        // A band lies within its setback of its edge, its contacts its
1059        // farthest points. A band standing alone whose ball runs off a face
1060        // is cut off on the face across that face's side, which may be one
1061        // another band stands on; where the two edges stand within their
1062        // setbacks' sum, it is checked against the others as a neighbour's
1063        // is.
1064        let reach =
1065            |found: &[crate::support::Contact]| found.iter().map(|c| c.setback).reduce(f64::max);
1066        let mut boxes = Vec::with_capacity(edges.len());
1067        for edge in edges {
1068            boxes.push(ogeom_algo::shape_bounds(model, edge, tol)?);
1069        }
1070        let reaches: Vec<Option<f64>> = contacts.iter().map(|found| reach(found)).collect();
1071        for (i, edge) in edges.iter().enumerate() {
1072            let Some(own) = reaches[i].filter(|_| alone(i)) else {
1073                continue;
1074            };
1075            let near = reaches.iter().enumerate().any(|(j, other)| {
1076                j != i
1077                    && other.is_some_and(|other| boxes[i].distance_to_box(&boxes[j]) < own + other)
1078            });
1079            if near {
1080                contacts[i] = crate::support::ball_fits(model, solid, edge, radii[i], true, tol)
1081                    .map_err(blame(i))?;
1082            }
1083        }
1084        Ok(())
1085    })?;
1086    crate::support::bands_clear(model, edges, &contacts, tol)?;
1087    Ok(rolled)
1088}
1089
1090/// What a rounding with junctions may take and took: whether crossed
1091/// junctions may be taken, whether any junction and any crossed one was,
1092/// and the solid's face surfaces as they stood before it built a band.
1093#[derive(Default)]
1094struct Taken {
1095    crossed: bool,
1096    any: bool,
1097    any_crossed: bool,
1098    surfaces: Vec<(ogeom_topo::SurfaceId, SurfaceGeometry)>,
1099}
1100
1101/// [`fillet_edges`] on a shape every node below which is its own, with
1102/// junctions where `meeting` asks, which notes there what it took.
1103#[allow(clippy::too_many_arguments, reason = "one request's settings")]
1104fn fillet_edges_own(
1105    model: &mut Model,
1106    solid: &Shape,
1107    edges: &[Shape],
1108    radii: &[f64],
1109    meeting: Option<&mut Taken>,
1110    cones: Rims,
1111    laws: Option<&[Profile]>,
1112    tol: Tolerances,
1113) -> OgeomResult<Built> {
1114    if edges.is_empty() {
1115        ogeom_bail!(Construction, "a chain of no edges rounds nothing");
1116    }
1117    let rolled = match request_fits(model, solid, edges, radii, tol) {
1118        Ok(rolled) => rolled,
1119        Err(crate::support::Blamed { error: refusal, .. }) if laws.is_some() => {
1120            return Err(refusal);
1121        }
1122        // A request of one edge is that edge alone, a strip beside it taken
1123        // off whole as there.
1124        Err(crate::support::Blamed { error: refusal, .. }) => {
1125            return match edges {
1126                [edge] if meeting.is_none() => {
1127                    consuming(model, solid, edge, radii[0], refusal, tol)
1128                }
1129                _ => Err(refusal),
1130            };
1131        }
1132    };
1133    use ogeom_geom::Curve3d as _;
1134    // The vertices three or more of the chain's edges meet at: the corners
1135    // the ball rolls round, closed by the corner tool.
1136    // Each holds the radii of the edges ending there.
1137    let mut corners: Vec<(Shape, Vec<f64>)> = Vec::new();
1138    for (edge, &radius) in edges.iter().zip(radii) {
1139        let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? else {
1140            continue;
1141        };
1142        for v in [a, b] {
1143            if let Some(slot) = corners.iter_mut().find(|(held, _)| held.is_same(&v)) {
1144                slot.1.push(radius);
1145            } else {
1146                corners.push((v, vec![radius]));
1147            }
1148        }
1149    }
1150    corners.retain(|(_, at)| at.len() >= 3);
1151    if laws.is_some() && (!corners.is_empty() || rolled.iter().any(|&r| r)) {
1152        ogeom_bail!(
1153            Construction,
1154            "a variable radius over a chain three of whose edges meet at a \
1155             vertex, or one of whose balls rests on a narrow face's far edge, \
1156             is still owed; see docs/CAPABILITIES.md, fillet.variable"
1157        );
1158    }
1159    // No ball rolls round a corner whose bands differ in radius: the
1160    // corner tool's ball would touch a band of another radius nowhere
1161    // along its rim. Such a corner is left to the bands here and closed by
1162    // the setback patch once they stand ([`mixed_corners`]).
1163    corners.retain(|(_, at)| at.iter().all(|&r| r == at[0]));
1164    // A corner the tool rounds, or the solid unchanged where it does not
1165    // speak the corner, which keeps the bands' caps.
1166    let round =
1167        |model: &mut Model, built: Built, vertex: &Shape, radius: f64| -> OgeomResult<Built> {
1168            match crate::corner::round_vertex_with(model, &built.shape, vertex, radius, None, tol) {
1169                Ok(rounded) => Ok(Built {
1170                    shape: rounded.shape,
1171                    history: built.history.then(&rounded.history),
1172                }),
1173                Err(OgeomError::Construction(_)) => Ok(built),
1174                Err(other) => Err(other),
1175            }
1176        };
1177    // The corner first, the bands after it: bands built first crash into
1178    // each other at an apex, and the corner tool wants the sharp vertex's
1179    // planes, not the bands' mitre. Corners that stand clear of each other
1180    // are cut in one boolean, the rest one after another.
1181    let mut built: Option<Built> = None;
1182    let mut rounded: Vec<Point> = Vec::new();
1183    // Corners of different radii are cut one after another.
1184    let together = if corners.len() > 1 && corners.iter().all(|(_, at)| at[0] == corners[0].1[0]) {
1185        let vertices: Vec<Shape> = corners.iter().map(|(v, _)| v.clone()).collect();
1186        crate::corner::round_vertices_together(model, solid, &vertices, corners[0].1[0], tol)?
1187    } else {
1188        None
1189    };
1190    let one_by_one: &[(Shape, Vec<f64>)] = if together.is_some() { &[] } else { &corners };
1191    if let Some(all) = together {
1192        for (vertex, _) in &corners {
1193            if let Some(point) = model
1194                .node(vertex)
1195                .and_then(|n| n.data().as_vertex())
1196                .map(|d| d.point)
1197            {
1198                rounded.push(vertex.transform(model.datums())?.apply(point));
1199            }
1200        }
1201        built = Some(all);
1202    }
1203    for (vertex, at) in one_by_one {
1204        let start = built
1205            .take()
1206            .unwrap_or_else(|| Built::from_nothing(solid.clone()));
1207        let before = start.shape.clone();
1208        let after = round(model, start, vertex, at[0])?;
1209        if !after.shape.is_same(&before)
1210            && let Some(point) = model
1211                .node(vertex)
1212                .and_then(|n| n.data().as_vertex())
1213                .map(|d| d.point)
1214        {
1215            rounded.push(vertex.transform(model.datums())?.apply(point));
1216        }
1217        built = Some(after);
1218    }
1219    let mut mates: Vec<Mate> = edges
1220        .iter()
1221        .map(|e| -> OgeomResult<Mate> {
1222            let (curve, range) = edge_curve(model, e, tol)?;
1223            let end_of = |t: f64, leaving: f64| -> OgeomResult<(Point, Vector)> {
1224                let d = curve.d1_at(t, tol)?;
1225                let m = d.magnitude();
1226                let unit = if m > tol.confusion() {
1227                    d / m
1228                } else {
1229                    Vector::ZERO
1230                };
1231                Ok((curve.point_at(t, tol)?, unit * leaving))
1232            };
1233            let ends = [end_of(range.0, 1.0)?, end_of(range.1, -1.0)?];
1234            let settled = ends.map(|(p, _)| {
1235                rounded
1236                    .iter()
1237                    .any(|q| q.distance(p) <= tol.confusion() * 1e3)
1238            });
1239            Ok(Mate {
1240                ends,
1241                settled,
1242                meets: [false, false],
1243                alone: [false, false],
1244            })
1245        })
1246        .collect::<OgeomResult<_>>()?;
1247    // The circles this chain has rounded the whole turn round. A piece of
1248    // a rim that closes on the solid in parts is blended the whole turn
1249    // round, so the rim's other pieces, asked for later or split off by the
1250    // boolean, are rounded already: done, not consumed. A piece of a rim
1251    // that stops is rounded over its own arc only, and the other pieces
1252    // still want theirs.
1253    let mut round_rims: Vec<ogeom_math::Circle> = Vec::new();
1254    let on_round_rim = |model: &Model, edge: &Shape, rims: &[ogeom_math::Circle]| {
1255        rim_circle(model, edge, tol).is_some_and(|c| rims.iter().any(|r| same_circle(r, &c, tol)))
1256    };
1257    // The two faces each edge of the chain lies between on the solid as
1258    // given. A blend along a crease runs the whole of it where the crease
1259    // closes on itself (a junction loop the march follows round), so a
1260    // later edge of the same crease that the blend consumed is rounded
1261    // already, not interfered with.
1262    let mut host_faces: ogeom_core::FastMap<ogeom_topo::TShapeId, Vec<Shape>> =
1263        ogeom_core::FastMap::default();
1264    for face in explore_unique(model, solid, ShapeType::Face)? {
1265        for e in explore_unique(model, &face, ShapeType::Edge)? {
1266            let held = host_faces.entry(e.node()).or_default();
1267            if !held.iter().any(|f| f.is_same(&face)) {
1268                held.push(face.clone());
1269            }
1270        }
1271    }
1272    // Where two of the request's creases turn at a shared vertex, their
1273    // bands meet there: built from the solid as it stands, each stops in
1274    // the plane the two meet in (carried on to it past its cap across a
1275    // re-entrant turn), and the two are joined across it into one wedge.
1276    let junctions = if meeting.is_some() {
1277        let given: Vec<Vec<Shape>> = edges
1278            .iter()
1279            .map(|e| host_faces.get(&e.node()).cloned().unwrap_or_default())
1280            .collect();
1281        let mut found = crate::junction::junctions(model, solid, edges, &given, &mates, radii, tol)
1282            .unwrap_or_default();
1283        if meeting.as_ref().is_some_and(|taken| !taken.crossed) {
1284            found.retain(|junction| !junction.crossed);
1285        }
1286        joinable(model, edges, found)?
1287    } else {
1288        Vec::new()
1289    };
1290    if let Some(taken) = meeting
1291        && !junctions.is_empty()
1292    {
1293        taken.any = true;
1294        taken.any_crossed = junctions.iter().any(|junction| junction.crossed);
1295        for face in explore_unique(model, solid, ShapeType::Face)? {
1296            let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
1297                continue;
1298            };
1299            if taken.surfaces.iter().any(|(id, _)| *id == data.surface) {
1300                continue;
1301            }
1302            if let Some(surface) = model.geometry().surface(data.surface) {
1303                taken.surfaces.push((data.surface, surface.clone()));
1304            }
1305        }
1306    }
1307    // A band stops at the vertex where a junction closes the corner; across
1308    // a crossed convex turn each runs out as it does alone.
1309    for junction in &junctions {
1310        for &index in &junction.pair {
1311            let mate = &mut mates[index];
1312            let end = usize::from(
1313                mate.ends[1].0.distance(junction.at) < mate.ends[0].0.distance(junction.at),
1314            );
1315            if junction.reentrant || !junction.crossed {
1316                mate.meets[end] = true;
1317            } else {
1318                mate.alone[end] = true;
1319            }
1320        }
1321    }
1322    let hosts_of = |edge: &Shape| -> Vec<ogeom_topo::TShapeId> {
1323        let mut out: Vec<_> = host_faces
1324            .get(&edge.node())
1325            .map(|faces| faces.iter().map(Shape::node).collect())
1326            .unwrap_or_default();
1327        out.sort_unstable();
1328        out
1329    };
1330    // The edges of the solid a round's blends are built on, by occurrence:
1331    // until a round is applied that solid is unchanged, and an edge of it
1332    // is found there by name rather than re-found by its geometry.
1333    let mut occurrences: Option<(Shape, ogeom_core::FastMap<ogeom_topo::SameKey, Shape>)> = None;
1334    let mut refind =
1335        |model: &Model, on: &Shape, edge: &Shape, hosts: &[Shape]| -> OgeomResult<Vec<Shape>> {
1336            if !occurrences
1337                .as_ref()
1338                .is_some_and(|(held, _)| held.is_same(on))
1339            {
1340                let mut by_name = ogeom_core::FastMap::default();
1341                for e in explore_unique(model, on, ShapeType::Edge)? {
1342                    by_name.entry(ogeom_topo::SameKey(e.clone())).or_insert(e);
1343                }
1344                occurrences = Some((on.clone(), by_name));
1345            }
1346            let named = occurrences
1347                .as_ref()
1348                .and_then(|(_, by_name)| by_name.get(&ogeom_topo::SameKey(edge.clone())));
1349            match named {
1350                Some(own) => Ok(vec![own.clone()]),
1351                None => refind_edges(model, on, edge, hosts, tol),
1352            }
1353        };
1354    let mut blended_creases: Vec<Vec<ogeom_topo::TShapeId>> = Vec::new();
1355    // Edges that share no vertex round without meeting: each blend is built
1356    // from the faces as they stand, and theirs can be applied together.
1357    // Edges that do share one are rounded in turn, the later built against
1358    // what the earlier left. So the chain is taken in rounds, no two edges
1359    // of a round sharing a vertex (pieces of one circle are built from the
1360    // same faces: the first rounds the whole turn where the rim closes, and
1361    // where it stops each piece is capped at its ends, flush with its
1362    // neighbour's cap), each round's wedges set
1363    // aside and applied together, one boolean each way.
1364    let rims: Vec<Option<Circle>> = edges.iter().map(|e| rim_circle(model, e, tol)).collect();
1365    let mut ends_of: Vec<Vec<Shape>> = Vec::with_capacity(edges.len());
1366    for edge in edges {
1367        let mut ends: Vec<Shape> = Vec::new();
1368        if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1369            for v in [a, b] {
1370                if !ends.iter().any(|held| held.is_same(&v)) {
1371                    ends.push(v);
1372                }
1373            }
1374        }
1375        ends_of.push(ends);
1376    }
1377    let meet = |i: usize, j: usize| {
1378        ends_of[i]
1379            .iter()
1380            .any(|v| ends_of[j].iter().any(|w| w.is_same(v)))
1381            && !matches!((&rims[i], &rims[j]), (Some(a), Some(b)) if same_circle(a, b, tol))
1382            // Two bands meeting in a junction's plane are built from the
1383            // solid as it stands and joined into one wedge.
1384            && !junctions
1385                .iter()
1386                .any(|junction| junction.slot(i).is_some() && junction.slot(j).is_some())
1387    };
1388    // Where a marched band and an analytic one meet at a vertex, the
1389    // marched band is built first, against the faces as they stand: its
1390    // run-out then melts with untouched hosts, and the analytic band built
1391    // after it runs on through it, which the melt resolves. Built the other
1392    // way round, the marched band's run-out lands on the analytic band's
1393    // rail along a stretch, a contact the melt does not resolve. Each edge
1394    // is routed on the solid as given: earlier rounds trim an edge's hosts
1395    // but leave their surfaces, which are what the route reads. The order
1396    // the edges were asked in decides everything else.
1397    let mut marched: Vec<Option<bool>> = vec![None; edges.len()];
1398    for i in 0..edges.len() {
1399        if (0..edges.len()).any(|j| j != i && meet(i, j)) {
1400            marched[i] = Some(matches!(
1401                route(model, solid, &edges[i], cones, tol)?,
1402                Route::Marched
1403            ));
1404        }
1405    }
1406    let first: Vec<bool> = (0..edges.len())
1407        .map(|i| {
1408            marched[i] == Some(true)
1409                && (0..edges.len()).any(|j| meet(i, j) && marched[j] == Some(false))
1410        })
1411        .collect();
1412    let mut taken: Vec<usize> = (0..edges.len()).collect();
1413    taken.sort_by_key(|&i| (!first[i], i));
1414    // Bands joined across a junction's plane are built in one round: each
1415    // joined group takes the first round in which none of its members
1416    // meets an edge already placed there.
1417    let mut group: Vec<usize> = (0..edges.len()).collect();
1418    for junction in &junctions {
1419        let [a, b] = junction.pair;
1420        let (from, to) = (group[a], group[b]);
1421        for g in &mut group {
1422            if *g == from {
1423                *g = to;
1424            }
1425        }
1426    }
1427    let mut round_of: Vec<Option<usize>> = vec![None; edges.len()];
1428    for &i in &taken {
1429        if round_of[i].is_some() {
1430            continue;
1431        }
1432        let members: Vec<usize> = (0..edges.len()).filter(|&m| group[m] == group[i]).collect();
1433        let mut round = 0;
1434        while members
1435            .iter()
1436            .any(|&m| (0..edges.len()).any(|j| round_of[j] == Some(round) && meet(m, j)))
1437        {
1438            round += 1;
1439        }
1440        for m in members {
1441            round_of[m] = Some(round);
1442        }
1443    }
1444    let round_of: Vec<usize> = round_of.into_iter().map(|r| r.unwrap_or(0)).collect();
1445    let rank: Vec<usize> = {
1446        let mut rank = vec![0; edges.len()];
1447        for (k, &i) in taken.iter().enumerate() {
1448            rank[i] = k;
1449        }
1450        rank
1451    };
1452    let mut order: Vec<usize> = (0..edges.len()).collect();
1453    order.sort_by_key(|&i| (round_of[i], rank[i]));
1454    let mut set_aside: Vec<(Vec<usize>, Shape, crate::support::Wedge)> = Vec::new();
1455    let mut this_round = 0;
1456    for index in order {
1457        let edge = &edges[index];
1458        // A round's wedges are applied before the next round is built.
1459        if round_of[index] != this_round {
1460            this_round = round_of[index];
1461            if let Some(b) = built.take() {
1462                let halves = with_corners(model, &mut set_aside, &junctions, &b.shape, false, tol)?;
1463                let joined = join_flush(
1464                    model,
1465                    core::mem::take(&mut set_aside),
1466                    &junctions,
1467                    &halves,
1468                    tol,
1469                )?;
1470                built = Some(apply_set_aside(model, b, joined, tol)?);
1471            }
1472        }
1473        // A piece of a circle whose whole turn another piece's blend rounds.
1474        if on_round_rim(model, edge, &round_rims) {
1475            if let Some(b) = built.as_mut() {
1476                b.history.delete(edge);
1477            }
1478            continue;
1479        }
1480        // The faces the edge lies between on the solid as given: what a
1481        // piece of it re-found later still lies between.
1482        let hosts: Vec<Shape> = host_faces.get(&edge.node()).cloned().unwrap_or_default();
1483        // The edge as it stands on the current solid: itself on the first
1484        // step, and afterwards whatever the earlier blends left of it: one
1485        // re-found stand-in, or the pieces a blend running out across it
1486        // split it into, each of which is a seat of its own ending against
1487        // that blend's band, which is exactly the corner it then meets.
1488        // The pieces are re-found from the edge itself, between its hosts:
1489        // the history also traces it to stubs along its line where an
1490        // earlier blend's cap meets a host tangentially, no corner to round.
1491        let (current, targets): (Shape, Vec<Shape>) = match &built {
1492            None => (solid.clone(), vec![edge.clone()]),
1493            Some(b) => {
1494                let gone = b.history.trace(edge).is_empty();
1495                // An edge both of whose ends this chain's corners rounded,
1496                // and which they left nothing of, ran its whole length
1497                // inside their balls: the corners are its blend.
1498                let on_blended_crease = blended_creases.contains(&hosts_of(edge))
1499                    || mates[index].settled == [true, true];
1500                let rounded = on_blended_crease || on_round_rim(model, edge, &round_rims);
1501                if gone && rounded {
1502                    continue;
1503                }
1504                if gone {
1505                    ogeom_bail!(
1506                        Construction,
1507                        "an earlier blend in the chain consumed this edge; \
1508                         the chain's members interfere"
1509                    );
1510                }
1511                let found = match refind(model, &b.shape, edge, &hosts) {
1512                    Ok(live) => live,
1513                    Err(_) if rounded => Vec::new(),
1514                    Err(e) => return Err(e),
1515                };
1516                (b.shape.clone(), found)
1517            }
1518        };
1519        // Nothing left of it: the crease it lies on is rounded, and the
1520        // edge with it.
1521        if targets.is_empty() {
1522            if let Some(b) = built.as_mut() {
1523                b.history.delete(edge);
1524            }
1525            continue;
1526        }
1527        let mut current = current;
1528        for target in &targets {
1529            // Each piece's blend replaces the solid; the next piece is
1530            // re-found on what that blend left.
1531            let live = match refind(model, &current, target, &hosts) {
1532                Ok(live) => live,
1533                Err(_) if on_round_rim(model, target, &round_rims) => continue,
1534                Err(e) => return Err(e),
1535            };
1536            let [target] = live.as_slice() else {
1537                ogeom_bail!(
1538                    Construction,
1539                    "a blend in the chain split a piece of a later edge again; \
1540                     the chain's members interfere"
1541                );
1542            };
1543            let whole_rim = match rim_circle(model, target, tol) {
1544                Some(rim) if rounds_whole_turn(model, &current, target, tol)? => Some(rim),
1545                _ => None,
1546            };
1547            let mut step = {
1548                let (step, wedges) = crate::support::collecting_wedges(|| {
1549                    if rolled[index] {
1550                        let Some(mut built) = crate::rolled::rolled_fillet(
1551                            model,
1552                            &current,
1553                            target,
1554                            radii[index],
1555                            tol,
1556                        )?
1557                        else {
1558                            ogeom_bail!(
1559                                Construction,
1560                                "the ball no longer rests on the far edge beside an edge \
1561                                 of the chain"
1562                            );
1563                        };
1564                        crate::support::credit_new_faces(model, &current, target, &mut built)?;
1565                        return Ok(built);
1566                    }
1567                    if let Some(laws) = laws {
1568                        return law_piece(
1569                            model,
1570                            &current,
1571                            &edges[index],
1572                            target,
1573                            &laws[index],
1574                            (index, &mates),
1575                            tol,
1576                        );
1577                    }
1578                    fillet_edge_meeting(
1579                        model,
1580                        &current,
1581                        target,
1582                        radii[index],
1583                        Some((index, &mates)),
1584                        cones,
1585                        tol,
1586                    )
1587                });
1588                let step = step?;
1589                for mut wedge in wedges {
1590                    if !wedge.additive {
1591                        for junction in &junctions {
1592                            let Some(slot) = junction.slot(index) else {
1593                                continue;
1594                            };
1595                            // A crossed junction's bands stand as built:
1596                            // a re-entrant one's corner is built from the
1597                            // two once the round has set both aside.
1598                            if junction.crossed {
1599                                continue;
1600                            }
1601                            let plane = junction.keeps(slot);
1602                            // Across a re-entrant turn the band is carried
1603                            // on past its cap to the plane it meets the
1604                            // other in; across a convex one it is trimmed
1605                            // back to it. Nothing of the band but its end
1606                            // stands within two radii of the vertex across
1607                            // the plane: a band of its own edge coming back
1608                            // that close would leave no room for its ball.
1609                            if junction.reentrant {
1610                                let Some(cap) = crate::junction::cap_at(
1611                                    model,
1612                                    &wedge.solid,
1613                                    junction.at,
1614                                    junction.outward[slot],
1615                                    tol,
1616                                )?
1617                                else {
1618                                    ogeom_bail!(
1619                                        Construction,
1620                                        "a band meeting another across a re-entrant turn has \
1621                                         no cap at the vertex to carry on from"
1622                                    );
1623                                };
1624                                wedge.solid = crate::junction::carried_into(
1625                                    model,
1626                                    &wedge.solid,
1627                                    &cap,
1628                                    (edge, &hosts),
1629                                    junction.outward[slot],
1630                                    plane,
1631                                    tol,
1632                                )?;
1633                            } else {
1634                                wedge.solid = crate::support::trimmed_wedge(
1635                                    model,
1636                                    &wedge.solid,
1637                                    plane,
1638                                    radii[index] * 2.0,
1639                                    tol,
1640                                )?;
1641                            }
1642                        }
1643                    }
1644                    set_aside.push((vec![index], edge.clone(), wedge));
1645                }
1646                step
1647            };
1648            if let Some(rim) = whole_rim {
1649                round_rims.push(rim);
1650            }
1651            let hosts = hosts_of(edge);
1652            if hosts.len() == 2 && !blended_creases.contains(&hosts) {
1653                blended_creases.push(hosts);
1654            }
1655            // The blend consumed the re-found stand-in; the caller's edge is
1656            // the same fact under its original name.
1657            if !target.is_same(edge) {
1658                step.history.delete(edge);
1659            }
1660            current = step.shape.clone();
1661            built = Some(match built {
1662                None => step,
1663                Some(prev) => Built {
1664                    shape: step.shape.clone(),
1665                    history: prev.history.then(&step.history),
1666                },
1667            });
1668        }
1669    }
1670    let built = built.unwrap_or_else(|| unreachable!());
1671    let halves = with_corners(model, &mut set_aside, &junctions, &built.shape, false, tol)?;
1672    let joined = join_flush(model, set_aside, &junctions, &halves, tol)?;
1673    apply_set_aside(model, built, joined, tol)
1674}
1675
1676/// The junctions of `found` whose bands can be joined: taken in turn, each
1677/// joins the groups of bands already joined at its two edges, and is
1678/// dropped where two edges of those groups also meet at another vertex,
1679/// which joined into one round would lose the later band's running on
1680/// through the earlier there.
1681fn joinable(
1682    model: &Model,
1683    edges: &[Shape],
1684    found: Vec<crate::junction::Junction>,
1685) -> OgeomResult<Vec<crate::junction::Junction>> {
1686    let mut ends: Vec<Vec<Shape>> = Vec::with_capacity(edges.len());
1687    for edge in edges {
1688        ends.push(
1689            ogeom_algo::edge_vertices(model, edge)?
1690                .map(|(a, b)| vec![a, b])
1691                .unwrap_or_default(),
1692        );
1693    }
1694    // Flush junctions are taken first: a crossed junction joins its two
1695    // bands only where that leaves every flush one standing.
1696    let (flush, crossed): (Vec<_>, Vec<_>) = found.into_iter().partition(|j| !j.crossed);
1697    let mut found = flush.clone();
1698    found.extend(crossed);
1699    let pending: Vec<[usize; 2]> = found.iter().map(|j| j.pair).collect();
1700    let mut group: Vec<usize> = (0..edges.len()).collect();
1701    let mut kept: Vec<crate::junction::Junction> = Vec::new();
1702    for junction in found {
1703        let [a, b] = junction.pair;
1704        if group[a] == group[b] {
1705            // A junction closing a loop of joined bands joins no groups,
1706            // and is taken for its corner. Each junction stands at a vertex
1707            // of its own, so two bands meeting at both their ends (a pocket
1708            // of a chord and its arc) close a loop of two there.
1709            kept.push(junction);
1710            continue;
1711        }
1712        // Two edges of the groups this joins that meet elsewhere must meet
1713        // there in a junction of their own: one taken, this one, or a
1714        // crossed one still to come.
1715        let joined = |m: usize, n: usize| {
1716            kept.iter()
1717                .chain(std::iter::once(&junction))
1718                .any(|j| j.slot(m).is_some() && j.slot(n).is_some())
1719                || pending
1720                    .iter()
1721                    .any(|pair| pair.contains(&m) && pair.contains(&n))
1722        };
1723        let of_a: Vec<usize> = (0..edges.len()).filter(|&m| group[m] == group[a]).collect();
1724        let of_b: Vec<usize> = (0..edges.len()).filter(|&m| group[m] == group[b]).collect();
1725        let clash = of_a.iter().any(|&m| {
1726            of_b.iter().any(|&n| {
1727                !joined(m, n) && ends[m].iter().any(|v| ends[n].iter().any(|w| w.is_same(v)))
1728            })
1729        });
1730        if clash {
1731            continue;
1732        }
1733        let (from, to) = (group[a], group[b]);
1734        for g in &mut group {
1735            if *g == from {
1736                *g = to;
1737            }
1738        }
1739        kept.push(junction);
1740    }
1741    // Every two edges of one joined group that share a vertex meet there
1742    // in a kept junction; where one was dropped, the request is taken with
1743    // its flush junctions alone.
1744    let meets_unjoined = (0..edges.len()).any(|m| {
1745        (m + 1..edges.len()).any(|n| {
1746            group[m] == group[n]
1747                && ends[m].iter().any(|v| ends[n].iter().any(|w| w.is_same(v)))
1748                && !kept
1749                    .iter()
1750                    .any(|j| j.slot(m).is_some() && j.slot(n).is_some())
1751        })
1752    });
1753    if meets_unjoined && kept.iter().any(|j| j.crossed) {
1754        return joinable(model, edges, flush);
1755    }
1756    Ok(kept)
1757}
1758
1759/// Join, among the wedges a round set aside, the two bands of each crossed
1760/// re-entrant junction the round built into the one wedge that also takes
1761/// their corner, in the material of `solid`, the solid the round's wedges
1762/// apply to. The joined wedge is set aside with the first band's edge, its
1763/// faces from the second band credited to the second's.
1764///
1765/// A band with a crossed junction at each end is first cut in two in the
1766/// plane square to its crease halfway along: each half is the straight
1767/// band of its own end section, joined into its own corner, and the two
1768/// corners' wedges are joined again across that plane, where both hold the
1769/// band's one section. Returns those planes, each with the band's index.
1770/// `law` says the bands are under a radius law, their sections changing up
1771/// to the vertex, as [`crate::junction::corner`] takes them.
1772fn with_corners(
1773    model: &mut Model,
1774    set_aside: &mut Vec<(Vec<usize>, Shape, crate::support::Wedge)>,
1775    junctions: &[crate::junction::Junction],
1776    solid: &Shape,
1777    law: bool,
1778    tol: Tolerances,
1779) -> OgeomResult<Vec<(usize, (Point, Vector))>> {
1780    let crossed: Vec<&crate::junction::Junction> = junctions
1781        .iter()
1782        .filter(|j| j.crossed && j.reentrant)
1783        .collect();
1784    let holding = |set_aside: &[(Vec<usize>, Shape, crate::support::Wedge)], index: usize| {
1785        (0..set_aside.len())
1786            .filter(|&k| set_aside[k].0.contains(&index))
1787            .collect::<Vec<usize>>()
1788    };
1789    let mut halves = Vec::new();
1790    let indices: Vec<usize> = crossed.iter().flat_map(|j| j.pair).collect();
1791    for &index in &indices {
1792        let ends: Vec<Point> = crossed
1793            .iter()
1794            .filter(|j| j.slot(index).is_some())
1795            .map(|j| j.at)
1796            .collect();
1797        if ends.len() != 2 || halves.iter().any(|(i, _)| *i == index) {
1798            continue;
1799        }
1800        let held = holding(set_aside, index);
1801        let [k] = held.as_slice() else {
1802            continue;
1803        };
1804        let k = *k;
1805        let along = ends[1] - ends[0];
1806        let length = along.magnitude();
1807        if length <= tol.confusion() {
1808            ogeom_bail!(Construction, "a band crossed at both ends has no length");
1809        }
1810        let toward = along / length;
1811        let middle = ends[0].midpoint(ends[1]);
1812        let span = length + crate::marched::band_fit_target(tol).max(length);
1813        let (indices_k, edge, wedge) = set_aside.remove(k);
1814        for keep in [-toward, toward] {
1815            let half =
1816                crate::support::trimmed_wedge(model, &wedge.solid, (middle, keep), span, tol)?;
1817            set_aside.push((
1818                indices_k.clone(),
1819                edge.clone(),
1820                crate::support::Wedge {
1821                    solid: half,
1822                    additive: wedge.additive,
1823                    edges: wedge.edges.clone(),
1824                    melt_refusal: wedge.melt_refusal,
1825                    credit: Vec::new(),
1826                },
1827            ));
1828        }
1829        halves.push((index, (middle, toward)));
1830    }
1831    for junction in crossed {
1832        // Each side's band: the one wedge holding it, or of a band cut in
1833        // two, the half ending at this junction's vertex.
1834        let mut sides = [None, None];
1835        for (slot, side) in sides.iter_mut().enumerate() {
1836            // A band not yet joined into another corner holds no other.
1837            let held = holding(set_aside, junction.pair[slot])
1838                .into_iter()
1839                .filter(|&k| set_aside[k].0.len() == 1);
1840            let mut here = Vec::new();
1841            for k in held {
1842                if crate::junction::cap_at(
1843                    model,
1844                    &set_aside[k].2.solid,
1845                    junction.at,
1846                    junction.outward[slot],
1847                    tol,
1848                )?
1849                .is_some()
1850                {
1851                    here.push(k);
1852                }
1853            }
1854            *side = match here.as_slice() {
1855                [] => None,
1856                [k] => Some(*k),
1857                _ => ogeom_bail!(
1858                    Construction,
1859                    "a crossed junction's band is more than one cut wedge in its round"
1860                ),
1861            };
1862        }
1863        // Built in the same round, or not yet.
1864        let (a, b) = match sides {
1865            [None, None] => continue,
1866            [Some(a), Some(b)] if a != b => (a, b),
1867            _ => ogeom_bail!(
1868                Construction,
1869                "a crossed junction's bands are not one cut wedge each in their round"
1870            ),
1871        };
1872        if set_aside[a].2.additive || set_aside[b].2.additive {
1873            ogeom_bail!(Construction, "a crossed junction's band is a fill");
1874        }
1875        if !set_aside[a].2.credit.is_empty() || !set_aside[b].2.credit.is_empty() {
1876            ogeom_bail!(Construction, "a crossed junction's band is already joined");
1877        }
1878        let (taken, of_second) = crate::junction::corner(
1879            model,
1880            [&set_aside[a].2.solid, &set_aside[b].2.solid],
1881            junction,
1882            solid,
1883            law,
1884            tol,
1885        )?;
1886        let second = set_aside[b].1.clone();
1887        let later = set_aside.remove(a.max(b));
1888        let earlier = set_aside.remove(a.min(b));
1889        let ((mut indices, edge, first), (others, other, last)) = if a < b {
1890            (earlier, later)
1891        } else {
1892            (later, earlier)
1893        };
1894        indices.extend(others);
1895        let mut rounded = first.edges;
1896        rounded.extend(last.edges);
1897        rounded.push(other);
1898        set_aside.push((
1899            indices,
1900            edge,
1901            crate::support::Wedge {
1902                solid: taken,
1903                additive: false,
1904                edges: rounded,
1905                melt_refusal: first.melt_refusal.or(last.melt_refusal),
1906                credit: of_second
1907                    .into_iter()
1908                    .map(|face| (face, second.clone()))
1909                    .collect(),
1910            },
1911        ));
1912    }
1913    Ok(halves)
1914}
1915
1916/// The wedges a round set aside, those of two bands meeting in a
1917/// junction's plane joined into one across it, each wedge with the edge it
1918/// rounds.
1919fn join_flush(
1920    model: &mut Model,
1921    set_aside: Vec<(Vec<usize>, Shape, crate::support::Wedge)>,
1922    junctions: &[crate::junction::Junction],
1923    halves: &[(usize, (Point, Vector))],
1924    tol: Tolerances,
1925) -> OgeomResult<Vec<(Shape, crate::support::Wedge)>> {
1926    // Each entry with every edge of the request whose band it holds.
1927    let mut held: Vec<Option<(Vec<usize>, Shape, crate::support::Wedge)>> =
1928        set_aside.into_iter().map(Some).collect();
1929    // The flush junctions, and the planes a band crossed at both ends was
1930    // cut in, each as the plane, a point of it the meeting stands at, and
1931    // the edges on either side.
1932    let meetings: Vec<((Point, Vector), Point, [usize; 2])> = junctions
1933        .iter()
1934        .filter(|j| !j.crossed)
1935        .map(|j| (j.flush, j.at, j.pair))
1936        .chain(
1937            halves
1938                .iter()
1939                .map(|&(index, (on, normal))| ((on, normal), on, [index, index])),
1940        )
1941        .collect();
1942    for ((on, normal), at, pair) in meetings {
1943        // The wedge holding each side's piece that ends in the plane, the
1944        // two sides two wedges.
1945        let mut sides = [None, None];
1946        for slot in 0..2 {
1947            for (k, entry) in held.iter().enumerate() {
1948                let Some((indices, _, wedge)) = entry else {
1949                    continue;
1950                };
1951                if !indices.contains(&pair[slot]) || (slot == 1 && sides[0] == Some(k)) {
1952                    continue;
1953                }
1954                let gap = crate::marched::band_fit_target(tol) * 2.0;
1955                if crate::junction::ends_in(model, &wedge.solid, (on, normal), at, gap, tol)? {
1956                    sides[slot] = Some(k);
1957                    if slot == 0 {
1958                        break;
1959                    }
1960                }
1961            }
1962        }
1963        let [Some(a), Some(b)] = sides else {
1964            continue;
1965        };
1966        if a == b {
1967            // A loop closing on itself: its wedge's two ends in the plane
1968            // are sewn to each other.
1969            let Some((indices, edge, ring)) = held[a].take() else {
1970                continue;
1971            };
1972            let (solid, traced) = crate::junction::joined(
1973                model,
1974                [&ring.solid, &ring.solid],
1975                (on, normal),
1976                at,
1977                crate::marched::band_fit_target(tol) * 2.0,
1978                tol,
1979            )?;
1980            let mut credit = Vec::new();
1981            for (given, image) in &traced {
1982                let owner = ring
1983                    .credit
1984                    .iter()
1985                    .find(|(f, _)| f.is_same(given))
1986                    .map_or_else(|| edge.clone(), |(_, e)| e.clone());
1987                credit.push((image.clone(), owner));
1988            }
1989            held[a] = Some((
1990                indices,
1991                edge,
1992                crate::support::Wedge {
1993                    solid,
1994                    credit,
1995                    ..ring
1996                },
1997            ));
1998            continue;
1999        }
2000        let (Some((mut indices, edge, first)), Some((others, other, second))) =
2001            (held[a].take(), held[b].take())
2002        else {
2003            continue;
2004        };
2005        let (solid, traced) = crate::junction::joined(
2006            model,
2007            [&first.solid, &second.solid],
2008            (on, normal),
2009            at,
2010            crate::marched::band_fit_target(tol) * 2.0,
2011            tol,
2012        )?;
2013        // Each face of the joined wedge stays credited to the edge whose
2014        // band it came from.
2015        let mut credit: Vec<(Shape, Shape)> = Vec::new();
2016        for (from, rounded) in [(&first, &edge), (&second, &other)] {
2017            for face in ogeom_topo::explore_unique(model, &from.solid, ShapeType::Face)? {
2018                let owner = from
2019                    .credit
2020                    .iter()
2021                    .find(|(f, _)| f.is_same(&face))
2022                    .map_or_else(|| rounded.clone(), |(_, e)| e.clone());
2023                for (given, image) in &traced {
2024                    if given.is_same(&face) {
2025                        credit.push((image.clone(), owner.clone()));
2026                    }
2027                }
2028            }
2029        }
2030        indices.extend(others);
2031        indices.dedup();
2032        let mut rounded: Vec<Shape> = first.edges;
2033        rounded.extend(second.edges);
2034        if !other.is_same(&edge) {
2035            rounded.push(other);
2036        }
2037        held[a] = Some((
2038            indices,
2039            edge,
2040            crate::support::Wedge {
2041                solid,
2042                additive: false,
2043                edges: rounded,
2044                melt_refusal: first.melt_refusal.or(second.melt_refusal),
2045                credit,
2046            },
2047        ));
2048    }
2049    Ok(held
2050        .into_iter()
2051        .flatten()
2052        .map(|(_, edge, wedge)| (edge, wedge))
2053        .collect())
2054}
2055
2056/// Apply the wedges a chain set aside: the cut ones together and the
2057/// fused ones together, each blend's faces credited to its edge through
2058/// the boolean's history. Wedges whose boxes meet go in different
2059/// booleans, as a compound of lumps that overlap would count their overlap
2060/// twice. A cut is [`ogeom_bool::cut_seated`] and a fill
2061/// [`ogeom_bool::fuse_seated`], which trim the faces straight bands, rims
2062/// and the pieces of rims a tangent chain's arcs round sit on, where nothing
2063/// else reaches into them, and straight bands running at a block's corner
2064/// into a band an earlier group rounded.
2065pub(crate) fn apply_set_aside(
2066    model: &mut Model,
2067    mut built: Built,
2068    set_aside: Vec<(Shape, crate::support::Wedge)>,
2069    tol: Tolerances,
2070) -> OgeomResult<Built> {
2071    if set_aside.is_empty() {
2072        return Ok(built);
2073    }
2074    let mut boxes = Vec::with_capacity(set_aside.len());
2075    for (_, wedge) in &set_aside {
2076        boxes.push(ogeom_algo::shape_bounds(model, &wedge.solid, tol)?);
2077    }
2078    let (fused, cut): (Vec<usize>, Vec<usize>) =
2079        (0..set_aside.len()).partition(|&i| set_aside[i].1.additive);
2080    // A cut and a fill whose boxes meet are applied in the order they were
2081    // built, one by one: which goes first decides what the corner keeps.
2082    let crossed = cut
2083        .iter()
2084        .any(|&i| fused.iter().any(|&j| boxes[i].intersects(&boxes[j])));
2085    let groups: Vec<Vec<usize>> = if crossed {
2086        (0..set_aside.len()).map(|i| vec![i]).collect()
2087    } else {
2088        // Each side's wedges in as few groups as keep every group's boxes
2089        // apart, each group one boolean: blends close side by side (rims on
2090        // a tight grid) take a handful of booleans, not one each.
2091        let mut groups: Vec<Vec<usize>> = Vec::new();
2092        for side in [cut, fused] {
2093            let first = groups.len();
2094            for i in side {
2095                let slot = groups[first..]
2096                    .iter()
2097                    .position(|g| g.iter().all(|&j| !boxes[i].intersects(&boxes[j])));
2098                match slot {
2099                    Some(k) => groups[first + k].push(i),
2100                    None => groups.push(vec![i]),
2101                }
2102            }
2103        }
2104        groups
2105    };
2106    let given = built.clone();
2107    let mut groups: std::collections::VecDeque<Vec<usize>> = groups.into();
2108    while let Some(group) = groups.pop_front() {
2109        let additive = set_aside[group[0]].1.additive;
2110        let lumps: Vec<Shape> = group
2111            .iter()
2112            .map(|&i| set_aside[i].1.solid.clone())
2113            .collect();
2114        let tool = if let [one] = lumps.as_slice() {
2115            one.clone()
2116        } else {
2117            model.add_compound(&lumps)?
2118        };
2119        let applied = if additive {
2120            ogeom_bool::fuse_seated(model, &built.shape, &tool, tol)
2121        } else {
2122            ogeom_bool::cut_seated(model, &built.shape, &tool, tol)
2123        };
2124        // A group the boolean does not resolve at once is no refusal: the
2125        // wedges are then applied one at a time, in the order they were
2126        // built, to the solid as it was given.
2127        if matches!(applied, Err(OgeomError::NotDone(_))) && group.len() > 1 {
2128            built = given.clone();
2129            groups = (0..set_aside.len()).map(|i| vec![i]).collect();
2130            continue;
2131        }
2132        let refusal = group.iter().find_map(|&i| set_aside[i].1.melt_refusal);
2133        let step = match (applied, refusal) {
2134            (Err(OgeomError::NotDone(cause)), Some(refusal)) => {
2135                ogeom_bail!(NotDone, "{refusal} ({cause})")
2136            }
2137            (applied, _) => applied?,
2138        };
2139        // The chain's history already says each edge is gone; the blends
2140        // it generated are recorded on the composed history, where an edge
2141        // consumed before this step still answers for them.
2142        let mut history = built.history.then(&step.history);
2143        let result = ogeom_topo::explore_unique(model, &step.shape, ShapeType::Face)?;
2144        for &i in &group {
2145            let (edge, wedge) = &set_aside[i];
2146            for face in ogeom_topo::explore_unique(model, &wedge.solid, ShapeType::Face)? {
2147                let owner = wedge
2148                    .credit
2149                    .iter()
2150                    .find(|(f, _)| f.is_same(&face))
2151                    .map_or(edge, |(_, e)| e);
2152                for image in step.history.trace(&face) {
2153                    if result.iter().any(|f| f.is_same(image)) {
2154                        history.generate(owner, image.clone());
2155                    }
2156                }
2157            }
2158            for rounded in &wedge.edges {
2159                history.delete(rounded);
2160            }
2161            history.delete(edge);
2162        }
2163        built = Built {
2164            shape: step.shape.clone(),
2165            history,
2166        };
2167    }
2168    Ok(built)
2169}
2170
2171/// Round a straight convex edge with a blend whose radius runs linearly from
2172/// `start_radius` at the edge's start to `end_radius` at its end.
2173///
2174/// For a linear law on a straight edge between planes the rolling ball's
2175/// envelope is *exactly* a rational B-spline surface (degree one along the
2176/// edge, a rational quadratic arc across it, the control net affine in the
2177/// radius), so nothing here is fitted. The tangency lines are straight, the
2178/// legs stay planar, and the wedge subtracts through the boolean like its
2179/// constant-radius siblings.
2180///
2181/// What `solid` shares with other shapes is copied before it is
2182/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
2183///
2184/// # Errors
2185///
2186/// As [`fillet_edge`], for the straight planar seat only.
2187pub fn fillet_edge_variable(
2188    model: &mut Model,
2189    solid: &Shape,
2190    edge: &Shape,
2191    start_radius: f64,
2192    end_radius: f64,
2193    tol: Tolerances,
2194) -> OgeomResult<Built> {
2195    ogeom_algo::on_own_nodes(
2196        model,
2197        solid,
2198        std::slice::from_ref(edge),
2199        |model, solid, parts| {
2200            fillet_edge_variable_own(model, solid, &parts[0], start_radius, end_radius, tol)
2201        },
2202    )
2203}
2204
2205/// [`fillet_edge_variable`] on a shape every node below which is its own.
2206fn fillet_edge_variable_own(
2207    model: &mut Model,
2208    solid: &Shape,
2209    edge: &Shape,
2210    start_radius: f64,
2211    end_radius: f64,
2212    tol: Tolerances,
2213) -> OgeomResult<Built> {
2214    for radius in [start_radius, end_radius] {
2215        if !radius.is_finite() || radius <= tol.confusion() {
2216            ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
2217        }
2218    }
2219    if (start_radius - end_radius).abs() <= tol.confusion() {
2220        return fillet_edge_own(model, solid, edge, start_radius, tol);
2221    }
2222    let law = Profile::new(
2223        &RadiusLaw::Linear {
2224            start: start_radius,
2225            end: end_radius,
2226        },
2227        false,
2228        tol,
2229    )?;
2230    if let Some(built) = consumed_law(model, solid, edge, &law, false, tol)? {
2231        return Ok(built);
2232    }
2233    law_ball_on_faces(model, solid, edge, &law, Some(false), tol)?;
2234    let seat = planar_seat(model, solid, edge, tol)?;
2235    let (first, second) = if seat
2236        .along
2237        .dot(seat.normals[0].cross(seat.normals[1]))
2238        .is_sign_positive()
2239    {
2240        (0, 1)
2241    } else {
2242        (1, 0)
2243    };
2244    let sign = if seat.convex { 1.0 } else { -1.0 };
2245    let n1 = seat.normals[first] * sign;
2246    let n2 = seat.normals[second] * sign;
2247    let a = seat.leg(first, tol)? * sign;
2248    let b = seat.leg(second, tol)? * sign;
2249    let bisector = {
2250        let u = a + b;
2251        let m = u.magnitude();
2252        if m <= tol.angular() {
2253            ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
2254        }
2255        u / m
2256    };
2257    let depth = -n1.dot(bisector);
2258    if depth <= tol.angular() {
2259        ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
2260    }
2261    let sweep = seat.along.dot(n1.cross(n2)).atan2(n1.dot(n2)) * sign;
2262    if sweep <= tol.angular() {
2263        ogeom_bail!(Construction, "the faces are parallel; there is no corner");
2264    }
2265
2266    // The sections at the two ends: everything else is affine between them.
2267    // On a concave edge every direction above is already mirrored: the
2268    // arithmetic below cannot tell which case it serves.
2269    let radii = [start_radius, end_radius];
2270    let apex = [seat.start, seat.end];
2271    let centre = [
2272        apex[0] + bisector * (radii[0] / depth),
2273        apex[1] + bisector * (radii[1] / depth),
2274    ];
2275    let contact_a = [centre[0] + n1 * radii[0], centre[1] + n1 * radii[1]];
2276    let contact_b = [centre[0] + n2 * radii[0], centre[1] + n2 * radii[1]];
2277
2278    // The unit arc from n1 to n2 as a rational quadratic: the middle control
2279    // point is where the end tangents meet, its weight the half-angle cosine.
2280    let half_cos = (sweep / 2.0).cos();
2281    let arc_mid = (n1 + n2) / (1.0 + n1.dot(n2));
2282    let weights = [1.0, half_cos, 1.0];
2283
2284    // The blend: degree one by rational quadratic, the control net
2285    // c_i + r_i * V_j, weights carried across the rows unchanged. Every
2286    // section of the surface is that section's exact blend arc.
2287    let u_knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
2288    let v_knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
2289    let mut net: Vec<ogeom_math::Weighted<Point>> = Vec::with_capacity(6);
2290    for i in 0..2 {
2291        let arms = [n1, arc_mid, n2];
2292        for (j, arm) in arms.iter().enumerate() {
2293            net.push(ogeom_math::Weighted::new(
2294                centre[i] + *arm * radii[i],
2295                weights[j],
2296                tol,
2297            )?);
2298        }
2299    }
2300    let grid = ogeom_math::ControlGrid::new(net, 2, 3)?;
2301    let surface: SurfaceGeometry =
2302        ogeom_geom::BSplineSurface::rational(u_knots, v_knots, grid)?.into();
2303    let surface_id = model.geometry_mut().add_surface(surface.clone());
2304
2305    // Shared vertices at the four tangency corners.
2306    let va = [
2307        make_vertex(model, contact_a[0]).shape,
2308        make_vertex(model, contact_a[1]).shape,
2309    ];
2310    let vb = [
2311        make_vertex(model, contact_b[0]).shape,
2312        make_vertex(model, contact_b[1]).shape,
2313    ];
2314
2315    // The tangency lines, straight because the law is linear, with
2316    // degree-one pcurves mapping their arc length onto the chart's rows.
2317    let rail = |model: &mut Model,
2318                from: (&Shape, Point),
2319                to: (&Shape, Point),
2320                row: f64|
2321     -> OgeomResult<Shape> {
2322        let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
2323        let curve = Curve::Line(line);
2324        let domain = ogeom_geom::Curve3d::domain(&curve);
2325        let built = make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape;
2326        let knots = ogeom_math::KnotVector::new(vec![domain.0, domain.0, domain.1, domain.1], 1)?;
2327        let pcurve = ogeom_geom::BSpline2d::new(
2328            knots,
2329            vec![
2330                ogeom_math::Point2::new(0.0, row),
2331                ogeom_math::Point2::new(1.0, row),
2332            ],
2333            tol,
2334        )?;
2335        ogeom_algo::attach_pcurve(
2336            model,
2337            &built,
2338            pcurve.into(),
2339            surface_id,
2340            ogeom_topo::Location::identity(),
2341            domain,
2342        )?;
2343        Ok(built)
2344    };
2345    let rail_a = rail(model, (&va[0], contact_a[0]), (&va[1], contact_a[1]), 0.0)?;
2346    let rail_b = rail(model, (&vb[0], contact_b[0]), (&vb[1], contact_b[1]), 1.0)?;
2347
2348    // The end arcs as the surface's own rational sections, so the cap edges
2349    // and the blend's chart speak the same parameter.
2350    let arc_edge = |model: &mut Model, i: usize| -> OgeomResult<Shape> {
2351        let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
2352        let control = vec![
2353            ogeom_math::Weighted::new(contact_a[i], 1.0, tol)?,
2354            ogeom_math::Weighted::new(centre[i] + arc_mid * radii[i], half_cos, tol)?,
2355            ogeom_math::Weighted::new(contact_b[i], 1.0, tol)?,
2356        ];
2357        let curve = Curve::BSpline(ogeom_geom::BSplineCurve::rational(knots, control)?);
2358        let built = make_edge_between(model, curve, (0.0, 1.0), &va[i], &vb[i], tol)?.shape;
2359        #[allow(clippy::cast_precision_loss)]
2360        let column = ogeom_geom::Line2d::over(
2361            ogeom_math::Axis2::new(
2362                ogeom_math::Point2::new(i as f64, 0.0),
2363                ogeom_math::Direction2::Y,
2364            ),
2365            0.0,
2366            1.0,
2367        )?;
2368        ogeom_algo::attach_pcurve(
2369            model,
2370            &built,
2371            column.into(),
2372            surface_id,
2373            ogeom_topo::Location::identity(),
2374            (0.0, 1.0),
2375        )?;
2376        Ok(built)
2377    };
2378    let arc0 = arc_edge(model, 0)?;
2379    let arc1 = arc_edge(model, 1)?;
2380
2381    // The blend face on the registered surface, oriented so its outward side
2382    // leaves the wedge, decided by measurement at the middle rather than by
2383    // convention.
2384    let blend = {
2385        let wire = ogeom_algo::make_wire(
2386            model,
2387            &[
2388                rail_a.clone(),
2389                arc1.clone(),
2390                rail_b.reversed(),
2391                arc0.reversed(),
2392            ],
2393            tol,
2394        )?
2395        .shape;
2396        let face =
2397            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2398        use ogeom_geom::Surface as _;
2399        let s_mid = surface.point_at(0.5, 0.5, tol)?;
2400        let (du, dv) = surface.d1_at(0.5, 0.5, tol)?;
2401        let apex_mid = apex[0].midpoint(apex[1]);
2402        let outward = s_mid - apex_mid.midpoint(s_mid);
2403        if du.cross(dv).dot(outward) >= 0.0 {
2404            face
2405        } else {
2406            face.reversed()
2407        }
2408    };
2409
2410    // Legs along the two faces, and caps closed by the end arcs. The caps go
2411    // through the generic builder: the rational arc projects into a plane
2412    // exactly, control point by control point.
2413    let leg_a = planar_face(
2414        model,
2415        &[apex[0], contact_a[0], contact_a[1], apex[1]],
2416        n1 * sign,
2417        tol,
2418    )?;
2419    let leg_b = planar_face(
2420        model,
2421        &[apex[0], contact_b[0], contact_b[1], apex[1]],
2422        n2 * sign,
2423        tol,
2424    )?;
2425    let cap = |model: &mut Model, i: usize, outward: Vector| -> OgeomResult<Built> {
2426        let plane = Plane::through(apex[i], Direction::new(outward, tol)?);
2427        let reach = (apex[i].distance(contact_a[i]) + apex[i].distance(contact_b[i]) + 1.0) * 2.0;
2428        let cap_surface: SurfaceGeometry =
2429            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2430        let apex_v = make_vertex(model, apex[i]).shape;
2431        let edges = vec![
2432            crate::support::segment_between(
2433                model,
2434                (&apex_v, apex[i]),
2435                (&va[i], contact_a[i]),
2436                tol,
2437            )?,
2438            if i == 0 { arc0.clone() } else { arc1.clone() },
2439            crate::support::segment_between(
2440                model,
2441                (&vb[i], contact_b[i]),
2442                (&apex_v, apex[i]),
2443                tol,
2444            )?,
2445        ];
2446        // Apex, contact on `a`, contact on `b`: walked back where that
2447        // turns against `outward`, so the cap keeps itself on the left.
2448        let turn = (contact_a[i] - apex[i]).cross(contact_b[i] - apex[i]);
2449        let edges: Vec<Shape> = if turn.dot(outward) < 0.0 {
2450            edges.iter().rev().map(Shape::reversed).collect()
2451        } else {
2452            edges
2453        };
2454        ogeom_algo::make_face_with_pcurves(model, cap_surface, &[edges], tol)
2455    };
2456    let cap0 = cap(model, 0, -seat.along)?.shape;
2457    let cap1 = cap(model, 1, seat.along)?.shape;
2458
2459    let faces = [leg_a, leg_b, cap0, cap1, blend];
2460    apply_wedge(model, solid, Some(edge), &faces, !seat.convex, tol)
2461}
2462
2463/// Round a convex or concave edge, or a closed chain of curved edges, with
2464/// a blend whose radius follows `law` along `chain`, taken as one
2465/// parameter in arc length.
2466///
2467/// At arc length `s` along the chain the blend's section, in the plane
2468/// square to the edge there, is the arc of radius `law(s)` tangent to both
2469/// faces: the rolling ball of that radius seated in the section. The
2470/// chain starts where its first edge does as that edge is oriented (a
2471/// reversed edge starts at its stored curve's far end) and runs the way
2472/// that edge does.
2473///
2474/// Rounded:
2475///
2476/// - a straight edge between two planes, for any law. The linear law is
2477///   [`fillet_edge_variable`]'s blend; under [`RadiusLaw::Points`] the
2478///   envelope is again exact: a cubic B-spline along the edge, the
2479///   rational quadratic arc across it, the control net the edge's
2480///   Greville points pushed out by the law's own control radii.
2481/// - a closed loop of curved edges between the same two faces, given as
2482///   one closed edge or as the arcs it is split into, end to end round
2483///   the loop: a drum's rim, a cut drum's oblique rim, a branch's seam. The
2484///   march carries the law's radius station by station round the whole
2485///   loop, and the band is fitted through each station's own arc.
2486/// - an open curved edge whose ball runs out past its ends (a half drum's
2487///   rim): the law is held at its end radius past each end, so the ball
2488///   runs out there at that one radius, as a constant round's does.
2489/// - an open chain of arcs of one curve between the same two faces, end
2490///   to end (a crease split at a vertex): the one seat over the window of
2491///   that curve its arcs cover.
2492/// - a straight edge along a drum's ruling (a flat or a wall meeting a
2493///   drum, or two drums): marched with the law's radius station by station.
2494/// - a seat pinched where its hosts turn tangent at an end (two equal
2495///   drums crossing): each station solved at the law's radius for its
2496///   place, the band pinching to the pole.
2497/// - a chain of edges of different seats continuing each other end to
2498///   end, open or closed (the top loop of a rounded-rectangle pad: its
2499///   sides between two planes, its corners' arcs between the top and a
2500///   drum). The run is walked from the first edge the way it runs; each
2501///   edge takes the law over its own stretch of the run, its band stopped
2502///   in its end sections, and the bands' wedges are joined across each
2503///   junction, where one ball touches the same two points from either
2504///   side, and applied in one boolean.
2505/// - a chain of straight edges between planes turning where they meet on
2506///   one shared planar host (a block's top loop, an L-block's). Past a
2507///   turning vertex each band goes on at the law's radius there, as the
2508///   law holds level past an edge's end: across a convex turn it runs out
2509///   as the constant band of that radius alone does and the cuts take the
2510///   union; across a re-entrant one it is carried to the plane bisecting
2511///   the turn, where the two are joined. A loop the tangent junctions join
2512///   round to one convex turn (a pad with one vertical left sharp) is
2513///   opened at a straight band between two of them, built in two halves.
2514/// - a chain turning where marched bands meet (a drum's ruling turning
2515///   into its rim), met in rounds as [`fillet_edges`] meets it: each band
2516///   a later round builds stands over what the earlier left of its edge,
2517///   under its own window of the law, and runs on through the earlier
2518///   band at the law's radius there.
2519/// - a straight edge alone beside a narrow strip the ball takes off whole
2520///   at every radius of the law: the exact envelope over the seat between
2521///   the faces either side of the strip.
2522///
2523/// A law holding one radius all along is [`fillet_edges`]' round.
2524///
2525/// What `solid` shares with other shapes is copied before it is
2526/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
2527///
2528/// # Errors
2529///
2530/// [`OgeomError::Construction`] where the law states no usable radius
2531/// (no points, a fraction off the chain or out of order, a radius not a
2532/// positive length), where a linear law changes radius round a closed
2533/// chain, where `chain` is empty, a chain of more than one edge whose
2534/// edges do not run end to end, a turn where a marched band meets a
2535/// straight one between planes, a loop joined
2536/// round a convex turn with no straight band between two of its junctions
2537/// to open it at, a ball resting
2538/// on a narrow face's far edge or two bands overlapping across a face
2539/// they share at an edge's largest radius, a
2540/// straight edge between faces that are neither two planes nor a drum and
2541/// a face along its ruling (only two planes inside a chain of different
2542/// seats), where two bands' end sections at a junction part by more than
2543/// a marched band's fit, where a law is given over one arc of a
2544/// crease that closes through other edges, or where the ball at the law's
2545/// largest radius does not fit the edge's faces; and as [`fillet_edge`]
2546/// for the seat itself.
2547pub fn fillet_edges_variable(
2548    model: &mut Model,
2549    solid: &Shape,
2550    chain: &[Shape],
2551    law: &RadiusLaw,
2552    tol: Tolerances,
2553) -> OgeomResult<Built> {
2554    if chain.is_empty() {
2555        ogeom_bail!(
2556            Construction,
2557            "a variable radius over no edges rounds nothing"
2558        );
2559    }
2560    ogeom_algo::on_own_nodes(model, solid, chain, |model, solid, parts| {
2561        fillet_chain_law_own(model, solid, parts, law, tol)
2562    })
2563}
2564
2565/// Whether `chain` runs end to end and back to its start, each edge
2566/// between the same two faces of `solid` as the first, none twice.
2567fn closed_chain(model: &Model, solid: &Shape, chain: &[Shape]) -> OgeomResult<bool> {
2568    if !runs_between_one_pair(model, solid, chain)? {
2569        return Ok(false);
2570    }
2571    let (Some(first), Some(last)) = (
2572        ogeom_algo::edge_vertices(model, &chain[0])?,
2573        ogeom_algo::edge_vertices(model, &chain[chain.len() - 1])?,
2574    ) else {
2575        return Ok(false);
2576    };
2577    Ok(last.1.is_same(&first.0))
2578}
2579
2580/// The window of one curve an open `chain` covers, where its edges run
2581/// end to end between the same two faces of `solid` and are each a piece
2582/// of that curve (the same geometry), one after the other along it: in the curve's own
2583/// parameters, a piece past a periodic curve's seam taken a period on.
2584/// `None` for any other chain.
2585fn one_curve_span(
2586    model: &Model,
2587    solid: &Shape,
2588    chain: &[Shape],
2589    tol: Tolerances,
2590) -> OgeomResult<Option<(f64, f64)>> {
2591    if !runs_between_one_pair(model, solid, chain)? {
2592        return Ok(None);
2593    }
2594    let mut pieces = Vec::with_capacity(chain.len());
2595    for edge in chain {
2596        let Some(ogeom_topo::EdgeRepr::Curve3d {
2597            curve,
2598            location,
2599            range,
2600        }) = model
2601            .node(edge)
2602            .and_then(|n| n.data().as_edge())
2603            .and_then(|d| d.curve3d())
2604            .cloned()
2605        else {
2606            return Ok(None);
2607        };
2608        if edge.location() != &ogeom_topo::Location::default() {
2609            return Ok(None);
2610        }
2611        pieces.push((curve, location, range, edge.orientation()));
2612    }
2613    // One curve: the same stored geometry, whether or not under one handle
2614    // (a boolean that splits a crease gives each piece its own copy).
2615    let (curve, location, _, orientation) = pieces[0].clone();
2616    let Some(geometry) = model.geometry().curve(curve) else {
2617        return Ok(None);
2618    };
2619    if pieces.iter().any(|(c, l, _, o)| {
2620        *l != location || *o != orientation || model.geometry().curve(*c) != Some(geometry)
2621    }) {
2622        return Ok(None);
2623    }
2624    let period = {
2625        use ogeom_geom::Curve3d as _;
2626        geometry.is_periodic().then(|| {
2627            let (lo, hi) = geometry.domain();
2628            hi - lo
2629        })
2630    };
2631    // Along the curve the way the chain runs: forward pieces end where the
2632    // next begins, reversed ones begin where the next ends.
2633    let forward = orientation != ogeom_topo::Orientation::Reversed;
2634    let ordered: Vec<(f64, f64)> = if forward {
2635        pieces.iter().map(|p| p.2).collect()
2636    } else {
2637        pieces.iter().rev().map(|p| p.2).collect()
2638    };
2639    let reach = tol.parametric();
2640    let mut span = ordered[0];
2641    for &(lo, hi) in &ordered[1..] {
2642        let shift = match period {
2643            Some(period) if (lo + period - span.1).abs() <= reach => period,
2644            _ => 0.0,
2645        };
2646        if (lo + shift - span.1).abs() > reach {
2647            return Ok(None);
2648        }
2649        span.1 = hi + shift;
2650    }
2651    Ok(Some(span))
2652}
2653
2654/// Whether `chain` runs end to end, each edge between the same two faces of
2655/// `solid` as the first, none twice.
2656fn runs_between_one_pair(model: &Model, solid: &Shape, chain: &[Shape]) -> OgeomResult<bool> {
2657    let faces_of = |edge: &Shape| -> OgeomResult<Vec<Shape>> {
2658        let mut faces: Vec<Shape> = Vec::new();
2659        for f in ogeom_topo::ancestors_of(model, solid, edge, ShapeType::Face)? {
2660            if !faces.iter().any(|g| g.is_same(&f)) {
2661                faces.push(f);
2662            }
2663        }
2664        Ok(faces)
2665    };
2666    let hosts = faces_of(&chain[0])?;
2667    let mut ends = Vec::with_capacity(chain.len());
2668    for (i, edge) in chain.iter().enumerate() {
2669        if chain[..i].iter().any(|e| e.is_same(edge)) {
2670            return Ok(false);
2671        }
2672        let faces = faces_of(edge)?;
2673        let same =
2674            faces.len() == hosts.len() && faces.iter().all(|f| hosts.iter().any(|h| h.is_same(f)));
2675        if !same {
2676            return Ok(false);
2677        }
2678        let Some(pair) = ogeom_algo::edge_vertices(model, edge)? else {
2679            return Ok(false);
2680        };
2681        ends.push(pair);
2682    }
2683    Ok((1..ends.len()).all(|i| ends[i - 1].1.is_same(&ends[i].0)))
2684}
2685
2686/// [`fillet_edges_variable`] on a shape every node below which is its own.
2687fn fillet_chain_law_own(
2688    model: &mut Model,
2689    solid: &Shape,
2690    chain: &[Shape],
2691    law: &RadiusLaw,
2692    tol: Tolerances,
2693) -> OgeomResult<Built> {
2694    let edge = &chain[0];
2695    let closed = closed_chain(model, solid, chain)?;
2696    // An open chain of arcs of one curve (a crease the boolean split at a
2697    // vertex) is rounded as the one seat it is, over the window of that
2698    // curve its arcs cover end to end.
2699    let span = if chain.len() > 1 && !closed {
2700        let Some(span) = one_curve_span(model, solid, chain, tol)? else {
2701            return chain_law(model, solid, chain, law, tol);
2702        };
2703        Some(span)
2704    } else {
2705        None
2706    };
2707    let profile = Profile::new(law, closed, tol)?;
2708    if profile.level(tol) {
2709        let radii = vec![profile.largest(); chain.len()];
2710        return fillet_edges_meeting(model, solid, chain, &radii, tol);
2711    }
2712    let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
2713    if chain.len() == 1
2714        && let RadiusLaw::Linear { start, end } = law
2715    {
2716        let (from, to) = if reversed {
2717            (*end, *start)
2718        } else {
2719            (*start, *end)
2720        };
2721        return fillet_edge_variable_own(model, solid, edge, from, to, tol);
2722    }
2723    // A straight edge alone: between two planes the envelope is exact;
2724    // along a drum's ruling (a wall meeting a drum, or two drums) every
2725    // section changes with the radius, and the march carries it.
2726    let mut planar = false;
2727    for e in chain {
2728        let (curve, _) = edge_curve(model, e, tol)?;
2729        if matches!(curve, Curve::Line(_)) {
2730            let seat = if chain.len() == 1 {
2731                Some(route(model, solid, e, Rims::Exact, tol)?)
2732            } else {
2733                None
2734            };
2735            planar = matches!(seat, Some(Route::Planar));
2736            if !planar && !matches!(seat, Some(Route::Ruled(_))) {
2737                ogeom_bail!(
2738                    Construction,
2739                    "a variable radius on a straight edge other than one \
2740                     between two planes or along a drum's ruling, alone, is \
2741                     still owed; see docs/CAPABILITIES.md, fillet.variable"
2742                );
2743            }
2744        }
2745    }
2746    // A narrow strip beside a straight edge alone, taken off whole at every
2747    // radius of the law, as the constant round takes it off at one.
2748    if planar
2749        && chain.len() == 1
2750        && let Some(built) = consumed_law(model, solid, edge, &profile, reversed, tol)?
2751    {
2752        return Ok(built);
2753    }
2754    // Each ball sits on both faces of its edge all along the law.
2755    let stations = (chain.len() == 1 && span.is_none()).then_some(reversed);
2756    if closed && chain.len() > 1 && !law_loop_fits(model, solid, chain, &profile, tol)? {
2757        for e in chain {
2758            law_ball_on_faces(model, solid, e, &profile, None, tol)?;
2759        }
2760    } else if !(closed && chain.len() > 1) {
2761        for e in chain {
2762            law_ball_on_faces(model, solid, e, &profile, stations, tol)?;
2763        }
2764    }
2765    let mut built = if planar {
2766        straight_law_fillet(model, solid, edge, &profile, reversed, (0.0, 1.0), tol)?
2767    } else {
2768        crate::marched::marched_fillet_law(model, solid, edge, &profile, span, None, tol)?
2769    };
2770    crate::support::credit_new_faces(model, solid, edge, &mut built)?;
2771    // The chain's other arcs went with the first: the one seat over them.
2772    if span.is_some() {
2773        for other in &chain[1..] {
2774            built.history.delete(other);
2775        }
2776    }
2777    Ok(built)
2778}
2779
2780/// The band over `piece`, what an earlier round of a chain left of the
2781/// chain's edge `whole`, under `law`: the law over `whole` from its own
2782/// start the way it runs, read over the stretch of it the piece covers.
2783///
2784/// A straight piece between planes is the exact envelope of that stretch;
2785/// where the constant band of the radius at an end runs on past it
2786/// (through a blend an earlier round built, as `fillet_edges` runs a
2787/// later band through an earlier one), what of that band stands past the
2788/// end is joined on, the law held level there at the end's radius. Any
2789/// other piece is marched under the law with the request's mates, which
2790/// run it out past an end as the constant band would.
2791fn law_piece(
2792    model: &mut Model,
2793    solid: &Shape,
2794    whole: &Shape,
2795    piece: &Shape,
2796    law: &Profile,
2797    (index, mates): (usize, &[Mate]),
2798    tol: Tolerances,
2799) -> OgeomResult<Built> {
2800    use ogeom_geom::Curve3d as _;
2801    let (curve, range) = edge_curve(model, whole, tol)?;
2802    let ruler = crate::law::Ruler::new(curve.clone(), range, false, tol)?;
2803    let whole_reversed = whole.orientation() == ogeom_topo::Orientation::Reversed;
2804    // The fraction of the whole edge, from its own start the way it runs,
2805    // at a point of it.
2806    let fraction = |p: Point| -> OgeomResult<f64> {
2807        let t = ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter;
2808        let s = ruler.fraction(t, tol)?.0;
2809        Ok(if whole_reversed { 1.0 - s } else { s })
2810    };
2811    let (piece_curve, (lo, hi)) = edge_curve(model, piece, tol)?;
2812    let reversed = piece.orientation() == ogeom_topo::Orientation::Reversed;
2813    let (start, end) = if reversed {
2814        (
2815            piece_curve.point_at(hi, tol)?,
2816            piece_curve.point_at(lo, tol)?,
2817        )
2818    } else {
2819        (
2820            piece_curve.point_at(lo, tol)?,
2821            piece_curve.point_at(hi, tol)?,
2822        )
2823    };
2824    let (from, to) = (fraction(start)?, fraction(end)?);
2825    if (to - from).abs() <= tol.parametric() {
2826        ogeom_bail!(
2827            Construction,
2828            "a piece of a chain's edge under a law has no length"
2829        );
2830    }
2831    let own = law.window(from, to);
2832    let planar = matches!(piece_curve, Curve::Line(_))
2833        && matches!(route(model, solid, piece, Rims::Exact, tol)?, Route::Planar);
2834    if !planar {
2835        return crate::marched::marched_fillet_law(
2836            model,
2837            solid,
2838            piece,
2839            &own,
2840            None,
2841            Some((index, mates)),
2842            tol,
2843        );
2844    }
2845    let (built, wedges) = crate::support::collecting_wedges(|| {
2846        straight_law_fillet(model, solid, piece, &own, reversed, (0.0, 1.0), tol)
2847    });
2848    built?;
2849    let [band] = <[crate::support::Wedge; 1]>::try_from(wedges).map_err(|w| {
2850        ogeom_core::ogeom_err!(
2851            Construction,
2852            "a piece of a chain's edge under a law built {} wedges, not one",
2853            w.len()
2854        )
2855    })?;
2856    let mut wedge = band.solid;
2857    let gap = crate::marched::band_fit_target(tol) * 2.0;
2858    let seat = planar_seat(model, solid, piece, tol)?;
2859    for (at, outward, radius) in [
2860        (
2861            seat.start,
2862            -seat.along,
2863            if reversed {
2864                own.at(1.0).0
2865            } else {
2866                own.at(0.0).0
2867            },
2868        ),
2869        (
2870            seat.end,
2871            seat.along,
2872            if reversed {
2873                own.at(0.0).0
2874            } else {
2875                own.at(1.0).0
2876            },
2877        ),
2878    ] {
2879        let (alone, wedges) = crate::support::collecting_wedges(|| {
2880            planar_fillet(
2881                model,
2882                solid,
2883                piece,
2884                Section::Ball(radius),
2885                Some((index, mates)),
2886                tol,
2887            )
2888        });
2889        alone?;
2890        let Some(alone) = wedges.into_iter().next() else {
2891            continue;
2892        };
2893        // A straight band's sections stand square to its crease, so its
2894        // vertices reach as far past the end as any point of it.
2895        let mut past = f64::NEG_INFINITY;
2896        for vertex in explore_unique(model, &alone.solid, ShapeType::Vertex)? {
2897            if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
2898                let p = vertex.transform(model.datums())?.apply(data.point);
2899                past = past.max((p - at).dot(outward));
2900            }
2901        }
2902        if past <= gap {
2903            continue;
2904        }
2905        let span = ogeom_algo::shape_bounds(model, &alone.solid, tol)?.diagonal() * 2.0;
2906        let run_out = crate::support::trimmed_wedge(model, &alone.solid, (at, outward), span, tol)?;
2907        wedge = crate::junction::joined(model, [&wedge, &run_out], (at, outward), at, gap, tol)?.0;
2908    }
2909    crate::support::apply_wedge_solid(model, solid, Some(piece), &wedge, band.additive, tol)
2910}
2911
2912/// The straight edge's band under `law` where a narrow strip beside it is
2913/// taken off whole at every radius of the law, as the constant round
2914/// takes it off at one ([`crate::consumed::consumed_seat`]): the exact
2915/// envelope over the seat between the faces either side of the strip.
2916/// The constant round takes it off where its ball does not sit on the
2917/// edge's faces and the seat beyond the strip holds; each of that seat's
2918/// conditions grows with the radius or is linear in it, and a ball that
2919/// does not sit on the strip at one radius sits on it at no larger one, so
2920/// where the law's least and largest radius both take the strip off,
2921/// every radius between does. `None` where neither does; a law taking it
2922/// off at one and not the other is refused by name.
2923fn consumed_law(
2924    model: &mut Model,
2925    solid: &Shape,
2926    edge: &Shape,
2927    law: &Profile,
2928    reversed: bool,
2929    tol: Tolerances,
2930) -> OgeomResult<Option<Built>> {
2931    // The constant round takes the strip off only where its ball does not
2932    // sit on the edge's faces: at a radius the strip holds, it rounds the
2933    // edge as any other.
2934    let consumes = |radius: f64| -> OgeomResult<Option<crate::support::Seat>> {
2935        match crate::support::ball_fits(model, solid, edge, radius, false, tol) {
2936            Ok(_) => Ok(None),
2937            Err(OgeomError::Construction(_)) => {
2938                crate::consumed::consumed_seat(model, solid, edge, radius, tol)
2939            }
2940            Err(e) => Err(e),
2941        }
2942    };
2943    let (least, largest) = (consumes(law.least())?, consumes(law.largest())?);
2944    let seat = match (least, largest) {
2945        (Some(_), Some(seat)) => seat,
2946        (None, None) => return Ok(None),
2947        _ => ogeom_bail!(
2948            Construction,
2949            "a variable radius whose ball sits on a narrow face beside the \
2950             edge at some radii of the law and takes it off whole at others \
2951             is still owed: the constant round changes construction between \
2952             them; see docs/CAPABILITIES.md, fillet.variable"
2953        ),
2954    };
2955    let mut built = straight_law_on(model, solid, edge, seat, law, reversed, (0.0, 1.0), tol)?;
2956    crate::support::credit_new_faces(model, solid, edge, &mut built)?;
2957    Ok(Some(built))
2958}
2959
2960/// The law's radius at a fraction of `edge`'s curve range, start to end:
2961/// the law laid by arc length from where the edge starts, the way it runs
2962/// (from the curve's end where `reversed`), as the band is built under it.
2963fn law_on_edge<'a>(
2964    model: &Model,
2965    edge: &Shape,
2966    law: &'a Profile,
2967    reversed: bool,
2968    tol: Tolerances,
2969) -> OgeomResult<Box<dyn Fn(f64) -> f64 + 'a>> {
2970    let (curve, range) = edge_curve(model, edge, tol)?;
2971    let flip = move |s: f64| if reversed { 1.0 - s } else { s };
2972    if matches!(curve, Curve::Line(_)) {
2973        return Ok(Box::new(move |f: f64| law.at(flip(f)).0));
2974    }
2975    let ruler = crate::law::Ruler::new(curve, range, false, tol)?;
2976    Ok(Box::new(move |f: f64| {
2977        let t = (range.1 - range.0).mul_add(f, range.0);
2978        let s = ruler.fraction(t, tol).map_or(f, |(s, _)| s);
2979        law.at(flip(s)).0
2980    }))
2981}
2982
2983/// Whether the ball under `law` sits on its faces round a closed loop of
2984/// arcs of one curve, the one seat the march walks: each arc read station
2985/// by station at the law's radius there, as [`law_ball_on_faces`] reads one
2986/// edge alone, the law laid by arc length round the first arc's curve from
2987/// where that arc starts, the way it runs, as the march lays it.
2988/// `Ok(false)` where the first arc's curve does not close on itself, for
2989/// the loop to be read as [`law_ball_on_faces`] reads any other.
2990fn law_loop_fits(
2991    model: &Model,
2992    solid: &Shape,
2993    chain: &[Shape],
2994    law: &Profile,
2995    tol: Tolerances,
2996) -> OgeomResult<bool> {
2997    use ogeom_geom::Curve3d as _;
2998    let (stored, (lo, hi)) = edge_curve(model, &chain[0], tol)?;
2999    if !stored.is_periodic() {
3000        return Ok(false);
3001    }
3002    let ruler = crate::law::Ruler::new(stored.clone(), stored.domain(), true, tol)?;
3003    let (start, heading) = if chain[0].orientation() == ogeom_topo::Orientation::Reversed {
3004        (stored.point_at(hi, tol)?, -stored.d1_at(hi, tol)?)
3005    } else {
3006        (stored.point_at(lo, tol)?, stored.d1_at(lo, tol)?)
3007    };
3008    let at = ogeom_algo::project_on_curve(&stored, start, 256, tol)?.parameter;
3009    let against = stored.d1_at(at, tol)?.dot(heading) < 0.0;
3010    let origin = ruler.fraction(at, tol)?.0;
3011    let fractions: Vec<f64> = (1..LAW_STATIONS)
3012        .map(|k| f64::from(k) / f64::from(LAW_STATIONS))
3013        .collect();
3014    for edge in chain {
3015        let (curve, (a, b)) = edge_curve(model, edge, tol)?;
3016        let radius_at = |f: f64| -> f64 {
3017            let read = || -> OgeomResult<f64> {
3018                let p = curve.point_at((b - a).mul_add(f, a), tol)?;
3019                let t = ogeom_algo::project_on_curve(&stored, p, 64, tol)?.parameter;
3020                let s = ruler.fraction(t, tol)?.0 - origin;
3021                Ok(law.at((if against { -s } else { s }).rem_euclid(1.0)).0)
3022            };
3023            read().unwrap_or_else(|_| law.largest())
3024        };
3025        crate::support::ball_fits_by(model, solid, edge, &radius_at, &fractions, false, tol)?;
3026        // As one edge alone is read: the constant round at the law's
3027        // largest radius holds the ball at some station.
3028        crate::support::ball_fits(model, solid, edge, law.largest(), false, tol)?;
3029    }
3030    Ok(true)
3031}
3032
3033/// Whether the balls of a chain under a law sit on their faces, and no
3034/// band overlaps another across a face they share.
3035///
3036/// A straight edge between two planes is read station by station at its
3037/// own law's radius as [`law_ball_on_faces`] reads one alone, its contacts
3038/// carried past a face's side where its ball runs off a narrow face
3039/// ([`crate::support::ball_fits_by`] with `carry`), as a request's band
3040/// sharing a vertex with another is read; any other edge is read as
3041/// [`law_ball_on_faces`] reads it, every contact on its own face at the
3042/// law's least and largest radius. The contacts of every station are then
3043/// checked against the other edges' bands
3044/// ([`crate::support::bands_clear`]).
3045fn law_chain_fits(
3046    model: &Model,
3047    solid: &Shape,
3048    chain: &[Shape],
3049    laws: &[&Profile],
3050    tol: Tolerances,
3051) -> OgeomResult<()> {
3052    let fractions: Vec<f64> = (1..LAW_STATIONS)
3053        .map(|k| f64::from(k) / f64::from(LAW_STATIONS))
3054        .collect();
3055    let mut contacts = Vec::with_capacity(chain.len());
3056    for (edge, law) in chain.iter().zip(laws) {
3057        let (curve, _) = edge_curve(model, edge, tol)?;
3058        let planar = matches!(curve, Curve::Line(_))
3059            && matches!(route(model, solid, edge, Rims::Exact, tol)?, Route::Planar);
3060        if planar {
3061            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3062            let radius_at = law_on_edge(model, edge, law, reversed, tol)?;
3063            contacts.push(crate::support::ball_fits_by(
3064                model,
3065                solid,
3066                edge,
3067                &*radius_at,
3068                &fractions,
3069                true,
3070                tol,
3071            )?);
3072        } else {
3073            law_ball_on_faces(model, solid, edge, law, None, tol)?;
3074            contacts.push(crate::support::ball_fits(
3075                model,
3076                solid,
3077                edge,
3078                law.largest(),
3079                true,
3080                tol,
3081            )?);
3082        }
3083    }
3084    crate::support::bands_clear(model, chain, &contacts, tol).map_err(|b| b.error)
3085}
3086
3087/// How many pieces a straight edge under a law is read in for whether
3088/// its ball sits on both faces: a station at each inner end.
3089const LAW_STATIONS: u32 = 32;
3090
3091/// Whether the ball under `law` sits on both faces of `edge` all along it.
3092///
3093/// Where `stations` is given (a straight edge between two planes rounded
3094/// alone, the law laid from the curve's start, or from its end where
3095/// `reversed`), the ball at each station has the law's radius there and is
3096/// read as the constant round reads its own
3097/// ([`crate::support::ball_fits_by`]): a contact on its face or on any face
3098/// of the solid holds; one past its face outside the solid is cut off
3099/// there, the band running on over the open as the constant round's does;
3100/// one standing in the material refuses, and so does a ball held at no
3101/// station. Where the law is level that is the constant round's own
3102/// reading at that radius.
3103///
3104/// Elsewhere [`crate::support::ball_fits`] is read at the law's least and
3105/// largest radius and every contact must stand on its own face: a chain's
3106/// bands and a marched band are not cut off where a contact runs off.
3107fn law_ball_on_faces(
3108    model: &Model,
3109    solid: &Shape,
3110    edge: &Shape,
3111    law: &Profile,
3112    stations: Option<bool>,
3113    tol: Tolerances,
3114) -> OgeomResult<()> {
3115    if let Some(reversed) = stations {
3116        let fractions: Vec<f64> = (1..LAW_STATIONS)
3117            .map(|k| f64::from(k) / f64::from(LAW_STATIONS))
3118            .collect();
3119        let radius_at = law_on_edge(model, edge, law, reversed, tol)?;
3120        crate::support::ball_fits_by(model, solid, edge, &*radius_at, &fractions, false, tol)?;
3121        // The constant round at the law's largest radius, as a chain's
3122        // request is read before its stations: a contact that runs off a
3123        // strip past the solid at one radius runs off at every larger one,
3124        // and a ball held at no station there is one the constant round
3125        // takes the strip off for, or refuses, not a band cut off.
3126        crate::support::ball_fits(model, solid, edge, law.largest(), false, tol)?;
3127        return Ok(());
3128    }
3129    for radius in [law.least(), law.largest()] {
3130        let deflection = ogeom_mesh::Deflection {
3131            chord: (radius * 1e-3).max(tol.confusion() * 1e2),
3132            ..ogeom_mesh::Deflection::default()
3133        };
3134        for contact in crate::support::ball_fits(model, solid, edge, radius, false, tol)? {
3135            let on =
3136                ogeom_algo::classify_on_face(model, &contact.face, contact.at, deflection, tol)?;
3137            if on == ogeom_algo::Containment::Out {
3138                ogeom_bail!(
3139                    Construction,
3140                    "a variable radius whose ball at radius {radius} runs off a \
3141                     face beside the edge (a narrow face's far edge) is still \
3142                     owed; see docs/CAPABILITIES.md, fillet.variable"
3143                );
3144            }
3145        }
3146    }
3147    Ok(())
3148}
3149
3150/// The request's edges by their ends, as a band asks of its mates: each
3151/// end's point and the unit tangent leaving it along the edge.
3152fn chain_mates(model: &Model, chain: &[Shape], tol: Tolerances) -> OgeomResult<Vec<Mate>> {
3153    use ogeom_geom::Curve3d as _;
3154    chain
3155        .iter()
3156        .map(|e| -> OgeomResult<Mate> {
3157            let (curve, range) = edge_curve(model, e, tol)?;
3158            let end_of = |t: f64, leaving: f64| -> OgeomResult<(Point, Vector)> {
3159                let d = curve.d1_at(t, tol)?;
3160                let m = d.magnitude();
3161                let unit = if m > tol.confusion() {
3162                    d / m
3163                } else {
3164                    Vector::ZERO
3165                };
3166                Ok((curve.point_at(t, tol)?, unit * leaving))
3167            };
3168            Ok(Mate {
3169                ends: [end_of(range.0, 1.0)?, end_of(range.1, -1.0)?],
3170                settled: [false, false],
3171                meets: [false, false],
3172                alone: [false, false],
3173            })
3174        })
3175        .collect()
3176}
3177
3178/// A chain of edges of different seats running end to end, rounded under
3179/// `law` as one run: edges continuing each other (the top loop of a
3180/// rounded-rectangle pad: its sides between two planes, its corners' arcs
3181/// between the top and a drum), or straight edges between planes turning
3182/// where they meet on one shared planar host (a block's top loop).
3183///
3184/// The run is walked from the first edge the way it runs, on through the
3185/// edges sharing its ends, and back from its start where it is open; the
3186/// law's fraction is the run's arc length from where the walk starts, and
3187/// the run closes where it comes back to its own start. Each edge takes
3188/// the law over its own stretch of the run ([`Profile::window`], the law
3189/// itself there), so the ball at a junction is one radius from either
3190/// side. Each band is built against the solid as given and stopped in its
3191/// end sections: a straight edge's are square to it by construction, and a
3192/// marched band stops flush where a mate continues its crease. Where the
3193/// creases continue each other and the hosts either side are tangent, one
3194/// ball touches the same two points from both sides, so the two end
3195/// sections are one; the bands' wedges are joined across each junction's
3196/// plane into one wedge (sewn within the fit a marched band is held to,
3197/// which refuses ends that part by more) and applied in one boolean.
3198/// Where they turn, each band goes on past the vertex at the law's radius
3199/// there, which the law holds level past an edge's end: the constant band
3200/// of that radius, read as `fillet_edges` reads it, run out across a
3201/// convex turn and carried to the plane bisecting a re-entrant one.
3202fn chain_law(
3203    model: &mut Model,
3204    solid: &Shape,
3205    chain: &[Shape],
3206    law: &RadiusLaw,
3207    tol: Tolerances,
3208) -> OgeomResult<Built> {
3209    let mates = chain_mates(model, chain, tol)?;
3210    // Each edge's ends the way it runs: its oriented start, then its end.
3211    let runs: Vec<[Point; 2]> = chain
3212        .iter()
3213        .zip(&mates)
3214        .map(|(e, m)| {
3215            let [(a, _), (b, _)] = m.ends;
3216            if e.orientation() == ogeom_topo::Orientation::Reversed {
3217                [b, a]
3218            } else {
3219                [a, b]
3220            }
3221        })
3222        .collect();
3223    let near = |p: Point, q: Point| p.distance(q) <= tol.confusion() * 1e3;
3224    // The walk: each step an edge and whether it runs with the walk.
3225    let mut walk: Vec<(usize, bool)> = vec![(0, true)];
3226    let mut used = vec![false; chain.len()];
3227    used[0] = true;
3228    let mut head = runs[0][1];
3229    while let Some(k) =
3230        (0..chain.len()).find(|&k| !used[k] && runs[k].iter().any(|p| near(*p, head)))
3231    {
3232        let with = near(runs[k][0], head);
3233        used[k] = true;
3234        head = if with { runs[k][1] } else { runs[k][0] };
3235        walk.push((k, with));
3236    }
3237    let closed = near(head, runs[0][0]);
3238    if !closed {
3239        let mut tail = runs[0][0];
3240        while let Some(k) =
3241            (0..chain.len()).find(|&k| !used[k] && runs[k].iter().any(|p| near(*p, tail)))
3242        {
3243            let with = near(runs[k][1], tail);
3244            used[k] = true;
3245            tail = if with { runs[k][0] } else { runs[k][1] };
3246            walk.insert(0, (k, with));
3247        }
3248    }
3249    if used.iter().any(|u| !u) {
3250        ogeom_bail!(
3251            Construction,
3252            "a variable radius over a chain whose edges do not run end to end \
3253             rounds nothing; see docs/CAPABILITIES.md, fillet.variable"
3254        );
3255    }
3256    let tangent = crate::junction::tangent_junctions(chain, &mates, tol);
3257    let profile = Profile::new(law, closed, tol)?;
3258    if profile.level(tol) {
3259        let radii = vec![profile.largest(); chain.len()];
3260        return fillet_edges_meeting(model, solid, chain, &radii, tol);
3261    }
3262    // The run's arc length, edge by edge along the walk.
3263    let mut lengths = Vec::with_capacity(walk.len());
3264    for &(k, _) in &walk {
3265        let (curve, range) = edge_curve(model, &chain[k], tol)?;
3266        lengths.push(crate::law::Ruler::new(curve, range, false, tol)?.length());
3267    }
3268    let total: f64 = lengths.iter().sum();
3269    // Each meeting of the run: the two edges, where they meet, and the
3270    // law's radius there, one radius from either side.
3271    let mut meetings: Vec<([usize; 2], Point, f64)> = Vec::new();
3272    let mut before = 0.0;
3273    for (step, &(k, with)) in walk.iter().enumerate() {
3274        before += lengths[step];
3275        let Some(&(next, _)) = walk
3276            .get(step + 1)
3277            .or(if closed { walk.first() } else { None })
3278        else {
3279            continue;
3280        };
3281        let at = if with { runs[k][1] } else { runs[k][0] };
3282        meetings.push(([k, next], at, profile.at(before / total).0));
3283    }
3284    // A meeting where the creases continue each other is a tangent
3285    // junction; any other is the junction two bands of the meeting's
3286    // radius make there, read as `fillet_edges` reads it.
3287    let mut junctions: Vec<crate::junction::Junction> = Vec::new();
3288    // Whether a turn is met in rounds, as `fillet_edges` meets a curved
3289    // crease's band and the band beside it: the one built first, the other
3290    // against what it left, running on through it.
3291    let mut in_rounds = false;
3292    for &([i, j], at, radius) in &meetings {
3293        if let Some(found) = tangent
3294            .iter()
3295            .find(|t| t.slot(i).is_some() && t.slot(j).is_some() && near(t.at, at))
3296        {
3297            junctions.push(found.clone());
3298            continue;
3299        }
3300        let hosts: Vec<Vec<Shape>> = [i, j]
3301            .iter()
3302            .map(|&k| -> OgeomResult<Vec<Shape>> {
3303                let mut faces: Vec<Shape> = Vec::new();
3304                for f in ogeom_topo::ancestors_of(model, solid, &chain[k], ShapeType::Face)? {
3305                    if !faces.iter().any(|g| g.is_same(&f)) {
3306                        faces.push(f);
3307                    }
3308                }
3309                Ok(faces)
3310            })
3311            .collect::<OgeomResult<_>>()?;
3312        let turning = crate::junction::junctions(
3313            model,
3314            solid,
3315            &[chain[i].clone(), chain[j].clone()],
3316            &hosts,
3317            &[mates[i], mates[j]],
3318            &[radius, radius],
3319            tol,
3320        )?
3321        .into_iter()
3322        .find(|t| near(t.at, at));
3323        let planar = |model: &Model, k: usize| -> OgeomResult<bool> {
3324            Ok(
3325                edge_curve(model, &chain[k], tol).is_ok_and(|(c, _)| matches!(c, Curve::Line(_)))
3326                    && matches!(
3327                        route(model, solid, &chain[k], Rims::Exact, tol)?,
3328                        Route::Planar
3329                    ),
3330            )
3331        };
3332        let straight = planar(model, i)? && planar(model, j)?;
3333        // Met in rounds, a marched band and a straight one between planes
3334        // are refused: under a law the marched band has no exact form (its
3335        // law runs by arc length, which no rational parameter of a circle
3336        // does), and its fitted end meets the straight band beside it in
3337        // what the boolean does not melt, whichever is built first and
3338        // whether it stops in the wall it ends on or runs out past it. The
3339        // constant round's band there is an exact torus band.
3340        let mixed = planar(model, i)? != planar(model, j)?;
3341        let in_turn = |mixed: bool| -> OgeomResult<()> {
3342            if mixed {
3343                ogeom_bail!(
3344                    Construction,
3345                    "a variable radius over a chain turning where a marched \
3346                     band meets a straight one between planes is still owed: \
3347                     under a law the marched band is fitted, and its end meets \
3348                     the straight band in what the boolean does not melt; see \
3349                     docs/CAPABILITIES.md, fillet.variable"
3350                );
3351            }
3352            Ok(())
3353        };
3354        let Some(mut turning) = turning else {
3355            // No junction on one shared planar host: the two bands meet as
3356            // `fillet_edges`' do, in rounds.
3357            in_turn(mixed)?;
3358            in_rounds = true;
3359            continue;
3360        };
3361        turning.pair = [i, j];
3362        if !straight && turning.crossed && !turning.reentrant {
3363            in_turn(mixed)?;
3364            in_rounds = true;
3365            continue;
3366        }
3367        let convex = turning.crossed && !turning.reentrant;
3368        let reentrant = turning.reentrant;
3369        if !straight || !(convex || reentrant) {
3370            ogeom_bail!(
3371                Construction,
3372                "a variable radius over a chain turning where it meets is \
3373                 rounded between straight creases across a convex or a \
3374                 re-entrant turn, and a curved crease across a convex turn; \
3375                 any other turn is still owed; see docs/CAPABILITIES.md, \
3376                 fillet.variable"
3377            );
3378        }
3379        junctions.push(turning);
3380    }
3381    // Each edge's law over its own stretch of the run, laid from its own
3382    // start the way it runs.
3383    let mut owns: Vec<Profile> = Vec::with_capacity(walk.len());
3384    let mut before = 0.0;
3385    for (&(_, with), length) in walk.iter().zip(&lengths) {
3386        let (from, to) = (before / total, (before + length) / total);
3387        before += length;
3388        owns.push(if with {
3389            profile.window(from, to)
3390        } else {
3391            profile.window(to, from)
3392        });
3393    }
3394    // A chain met in rounds goes to `fillet_edges`' own rounds, each edge
3395    // under its own window of the law; its other meetings must then be
3396    // turns too, met the same way.
3397    if in_rounds {
3398        if !junctions.is_empty() {
3399            ogeom_bail!(
3400                Construction,
3401                "a variable radius over a chain that turns where a curved \
3402                 crease meets and also joins bands across another junction is \
3403                 still owed; see docs/CAPABILITIES.md, fillet.variable"
3404            );
3405        }
3406        let mut laws: Vec<Profile> = owns.clone();
3407        let mut radii: Vec<f64> = vec![0.0; chain.len()];
3408        for (&(k, _), own) in walk.iter().zip(&owns) {
3409            laws[k] = own.clone();
3410            radii[k] = own.largest();
3411        }
3412        for (edge, own) in chain.iter().zip(&laws) {
3413            law_ball_on_faces(model, solid, edge, own, None, tol)?;
3414        }
3415        return fillet_edges_own(
3416            model,
3417            solid,
3418            chain,
3419            &radii,
3420            None,
3421            Rims::Exact,
3422            Some(&laws),
3423            tol,
3424        );
3425    }
3426    // The request's checks at each edge's largest radius, where its ball
3427    // sets back furthest: each ball on its faces, and no band over another
3428    // across a face they share. A ball resting on a narrow face's far
3429    // edge, which `fillet_edges` takes off whole, is refused here.
3430    {
3431        let mut edges: Vec<Shape> = Vec::with_capacity(walk.len());
3432        let mut radii: Vec<f64> = Vec::with_capacity(walk.len());
3433        for (&(k, _), own) in walk.iter().zip(&owns) {
3434            edges.push(chain[k].clone());
3435            radii.push(own.largest());
3436        }
3437        let rests = request_fits(model, solid, &edges, &radii, tol).map_err(|b| b.error)?;
3438        if rests.iter().any(|&r| r) {
3439            ogeom_bail!(
3440                Construction,
3441                "a variable radius over a chain one of whose balls rests on \
3442                 the far edge of a narrow face beside it is still owed; see \
3443                 docs/CAPABILITIES.md, fillet.variable"
3444            );
3445        }
3446    }
3447    // Bands the junctions join into one wedge, by the group each edge's
3448    // band falls in. A group holding both bands of a convex turn (a loop of
3449    // tangent junctions closing on one sharp corner) would be one wedge
3450    // overlapping itself there; one straight band of it between two
3451    // junctions it joins is built in two halves instead, the halves'
3452    // shared section the same arc, so the group opens into two wedges that
3453    // meet flush there and overlap only at the corner, where the cuts take
3454    // their union.
3455    let mut group: Vec<usize> = (0..chain.len()).collect();
3456    for junction in junctions.iter().filter(|j| !j.crossed) {
3457        let [a, b] = junction.pair;
3458        let (from, to) = (group[a], group[b]);
3459        for g in &mut group {
3460            if *g == from {
3461                *g = to;
3462            }
3463        }
3464    }
3465    let mut halved: Vec<usize> = Vec::new();
3466    for junction in junctions.iter().filter(|j| j.crossed && !j.reentrant) {
3467        let [a, b] = junction.pair;
3468        if group[a] != group[b] || halved.iter().any(|&h| group[h] == group[a]) {
3469            continue;
3470        }
3471        let between = (0..chain.len()).find(|&m| {
3472            group[m] == group[a]
3473                && junctions
3474                    .iter()
3475                    .filter(|j| !j.crossed && j.slot(m).is_some())
3476                    .count()
3477                    == 2
3478                && edge_curve(model, &chain[m], tol).is_ok_and(|(c, _)| matches!(c, Curve::Line(_)))
3479        });
3480        let Some(m) = between else {
3481            ogeom_bail!(
3482                Construction,
3483                "a variable radius over a loop joined end to end round a convex \
3484                 turn with no straight edge between two of its junctions to \
3485                 open it at is still owed; see docs/CAPABILITIES.md, \
3486                 fillet.variable"
3487            );
3488        };
3489        halved.push(m);
3490    }
3491    {
3492        let mut laws: Vec<Option<&Profile>> = vec![None; chain.len()];
3493        for (&(k, _), own) in walk.iter().zip(&owns) {
3494            laws[k] = Some(own);
3495        }
3496        let laws: Vec<&Profile> = laws.into_iter().flatten().collect();
3497        if laws.len() != chain.len() {
3498            ogeom_bail!(Invariant, "a chain's walk takes every edge once");
3499        }
3500        law_chain_fits(model, solid, chain, &laws, tol)?;
3501    }
3502    let mut set_aside: Vec<(Vec<usize>, Shape, crate::support::Wedge)> = Vec::new();
3503    for ((&(k, _), own), _) in walk.iter().zip(&owns).zip(&lengths) {
3504        let edge = &chain[k];
3505        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3506        let (curve, _) = edge_curve(model, edge, tol)?;
3507        if halved.contains(&k) {
3508            // Each half of the edge by its stored parameter, its law laid
3509            // from its own start the way the edge runs.
3510            for (part, (from, to)) in [((0.0, 0.5), (0.0, 0.5)), ((0.5, 1.0), (0.5, 1.0))] {
3511                let law = if reversed {
3512                    own.window(1.0 - to, 1.0 - from)
3513                } else {
3514                    own.window(from, to)
3515                };
3516                let (built, wedges) = crate::support::collecting_wedges(|| {
3517                    straight_law_fillet(model, solid, edge, &law, reversed, part, tol)
3518                });
3519                built?;
3520                let [wedge] = <[crate::support::Wedge; 1]>::try_from(wedges).map_err(|w| {
3521                    ogeom_core::ogeom_err!(
3522                        Construction,
3523                        "a half band of a chain under a law built {} wedges, not one",
3524                        w.len()
3525                    )
3526                })?;
3527                set_aside.push((vec![k], edge.clone(), wedge));
3528            }
3529            continue;
3530        }
3531        let (built, wedges) = crate::support::collecting_wedges(|| -> OgeomResult<Built> {
3532            if matches!(curve, Curve::Line(_)) {
3533                if !matches!(route(model, solid, edge, Rims::Exact, tol)?, Route::Planar) {
3534                    ogeom_bail!(
3535                        Construction,
3536                        "a variable radius on a straight edge of a chain other \
3537                         than one between two planes is still owed; see \
3538                         docs/CAPABILITIES.md, fillet.variable"
3539                    );
3540                }
3541                straight_law_fillet(model, solid, edge, own, reversed, (0.0, 1.0), tol)
3542            } else {
3543                crate::marched::marched_fillet_law(
3544                    model,
3545                    solid,
3546                    edge,
3547                    own,
3548                    None,
3549                    Some((k, &mates)),
3550                    tol,
3551                )
3552            }
3553        });
3554        built?;
3555        let [wedge] = <[crate::support::Wedge; 1]>::try_from(wedges).map_err(|w| {
3556            ogeom_core::ogeom_err!(
3557                Construction,
3558                "a band of a chain under a law built {} wedges, not one",
3559                w.len()
3560            )
3561        })?;
3562        set_aside.push((vec![k], edge.clone(), wedge));
3563    }
3564    // At a turning junction each band goes on past the vertex at the
3565    // meeting's radius, where the law holds level past an edge's end: the
3566    // constant band of that radius, which is what the junction is read for.
3567    // Across a convex turn each band runs out as the constant band alone
3568    // does and the cuts take their union; across a re-entrant one each is
3569    // carried on to the plane the two meet in, where they are joined.
3570    let gap = crate::marched::band_fit_target(tol) * 2.0;
3571    let run_on = run_on_past_turns(
3572        model,
3573        solid,
3574        chain,
3575        (&walk, &owns, &halved),
3576        (&junctions, &meetings),
3577        &mut set_aside,
3578        tol,
3579    )?;
3580    for (junction, &(_, _, radius)) in junctions.iter().zip(&meetings) {
3581        if junction.tangent {
3582            continue;
3583        }
3584        for slot in 0..2 {
3585            let index = junction.pair[slot];
3586            let edge = chain[index].clone();
3587            let Some(entry) = set_aside
3588                .iter_mut()
3589                .find(|(held, _, _)| held.contains(&index))
3590            else {
3591                continue;
3592            };
3593            let outward = junction.outward[slot];
3594            // Across a re-entrant turn whose sections differ, the corner
3595            // joins the two bands, each followed under the law to the
3596            // vertex and carried on level past it.
3597            if junction.crossed && junction.reentrant {
3598                continue;
3599            }
3600            if junction.reentrant {
3601                let Some(cap) =
3602                    crate::junction::cap_at(model, &entry.2.solid, junction.at, outward, tol)?
3603                else {
3604                    ogeom_bail!(
3605                        Construction,
3606                        "a band meeting another across a re-entrant turn has no \
3607                         cap at the vertex to carry on from"
3608                    );
3609                };
3610                let hosts: Vec<Shape> =
3611                    ogeom_topo::ancestors_of(model, solid, &edge, ShapeType::Face)?;
3612                entry.2.solid = crate::junction::carried_into(
3613                    model,
3614                    &entry.2.solid,
3615                    &cap,
3616                    (&edge, &hosts),
3617                    outward,
3618                    junction.keeps(slot),
3619                    tol,
3620                )?;
3621                continue;
3622            }
3623            // A marched band ran out past the vertex as it was built, and a
3624            // band run on past it is built so.
3625            if !edge_curve(model, &edge, tol).is_ok_and(|(c, _)| matches!(c, Curve::Line(_)))
3626                || run_on
3627                    .iter()
3628                    .any(|&(k, at)| k == index && at.distance(junction.at) <= tol.confusion())
3629            {
3630                continue;
3631            }
3632            // The constant band alone, and what of it stands past the
3633            // vertex: its run-out, where it runs out there.
3634            let (built, wedges) = crate::support::collecting_wedges(|| {
3635                planar_fillet(model, solid, &edge, Section::Ball(radius), None, tol)
3636            });
3637            built?;
3638            let Some(alone) = wedges.into_iter().next() else {
3639                continue;
3640            };
3641            // A straight band's sections stand square to its crease, so
3642            // its vertices reach as far past the vertex as any point of it.
3643            let mut past = f64::NEG_INFINITY;
3644            for vertex in explore_unique(model, &alone.solid, ShapeType::Vertex)? {
3645                if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
3646                    let p = vertex.transform(model.datums())?.apply(data.point);
3647                    past = past.max((p - junction.at).dot(outward));
3648                }
3649            }
3650            if past <= gap {
3651                continue;
3652            }
3653            let run_out = crate::support::trimmed_wedge(
3654                model,
3655                &alone.solid,
3656                (junction.at, outward),
3657                ogeom_algo::shape_bounds(model, &alone.solid, tol)?.diagonal() * 2.0,
3658                tol,
3659            )?;
3660            let (solid_joined, _) = crate::junction::joined(
3661                model,
3662                [&entry.2.solid, &run_out],
3663                (junction.at, outward),
3664                junction.at,
3665                gap,
3666                tol,
3667            )?;
3668            entry.2.solid = solid_joined;
3669        }
3670    }
3671    let halves = with_corners(model, &mut set_aside, &junctions, solid, true, tol)?;
3672    let joined = join_flush(model, set_aside, &junctions, &halves, tol)?;
3673    let mut built = apply_set_aside(model, Built::from_nothing(solid.clone()), joined, tol)?;
3674    for edge in chain {
3675        built.history.delete(edge);
3676    }
3677    Ok(built)
3678}
3679
3680/// A meeting of a chain under a law: its two edges, where they meet, and
3681/// the law's radius there.
3682type Meeting = ([usize; 2], Point, f64);
3683
3684/// The straight bands of a chain under a law that run on past a convex
3685/// turn rather than meet a constant run-out there: where the two end
3686/// sections are no reflection of each other in the plane bisecting the turn
3687/// (a drafted wall beside a square one), the other band crosses this one's
3688/// run-out near the vertex, and a seam there between the law's band and the
3689/// constant run-out is what the cut leaves open. Each such band is built
3690/// again over its edge and past it as far as the constant band of the
3691/// meeting's radius runs out alone, the law held level past the edge's
3692/// end: one surface through the vertex, which past the vertex is that
3693/// constant band itself where it ends square to its crease. A constant band
3694/// trimmed by a face it runs into is left to the join. Returns each band's
3695/// index with the vertex it runs on past.
3696fn run_on_past_turns(
3697    model: &mut Model,
3698    solid: &Shape,
3699    chain: &[Shape],
3700    (walk, owns, halved): (&[(usize, bool)], &[Profile], &[usize]),
3701    (junctions, meetings): (&[crate::junction::Junction], &[Meeting]),
3702    set_aside: &mut [(Vec<usize>, Shape, crate::support::Wedge)],
3703    tol: Tolerances,
3704) -> OgeomResult<Vec<(usize, Point)>> {
3705    let gap = crate::marched::band_fit_target(tol) * 2.0;
3706    // How far past each end of its oriented run, as fractions of its
3707    // length, and each turn it runs on past.
3708    let mut ends: Vec<[f64; 2]> = vec![[0.0; 2]; chain.len()];
3709    let mut alone_at: Vec<(usize, Point)> = Vec::new();
3710    for (junction, &(_, _, radius)) in junctions.iter().zip(meetings) {
3711        if junction.tangent || junction.reentrant || !junction.crossed {
3712            continue;
3713        }
3714        let (meet, bisector) = junction.flush;
3715        let reflect = |v: Vector| v - bisector * (2.0 * v.dot(bisector));
3716        let miss = radius.mul_add(
3717            (reflect(junction.sides[0]) - junction.sides[1]).magnitude(),
3718            2.0 * (junction.at - meet).dot(bisector).abs(),
3719        );
3720        if miss <= crate::marched::band_fit_target(tol) {
3721            continue;
3722        }
3723        for slot in 0..2 {
3724            let index = junction.pair[slot];
3725            let edge = &chain[index];
3726            let (curve, range) = edge_curve(model, edge, tol)?;
3727            if !matches!(curve, Curve::Line(_)) || halved.contains(&index) {
3728                continue;
3729            }
3730            let outward = junction.outward[slot];
3731            let (built, wedges) = crate::support::collecting_wedges(|| {
3732                planar_fillet(model, solid, edge, Section::Ball(radius), None, tol)
3733            });
3734            built?;
3735            let Some(alone) = wedges.into_iter().next() else {
3736                continue;
3737            };
3738            let mut past = f64::NEG_INFINITY;
3739            for vertex in explore_unique(model, &alone.solid, ShapeType::Vertex)? {
3740                if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
3741                    let p = vertex.transform(model.datums())?.apply(data.point);
3742                    past = past.max((p - junction.at).dot(outward));
3743                }
3744            }
3745            // The constant band runs out as the straight band of its
3746            // section, ended square to its crease where it has left the
3747            // solid; one trimmed by a face's plane it runs into is met as
3748            // before.
3749            let far = junction.at + outward * past;
3750            if past <= gap
3751                || !crate::junction::ends_in(model, &alone.solid, (far, outward), far, gap, tol)?
3752            {
3753                continue;
3754            }
3755            let first = ogeom_geom::Curve3d::point_at(&curve, range.0, tol)?;
3756            let last = ogeom_geom::Curve3d::point_at(&curve, range.1, tol)?;
3757            let start = if edge.orientation() == ogeom_topo::Orientation::Reversed {
3758                last
3759            } else {
3760                first
3761            };
3762            let end = usize::from(start.distance(junction.at) > first.distance(last) / 2.0);
3763            ends[index][end] = ends[index][end].max(past / first.distance(last));
3764            alone_at.push((index, junction.at));
3765        }
3766    }
3767    let mut run_on = Vec::new();
3768    for (index, &[before, after]) in ends.iter().enumerate() {
3769        if before == 0.0 && after == 0.0 {
3770            continue;
3771        }
3772        let Some(slot) = walk.iter().position(|&(k, _)| k == index) else {
3773            continue;
3774        };
3775        let edge = &chain[index];
3776        let law = owns[slot].stretched(before, after);
3777        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3778        let part = if reversed {
3779            (-after, 1.0 + before)
3780        } else {
3781            (-before, 1.0 + after)
3782        };
3783        let (built, wedges) = crate::support::collecting_wedges(|| {
3784            straight_law_fillet(model, solid, edge, &law, reversed, part, tol)
3785        });
3786        built?;
3787        let [wedge] = <[crate::support::Wedge; 1]>::try_from(wedges).map_err(|w| {
3788            ogeom_core::ogeom_err!(
3789                Construction,
3790                "a band run on past its turns built {} wedges, not one",
3791                w.len()
3792            )
3793        })?;
3794        let Some(entry) = set_aside
3795            .iter_mut()
3796            .find(|(held, _, _)| held.contains(&index))
3797        else {
3798            continue;
3799        };
3800        entry.2 = wedge;
3801        for (_, at) in alone_at.iter().filter(|(k, _)| *k == index) {
3802            run_on.push((index, *at));
3803        }
3804    }
3805    Ok(run_on)
3806}
3807
3808/// The straight planar seat under a law of points: the envelope exact, a
3809/// cubic B-spline along the edge by the rational quadratic arc across it.
3810///
3811/// Along a straight edge the arc length is affine in the parameter, so the
3812/// law's cubic B-spline is the radius at every section; the apex line in
3813/// the same basis has the Greville points for control points. Each row of
3814/// the net is that apex control point moved out along the section's arm
3815/// by the law's control radius, which is the section's arc at every
3816/// parameter, since both are affine in the net. `part` is the stretch of
3817/// the edge built, by fractions of its stored parameter, `(0, 1)` for the
3818/// whole: the law is laid over that stretch, and the band is capped
3819/// square to the edge at its ends.
3820fn straight_law_fillet(
3821    model: &mut Model,
3822    solid: &Shape,
3823    edge: &Shape,
3824    law: &Profile,
3825    reversed: bool,
3826    part: (f64, f64),
3827    tol: Tolerances,
3828) -> OgeomResult<Built> {
3829    let seat = planar_seat(model, solid, edge, tol)?;
3830    straight_law_on(model, solid, edge, seat, law, reversed, part, tol)
3831}
3832
3833/// [`straight_law_fillet`] on `seat`, the edge's own or one its band takes
3834/// a narrow strip beside it off whole on, running the way the edge does.
3835#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
3836#[allow(clippy::too_many_lines, reason = "one wedge, assembled end to end")]
3837fn straight_law_on(
3838    model: &mut Model,
3839    solid: &Shape,
3840    edge: &Shape,
3841    mut seat: crate::support::Seat,
3842    law: &Profile,
3843    reversed: bool,
3844    part: (f64, f64),
3845    tol: Tolerances,
3846) -> OgeomResult<Built> {
3847    let (whole_start, whole_end) = (seat.start, seat.end);
3848    seat.start = whole_start + (whole_end - whole_start) * part.0;
3849    seat.end = whole_start + (whole_end - whole_start) * part.1;
3850    let (first, second) = if seat
3851        .along
3852        .dot(seat.normals[0].cross(seat.normals[1]))
3853        .is_sign_positive()
3854    {
3855        (0, 1)
3856    } else {
3857        (1, 0)
3858    };
3859    let sign = if seat.convex { 1.0 } else { -1.0 };
3860    let n1 = seat.normals[first] * sign;
3861    let n2 = seat.normals[second] * sign;
3862    let a = seat.leg(first, tol)? * sign;
3863    let b = seat.leg(second, tol)? * sign;
3864    let bisector = {
3865        let u = a + b;
3866        let m = u.magnitude();
3867        if m <= tol.angular() {
3868            ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
3869        }
3870        u / m
3871    };
3872    let depth = -n1.dot(bisector);
3873    if depth <= tol.angular() {
3874        ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
3875    }
3876    let sweep = seat.along.dot(n1.cross(n2)).atan2(n1.dot(n2)) * sign;
3877    if sweep <= tol.angular() {
3878        ogeom_bail!(Construction, "the faces are parallel; there is no corner");
3879    }
3880
3881    // The law over the edge's own parameter, start to end: the chain's
3882    // fraction where the edge runs with it, and its complement where
3883    // against.
3884    let (knots, radii) = {
3885        let (knots, values) = law.spline(tol);
3886        if reversed {
3887            (
3888                knots.iter().rev().map(|k| 1.0 - k).collect::<Vec<f64>>(),
3889                values.into_iter().rev().collect::<Vec<f64>>(),
3890            )
3891        } else {
3892            (knots, values)
3893        }
3894    };
3895    let greville: Vec<f64> = (0..radii.len())
3896        .map(|i| (knots[i + 1] + knots[i + 2] + knots[i + 3]) / 3.0)
3897        .collect();
3898    let apex_at = |u: f64| seat.start + (seat.end - seat.start) * u;
3899    let apex = [seat.start, seat.end];
3900    let end_radii = [radii[0], radii[radii.len() - 1]];
3901    let centre = [
3902        apex[0] + bisector * (end_radii[0] / depth),
3903        apex[1] + bisector * (end_radii[1] / depth),
3904    ];
3905    let contact_a = [centre[0] + n1 * end_radii[0], centre[1] + n1 * end_radii[1]];
3906    let contact_b = [centre[0] + n2 * end_radii[0], centre[1] + n2 * end_radii[1]];
3907
3908    let half_cos = (sweep / 2.0).cos();
3909    let arc_mid = (n1 + n2) / (1.0 + n1.dot(n2));
3910    let weights = [1.0, half_cos, 1.0];
3911    let arms = [n1, arc_mid, n2];
3912    let lift = bisector / depth;
3913
3914    let u_knots = ogeom_math::KnotVector::new(knots.clone(), 3)?;
3915    let v_knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
3916    let mut net: Vec<ogeom_math::Weighted<Point>> = Vec::with_capacity(radii.len() * 3);
3917    for (g, r) in greville.iter().zip(&radii) {
3918        for (arm, w) in arms.iter().zip(weights) {
3919            net.push(ogeom_math::Weighted::new(
3920                apex_at(*g) + (lift + *arm) * *r,
3921                w,
3922                tol,
3923            )?);
3924        }
3925    }
3926    let grid = ogeom_math::ControlGrid::new(net, radii.len(), 3)?;
3927    let surface: SurfaceGeometry =
3928        ogeom_geom::BSplineSurface::rational(u_knots, v_knots, grid)?.into();
3929    let surface_id = model.geometry_mut().add_surface(surface.clone());
3930
3931    let va = [
3932        make_vertex(model, contact_a[0]).shape,
3933        make_vertex(model, contact_a[1]).shape,
3934    ];
3935    let vb = [
3936        make_vertex(model, contact_b[0]).shape,
3937        make_vertex(model, contact_b[1]).shape,
3938    ];
3939
3940    // The tangency lines: the net's border rows, each in its own face's
3941    // plane, since every arm's end lies in its face.
3942    let rail = |model: &mut Model,
3943                arm: Vector,
3944                ends: [&Shape; 2],
3945                row: f64|
3946     -> OgeomResult<Shape> {
3947        let control: Vec<Point> = greville
3948            .iter()
3949            .zip(&radii)
3950            .map(|(g, r)| apex_at(*g) + (lift + arm) * *r)
3951            .collect();
3952        let knot_vector = ogeom_math::KnotVector::new(knots.clone(), 3)?;
3953        let curve = Curve::BSpline(ogeom_geom::BSplineCurve::new(knot_vector, control, tol)?);
3954        let built = make_edge_between(model, curve, (0.0, 1.0), ends[0], ends[1], tol)?.shape;
3955        let image = ogeom_geom::Line2d::over(
3956            ogeom_math::Axis2::new(ogeom_math::Point2::new(0.0, row), ogeom_math::Direction2::X),
3957            0.0,
3958            1.0,
3959        )?;
3960        ogeom_algo::attach_pcurve(
3961            model,
3962            &built,
3963            image.into(),
3964            surface_id,
3965            ogeom_topo::Location::identity(),
3966            (0.0, 1.0),
3967        )?;
3968        Ok(built)
3969    };
3970    let rail_a = rail(model, n1, [&va[0], &va[1]], 0.0)?;
3971    let rail_b = rail(model, n2, [&vb[0], &vb[1]], 1.0)?;
3972
3973    let arc_edge = |model: &mut Model, i: usize| -> OgeomResult<Shape> {
3974        let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
3975        let control = vec![
3976            ogeom_math::Weighted::new(contact_a[i], 1.0, tol)?,
3977            ogeom_math::Weighted::new(centre[i] + arc_mid * end_radii[i], half_cos, tol)?,
3978            ogeom_math::Weighted::new(contact_b[i], 1.0, tol)?,
3979        ];
3980        let curve = Curve::BSpline(ogeom_geom::BSplineCurve::rational(knots, control)?);
3981        let built = make_edge_between(model, curve, (0.0, 1.0), &va[i], &vb[i], tol)?.shape;
3982        #[allow(clippy::cast_precision_loss)]
3983        let column = ogeom_geom::Line2d::over(
3984            ogeom_math::Axis2::new(
3985                ogeom_math::Point2::new(i as f64, 0.0),
3986                ogeom_math::Direction2::Y,
3987            ),
3988            0.0,
3989            1.0,
3990        )?;
3991        ogeom_algo::attach_pcurve(
3992            model,
3993            &built,
3994            column.into(),
3995            surface_id,
3996            ogeom_topo::Location::identity(),
3997            (0.0, 1.0),
3998        )?;
3999        Ok(built)
4000    };
4001    let arc0 = arc_edge(model, 0)?;
4002    let arc1 = arc_edge(model, 1)?;
4003
4004    let blend = {
4005        let wire = ogeom_algo::make_wire(
4006            model,
4007            &[
4008                rail_a.clone(),
4009                arc1.clone(),
4010                rail_b.reversed(),
4011                arc0.reversed(),
4012            ],
4013            tol,
4014        )?
4015        .shape;
4016        let face =
4017            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
4018        use ogeom_geom::Surface as _;
4019        let s_mid = surface.point_at(0.5, 0.5, tol)?;
4020        let (du, dv) = surface.d1_at(0.5, 0.5, tol)?;
4021        let apex_mid = apex[0].midpoint(apex[1]);
4022        let outward = s_mid - apex_mid.midpoint(s_mid);
4023        if du.cross(dv).dot(outward) >= 0.0 {
4024            face
4025        } else {
4026            face.reversed()
4027        }
4028    };
4029
4030    // A planar face on `corners`' plane bounded by `edges` in ring order,
4031    // walked back where the corners turn against `outward`.
4032    let plane_face = |model: &mut Model,
4033                      corners: [Point; 3],
4034                      edges: Vec<Shape>,
4035                      outward: Vector|
4036     -> OgeomResult<Shape> {
4037        let plane = Plane::through(corners[0], Direction::new(outward, tol)?);
4038        let reach = corners
4039            .iter()
4040            .fold(1.0_f64, |m, p| m.max(p.distance(corners[0])))
4041            * 2.0
4042            + seat.start.distance(seat.end) * 2.0;
4043        let surface: SurfaceGeometry =
4044            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
4045        let turn = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
4046        let edges: Vec<Shape> = if turn.dot(outward) < 0.0 {
4047            edges.iter().rev().map(Shape::reversed).collect()
4048        } else {
4049            edges
4050        };
4051        Ok(ogeom_algo::make_face_with_pcurves(model, surface, &[edges], tol)?.shape)
4052    };
4053    let apex_v = [
4054        make_vertex(model, apex[0]).shape,
4055        make_vertex(model, apex[1]).shape,
4056    ];
4057    let leg = |model: &mut Model,
4058               rail: &Shape,
4059               ends: &[Shape; 2],
4060               contact: [Point; 2],
4061               outward: Vector|
4062     -> OgeomResult<Shape> {
4063        let edges = vec![
4064            segment_between(model, (&apex_v[0], apex[0]), (&ends[0], contact[0]), tol)?,
4065            rail.clone(),
4066            segment_between(model, (&ends[1], contact[1]), (&apex_v[1], apex[1]), tol)?,
4067            segment_between(model, (&apex_v[1], apex[1]), (&apex_v[0], apex[0]), tol)?,
4068        ];
4069        plane_face(model, [apex[0], contact[0], contact[1]], edges, outward)
4070    };
4071    let leg_a = leg(model, &rail_a, &va, contact_a, n1 * sign)?;
4072    let leg_b = leg(model, &rail_b, &vb, contact_b, n2 * sign)?;
4073    let cap = |model: &mut Model, i: usize, outward: Vector| -> OgeomResult<Shape> {
4074        let edges = vec![
4075            segment_between(model, (&apex_v[i], apex[i]), (&va[i], contact_a[i]), tol)?,
4076            if i == 0 { arc0.clone() } else { arc1.clone() },
4077            segment_between(model, (&vb[i], contact_b[i]), (&apex_v[i], apex[i]), tol)?,
4078        ];
4079        plane_face(model, [apex[i], contact_a[i], contact_b[i]], edges, outward)
4080    };
4081    let cap0 = cap(model, 0, -seat.along)?;
4082    let cap1 = cap(model, 1, seat.along)?;
4083
4084    let faces = [leg_a, leg_b, cap0, cap1, blend];
4085    apply_wedge(model, solid, Some(edge), &faces, !seat.convex, tol)
4086}
4087
4088/// The straight-edge fillet: the rolling ball's envelope is a cylinder.
4089pub(crate) fn planar_fillet(
4090    model: &mut Model,
4091    solid: &Shape,
4092    edge: &Shape,
4093    section: Section,
4094    mates: Option<(usize, &[Mate])>,
4095    tol: Tolerances,
4096) -> OgeomResult<Built> {
4097    let radius = section.size();
4098    let mut seat = planar_seat(model, solid, edge, tol)?;
4099    // Where a convex crease runs into a face overhanging its corner (a rib
4100    // running up under a slanted roof), the band is trimmed by that face's
4101    // plane: carried on until the plane has passed its whole section, and
4102    // what lies behind the plane cut off it, so the band stops on the face
4103    // rather than cutting into what it bounds. A face square to the crease
4104    // meets the band's cap in the cap's own plane, and the cap stands.
4105    let mut trims: Vec<(Point, Vector)> = Vec::new();
4106    let mut trimmed = [false; 2];
4107    for (slot, end) in [true, false].into_iter().enumerate() {
4108        if !seat.convex {
4109            break;
4110        }
4111        let at = if end { seat.start } else { seat.end };
4112        // A junction of the request closes the corner at this end.
4113        if mates.is_some_and(|(index, mates)| Mate::meets_at(mates, index, at, tol)) {
4114            continue;
4115        }
4116        let outward = if end { -seat.along } else { seat.along };
4117        let hosts = [&seat.faces[0], &seat.faces[1]];
4118        let Some((origin, normal)) =
4119            crate::support::overhang_at(model, solid, edge, hosts, at, outward, tol)?
4120        else {
4121            continue;
4122        };
4123        let Some(inward) = section.centre(&seat, tol)? else {
4124            continue;
4125        };
4126        // Where each point of the section crosses the plane, carried along
4127        // `outward`: a plane passes a section last at one of its corners.
4128        let rate = -outward.dot(normal);
4129        let crossings = section
4130            .points(&seat, at, inward, tol)?
4131            .map(|p| (origin - p).dot(normal) / rate);
4132        if crossings.iter().all(|c| c.abs() <= tol.confusion() * 1e3) {
4133            continue;
4134        }
4135        let reach = crossings.iter().fold(0.0_f64, |m, c| m.max(-*c)) + radius * 0.25;
4136        if end {
4137            seat.start -= seat.along * reach;
4138        } else {
4139            seat.end += seat.along * reach;
4140        }
4141        trims.push((at - normal * (at - origin).dot(normal), normal));
4142        trimmed[slot] = true;
4143    }
4144    // A straight seat runs out. Where a convex edge ends at a face that
4145    // cuts the corner away obliquely (a floor giving way to a ramp under a
4146    // wall that carries on), the seat runs on along the host it stays
4147    // tangent to until that face has cut its whole section away, and the
4148    // cut trims it there. At a plain wall the cap already stands in the
4149    // wall's own plane and stays; at a step the seat stops where the edge
4150    // does. Where a seat asked to meet ends against a neighbouring blend
4151    // (a band tangent to one of the hosts at the end vertex), the material
4152    // past the end is that blend's own rounding, and the seat carries on
4153    // through it until the ball has left the solid; the cut then trims the
4154    // two bands against each other along their own intersection, which is
4155    // what two fillets meeting at a corner are.
4156    let mut probes = crate::support::LazyProbe::default();
4157    for (slot, end) in [true, false].into_iter().enumerate() {
4158        // A band trimmed by a face it runs into stops on that face.
4159        if trimmed[slot] {
4160            continue;
4161        }
4162        let at = if end { seat.start } else { seat.end };
4163        let outward = if end { -seat.along } else { seat.along };
4164        if let Some((index, mates)) = mates {
4165            // A mate leaving this vertex the way this edge arrives is a
4166            // chain mate: the caps stand flush and the melt joins the
4167            // blends.
4168            let chain = mates.iter().enumerate().any(|(i, mate)| {
4169                i != index
4170                    && mate.ends.iter().any(|(p, leaving)| {
4171                        p.distance(at) <= tol.confusion() * 1e3
4172                            && leaving.cross(outward).magnitude() <= 1e-2
4173                            && leaving.dot(outward) > 0.0
4174                    })
4175            });
4176            if chain {
4177                continue;
4178            }
4179            // A corner the corner tool closed, or a junction of the request
4180            // that meets the band here.
4181            if Mate::settled_at(mates, index, at, tol) || Mate::meets_at(mates, index, at, tol) {
4182                continue;
4183            }
4184        }
4185        let hosts = [&seat.faces[0], &seat.faces[1]];
4186        if !crate::marched::crease_terminates_at(model, solid, edge, hosts, at, tol)? {
4187            continue;
4188        }
4189        let through_blend =
4190            crate::marched::neighbour_blend_at(model, solid, hosts, at, seat.convex, radius, tol)?;
4191        // One edge at a time stops flush against a neighbouring blend: the
4192        // state the corner tool closes. A fill runs on only through one.
4193        if through_blend && mates.is_none() || !through_blend && !seat.convex {
4194            continue;
4195        }
4196        // The ball's centre line, a radius in from both hosts.
4197        let Some(inward) = section.centre(&seat, tol)? else {
4198            continue;
4199        };
4200        let centre_at = |s: f64| at + outward * s + inward;
4201        let step = radius / 8.0;
4202        let mut reach: Option<f64> = None;
4203        if through_blend {
4204            let Some(probe) = probes.get(model, solid, tol)? else {
4205                continue;
4206            };
4207            for k in 1..=32 {
4208                let s = step * f64::from(k);
4209                let inside = probe.holds(model, centre_at(s), tol)? == ogeom_algo::Containment::In;
4210                if inside != seat.convex {
4211                    reach = Some(s + radius * 0.25);
4212                    break;
4213                }
4214            }
4215        } else {
4216            // No blend follows: the band runs out across whatever face cuts
4217            // the corner away past the end, probed over its section there,
4218            // the crease, the two contacts, each leg's middle and the arc's.
4219            // A boss's cylinder is run out across only where the band runs
4220            // out as it does alone: where another band of the request ends
4221            // at the vertex with no junction there, the two meet as before,
4222            // the later running on through the earlier.
4223            let points = section.points(&seat, at, inward, tol)?;
4224            let boss = mates.is_none_or(|(index, mates)| Mate::alone_at(mates, index, at, tol));
4225            reach = crate::support::run_out_reach(
4226                model,
4227                solid,
4228                edge,
4229                hosts,
4230                (at, outward),
4231                &points,
4232                (radius, boss),
4233                &mut probes,
4234                tol,
4235            )?;
4236        }
4237        if std::env::var_os("OGEOM_DEBUG_RUNOUT").is_some() {
4238            eprintln!("RUNOUT straight seat end {end}: reach {reach:?}");
4239        }
4240        let Some(mut reach) = reach else {
4241            continue;
4242        };
4243        // The edge as asked for ends at its own vertex, where its blend
4244        // alone caps flush. Where the ball's centre is still in the material
4245        // a step past that vertex, the solid runs on there (a curved face
4246        // leaving the vertex obliquely), and a seat carried further would
4247        // round material no ball on this edge touches: it stops at the
4248        // vertex, as rounding this edge before the neighbour does.
4249        if let Some(mate) = mates.and_then(|(index, mates)| mates.get(index)) {
4250            let own = mate
4251                .ends
4252                .iter()
4253                .map(|(p, _)| *p)
4254                .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
4255            let to_own = own.map(|own| (own - at).dot(outward));
4256            if let Some(to_own) = to_own.filter(|&t| t > tol.confusion() && t < reach)
4257                && let Some(probe) = probes.get(model, solid, tol)?
4258                && probe.holds(model, centre_at(to_own + step), tol)? == ogeom_algo::Containment::In
4259            {
4260                reach = to_own;
4261            }
4262        }
4263        if end {
4264            seat.start -= seat.along * reach;
4265        } else {
4266            seat.end += seat.along * reach;
4267        }
4268    }
4269    match section {
4270        Section::Ball(radius) => {
4271            seated_fillet(model, solid, &seat, radius, Some(edge), &trims, tol)
4272        }
4273        Section::Conic { .. } => {
4274            crate::asym::seated_conic(model, solid, &seat, section, Some(edge), &trims, tol)
4275        }
4276    }
4277}
4278
4279/// From a point of a straight seat's crease to the ball's centre in the
4280/// section square to the crease there; `None` where the faces meet too
4281/// sharply or too flat to seat a ball.
4282pub(crate) fn seat_centre(
4283    seat: &Seat,
4284    radius: f64,
4285    tol: Tolerances,
4286) -> OgeomResult<Option<Vector>> {
4287    let sign = if seat.convex { 1.0 } else { -1.0 };
4288    let bisector = seat.leg(0, tol)? * sign + seat.leg(1, tol)? * sign;
4289    let m = bisector.magnitude();
4290    if m <= tol.angular() {
4291        return Ok(None);
4292    }
4293    let depth = seat.normals[0].dot(bisector / m).abs();
4294    if depth <= tol.angular() {
4295        return Ok(None);
4296    }
4297    Ok(Some(bisector / m * (radius / depth)))
4298}
4299
4300/// Points of a straight seat's band section square to the crease at `at`,
4301/// the ball's centre `inward` of it: the section's corners (the crease and
4302/// the two contacts) first, then each leg's middle and the arc's.
4303pub(crate) fn section_at(seat: &Seat, at: Point, inward: Vector, radius: f64) -> [Point; 6] {
4304    let centre = at + inward;
4305    let sign = if seat.convex { 1.0 } else { -1.0 };
4306    let touch = [0, 1].map(|i| centre + seat.normals[i] * (radius * sign));
4307    let arc = centre - inward * (radius / inward.magnitude());
4308    [
4309        at,
4310        touch[0],
4311        touch[1],
4312        at.midpoint(touch[0]),
4313        at.midpoint(touch[1]),
4314        arc,
4315    ]
4316}
4317
4318/// Where the rolling ball of `radius` seats between a straight seat's two
4319/// planes: the faces in the order that turns positively about the crease,
4320/// so the ball's arc and the cylinder's chart run from the first face's
4321/// contact to the second's; their normals; the sign that mirrors every
4322/// direction onto a concave seat's open side; the ball's centre from the
4323/// crease, square to it; and the arc's sweep.
4324#[derive(Debug, Clone, Copy)]
4325pub(crate) struct BallSeat {
4326    /// The seat's face the arc starts on.
4327    pub first: usize,
4328    /// The face it ends on.
4329    pub second: usize,
4330    /// The first face's outward normal.
4331    pub n1: Vector,
4332    /// The second's.
4333    pub n2: Vector,
4334    /// `1` on a convex seat, `-1` on a concave one.
4335    pub sign: f64,
4336    /// From a point of the crease to the ball's centre.
4337    pub offset: Vector,
4338    /// The angle the arc turns through, about the crease.
4339    pub sweep: f64,
4340}
4341
4342/// [`BallSeat`] of `seat` at `radius`; refused where the faces meet too
4343/// sharply or are parallel.
4344pub(crate) fn ball_seat(seat: &Seat, radius: f64, tol: Tolerances) -> OgeomResult<BallSeat> {
4345    // Order the two faces so the blend arc sweeps positively about the edge
4346    // direction: the cylinder's parameterization and every arc then run
4347    // from the first face's tangency line to the second's.
4348    let (first, second) = if seat
4349        .along
4350        .dot(seat.normals[0].cross(seat.normals[1]))
4351        .is_sign_positive()
4352    {
4353        (0, 1)
4354    } else {
4355        (1, 0)
4356    };
4357    let n1 = seat.normals[first];
4358    let n2 = seat.normals[second];
4359    // On a concave edge the legs mirror into the open dihedral, the ball
4360    // rolls there instead of in the material, and the contacts sit on the
4361    // faces' other side of the centre.
4362    let sign = if seat.convex { 1.0 } else { -1.0 };
4363    let a = seat.leg(first, tol)? * sign;
4364    let b = seat.leg(second, tol)? * sign;
4365
4366    // The rolling ball's centre line: along the bisector, at the distance
4367    // where the ball touches both planes.
4368    let bisector = {
4369        let u = a + b;
4370        let m = u.magnitude();
4371        if m <= tol.angular() {
4372            ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
4373        }
4374        u / m
4375    };
4376    let depth = (n1.dot(bisector)).abs();
4377    if depth <= tol.angular() {
4378        ogeom_bail!(Construction, "the faces meet too sharply to seat a fillet");
4379    }
4380    let offset = bisector * (radius / depth);
4381    let sweep = seat.along.dot(n1.cross(n2)).atan2(n1.dot(n2));
4382    if sweep <= tol.angular() {
4383        ogeom_bail!(Construction, "the faces are parallel; there is no corner");
4384    }
4385    Ok(BallSeat {
4386        first,
4387        second,
4388        n1,
4389        n2,
4390        sign,
4391        offset,
4392        sweep,
4393    })
4394}
4395
4396/// The cylindrical blend of a seat, whatever found the seat.
4397///
4398/// A blend's construction cares about the seat (where the ball rolls and
4399/// which way the two planes face) and not at all about whether an edge of
4400/// the solid runs along it. An edge gives one; two faces that share nothing
4401/// give the same one through their planes' own intersection, and everything
4402/// from here down is common to both.
4403pub(crate) fn seated_fillet(
4404    model: &mut Model,
4405    solid: &Shape,
4406    seat: &Seat,
4407    radius: f64,
4408    edge: Option<&Shape>,
4409    trims: &[(Point, Vector)],
4410    tol: Tolerances,
4411) -> OgeomResult<Built> {
4412    let BallSeat {
4413        n1,
4414        n2,
4415        sign,
4416        offset,
4417        sweep,
4418        ..
4419    } = ball_seat(seat, radius, tol)?;
4420    let centre = seat.start + offset;
4421
4422    let travel = seat.end - seat.start;
4423    let length = travel.magnitude();
4424    let apex0 = seat.start;
4425    let apex1 = seat.end;
4426    let contact_a0 = centre + n1 * (radius * sign);
4427    let contact_b0 = centre + n2 * (radius * sign);
4428    let contact_a1 = contact_a0 + travel;
4429    let contact_b1 = contact_b0 + travel;
4430
4431    let along_dir = Direction::new(seat.along, tol)?;
4432    let radial_dir = Direction::new(n1 * sign, tol)?;
4433
4434    // An arc of the blend circle at height `h` along the edge, running from
4435    // the first face's contact vertex to the second's. Its frame is the
4436    // cylinder's frame translated up the axis, which is what makes its pcurve
4437    // on the cylinder a straight line in `(u, v)` with the arc's own
4438    // parameter.
4439    let arc = |model: &mut Model, h: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
4440        let frame = Frame::new(centre + seat.along * h, along_dir, radial_dir, tol)?;
4441        let circle = Circle::new(frame, radius, tol)?;
4442        let curve = Curve::Circle(CircleCurve::new(circle));
4443        Ok(make_edge_between(model, curve, (0.0, sweep), from, to, tol)?.shape)
4444    };
4445
4446    // The legs, coplanar with the solid's own faces: aligned with them on a
4447    // convex edge, where the melt keeps one copy, opposed on a concave one,
4448    // where the fuse cancels both.
4449    let leg_a = planar_face(
4450        model,
4451        &[apex0, contact_a0, contact_a1, apex1],
4452        n1 * sign,
4453        tol,
4454    )?;
4455    let leg_b = planar_face(
4456        model,
4457        &[apex0, contact_b0, contact_b1, apex1],
4458        n2 * sign,
4459        tol,
4460    )?;
4461
4462    // The caps: two segments to the tangency points, closed by the arc.
4463    let cap = |model: &mut Model,
4464               apex: Point,
4465               ca: Point,
4466               cb: Point,
4467               h: f64,
4468               outward: Vector|
4469     -> OgeomResult<Shape> {
4470        let plane = Plane::through(apex, Direction::new(outward, tol)?);
4471        let reach = (apex.distance(ca).max(apex.distance(cb)) + radius).max(1.0) * 2.0;
4472        let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
4473        let apex_v = make_vertex(model, apex).shape;
4474        let ca_v = make_vertex(model, ca).shape;
4475        let cb_v = make_vertex(model, cb).shape;
4476        let edges = vec![
4477            segment_between(model, (&apex_v, apex), (&ca_v, ca), tol)?,
4478            arc(model, h, &ca_v, &cb_v)?,
4479            segment_between(model, (&cb_v, cb), (&apex_v, apex), tol)?,
4480        ];
4481        // Walked back where apex, `ca`, `cb` turn against `outward`, so the
4482        // cap keeps itself on the left.
4483        let edges: Vec<Shape> = if (ca - apex).cross(cb - apex).dot(outward) < 0.0 {
4484            edges.iter().rev().map(Shape::reversed).collect()
4485        } else {
4486            edges
4487        };
4488        face_from_edges(model, surface.into(), &edges, tol)
4489    };
4490    let cap0 = cap(model, apex0, contact_a0, contact_b0, 0.0, -seat.along)?;
4491    let cap1 = cap(model, apex1, contact_a1, contact_b1, length, seat.along)?;
4492
4493    // The blend face itself. A cylinder's natural normal points away from its
4494    // axis (into this wedge, whose material lies between the cylinder and
4495    // the apex), so the face enters the shell reversed.
4496    let blend = {
4497        let frame = Frame::new(centre, along_dir, radial_dir, tol)?;
4498        let surface = CylinderSurface::new(Cylinder::new(frame, radius, tol)?, (0.0, length))?;
4499        let a0_v = make_vertex(model, contact_a0).shape;
4500        let b0_v = make_vertex(model, contact_b0).shape;
4501        let a1_v = make_vertex(model, contact_a1).shape;
4502        let b1_v = make_vertex(model, contact_b1).shape;
4503        let edges = vec![
4504            arc(model, 0.0, &a0_v, &b0_v)?,
4505            segment_between(model, (&b0_v, contact_b0), (&b1_v, contact_b1), tol)?,
4506            arc(model, length, &a1_v, &b1_v)?.reversed(),
4507            segment_between(model, (&a0_v, contact_a0), (&a1_v, contact_a1), tol)?.reversed(),
4508        ];
4509        face_from_edges(model, surface.into(), &edges, tol)?
4510    };
4511
4512    let faces = [leg_a, leg_b, cap0, cap1, blend.reversed()];
4513    let mut wedge = wedge_solid(model, &faces, false, tol)?;
4514    // Wide and deep enough to hold all of the wedge behind each plane.
4515    let span = length + (centre.distance(apex0) + radius) * 4.0;
4516    for &plane in trims {
4517        wedge = trimmed_wedge(model, &wedge, plane, span, tol)?;
4518    }
4519    apply_wedge_solid(model, solid, edge, &wedge, !seat.convex, tol)
4520}
4521
4522/// The circular-rim fillet: a ball rolling around a rim where a cylindrical
4523/// or conical wall meets a perpendicular planar cap traces a torus.
4524///
4525/// Four seats, one parameterization. With `sigma` the wall's outward radial
4526/// sign and `tau` telling whether the wall extends away from the cap's
4527/// outward side, the tube's centre circle sits at radius `R − sigma tau r`
4528/// beside a cylinder (where a cone's wall leans, as
4529/// [`RevolvedSeat::ball`](crate::support::RevolvedSeat::ball) derives),
4530/// lifted `tau r` against the cap's normal, and `tau` alone decides whether
4531/// the wedge subtracts (the external rim and the hole's rim, both convex) or
4532/// fuses (the boss base and the blind hole's floor, both concave). The wedge
4533/// is always the same three revolved faces: a band of the wall, an annulus
4534/// of the cap, and the stretch of tube between them, a quarter beside a
4535/// cylinder.
4536fn revolved_fillet(
4537    model: &mut Model,
4538    solid: &Shape,
4539    edge: &Shape,
4540    rim: &CircleCurve,
4541    radius: f64,
4542    tol: Tolerances,
4543) -> OgeomResult<Built> {
4544    let seat = revolved_seat(model, solid, edge, rim, tol)?;
4545    revolved_fillet_on(model, solid, edge, seat, radius, tol)
4546}
4547
4548/// [`revolved_fillet`] on `seat`, read from `edge` or from the faces either
4549/// side of a ring beside it.
4550fn revolved_fillet_on(
4551    model: &mut Model,
4552    solid: &Shape,
4553    edge: &Shape,
4554    seat: crate::support::RevolvedSeat,
4555    radius: f64,
4556    tol: Tolerances,
4557) -> OgeomResult<Built> {
4558    let ball = seat.ball(radius);
4559    let tube_rho = ball.rho;
4560    if tube_rho <= tol.confusion() {
4561        ogeom_bail!(
4562            Construction,
4563            "a fillet of radius {radius} swallows the axis of a rim of \
4564             radius {}",
4565            seat.radius
4566        );
4567    }
4568    let tube_level = seat.centre + seat.up * ball.height;
4569    let flanks = revolved_flanks(model, &seat, -seat.tau * ball.wall_height, tube_rho, tol)?;
4570
4571    // The blend: the tube between the tangencies, its natural normal away
4572    // from the tube's centre (into the wedge), so always reversed.
4573    let blend_band = {
4574        let surface: SurfaceGeometry = TorusSurface::new(Torus::new(
4575            seat.frame_at(tube_level, tol)?,
4576            tube_rho,
4577            radius,
4578            tol,
4579        )?)
4580        .into();
4581        if seat.slope == 0.0 {
4582            make_revolution_band(model, &surface, &flanks.wall_ring, &flanks.cap_ring, tol)?
4583        } else {
4584            tube_band(model, &surface, &seat, &flanks, tol)?
4585        }
4586        .reversed()
4587    };
4588
4589    let faces = [flanks.wall_band, flanks.annulus, blend_band];
4590    apply_wedge(model, solid, Some(edge), &faces, seat.additive(), tol)
4591}
4592
4593/// The tube between a cone seat's tangency rings, over the whole turn.
4594///
4595/// In the tube's chart the angle about the tube is counted from its outer
4596/// equator, and beside a cone the ball's arc can cross it: where the wall
4597/// leans out over the ball, it touches below the equator. So the rings'
4598/// rows are given, not read back: the cap's straight along `tau up` from
4599/// the tube's centre and the wall's within a half turn of it, the arc
4600/// between them less than one.
4601fn tube_band(
4602    model: &mut Model,
4603    surface: &SurfaceGeometry,
4604    seat: &crate::support::RevolvedSeat,
4605    flanks: &crate::support::RevolvedFlanks,
4606    tol: Tolerances,
4607) -> OgeomResult<Shape> {
4608    use core::f64::consts::{PI, TAU};
4609    let cap_row = seat.tau.atan2(0.0);
4610    let lean = seat.sigma * seat.tau;
4611    let wall_row = {
4612        let row = (-lean * seat.slope).atan2(lean);
4613        row + ((cap_row - row) / TAU).round() * TAU
4614    };
4615    debug_assert!((cap_row - wall_row).abs() < PI);
4616    let row = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
4617        Ok(ogeom_geom::Line2d::over(
4618            ogeom_math::Axis2::new(
4619                ogeom_math::Point2::new(0.0, v),
4620                ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), tol)?,
4621            ),
4622            0.0,
4623            TAU,
4624        )?
4625        .into())
4626    };
4627    ogeom_algo::make_band_between(
4628        model,
4629        surface,
4630        [
4631            (&flanks.wall_ring, row(wall_row)?),
4632            (&flanks.cap_ring, row(cap_row)?),
4633        ],
4634        tol,
4635    )
4636}
4637
4638/// Whether the blend along `edge` of `solid`, a circle, runs the circle's
4639/// whole turn: the edge is closed, or it is a piece of a rim the solid
4640/// holds whole on its revolved seat, or of a crease that closes on itself
4641/// on any other pair of hosts.
4642fn rounds_whole_turn(
4643    model: &Model,
4644    solid: &Shape,
4645    edge: &Shape,
4646    tol: Tolerances,
4647) -> OgeomResult<bool> {
4648    let (Curve::Circle(c), range) = edge_curve(model, edge, tol)? else {
4649        return Ok(false);
4650    };
4651    if ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b)) {
4652        return Ok(true);
4653    }
4654    match revolved_seat(model, solid, edge, &c, tol) {
4655        Ok(seat) => crate::support::runs_whole_turn(model, &seat, &c, range, tol),
4656        Err(OgeomError::Construction(_)) => crate::marched::crease_closes(model, solid, edge, tol),
4657        Err(e) => Err(e),
4658    }
4659}
4660
4661/// The circle an edge runs on, where it runs on one.
4662fn rim_circle(model: &Model, edge: &Shape, tol: Tolerances) -> Option<ogeom_math::Circle> {
4663    match edge_curve(model, edge, tol).ok()?.0 {
4664        Curve::Circle(c) => Some(c.circle()),
4665        _ => None,
4666    }
4667}
4668
4669/// Whether two circles are one: the same centre, radius and plane.
4670fn same_circle(a: &ogeom_math::Circle, b: &ogeom_math::Circle, tol: Tolerances) -> bool {
4671    let reach = tol.confusion() * 100.0;
4672    a.centre().distance(b.centre()) <= reach
4673        && (a.radius() - b.radius()).abs() <= reach
4674        && a.frame()
4675            .z()
4676            .vector()
4677            .cross(b.frame().z().vector())
4678            .magnitude()
4679            <= tol.angular()
4680}
4681
4682/// The edge of `solid` standing where `edge` stood.
4683///
4684/// A boolean rebuilds every face it splits with fresh edges and its history
4685/// speaks of faces, not of them, so a chain's later edge is re-found by
4686/// geometry: the solid's edges whose own samples all lie on the sought
4687/// edge's curve. A trimmed survivor qualifies, and so does each piece an
4688/// earlier blend running out across the edge left of it; every one is a
4689/// part of the edge still there to blend.
4690///
4691/// A corner, though, and not merely a line. An earlier blend's end cap can
4692/// stand in one of the hosts' own planes and leave a fresh edge along the
4693/// very line the sought one runs on (an L-bracket's front blend caps in
4694/// the wall's plane, along the wall's own top line) and there the two
4695/// faces are tangent: a seam across one flat, no corner to round, and no
4696/// part of what was asked for. Nor is a corner between other surfaces: a
4697/// cap standing square to the line where a curved host leaves it meets
4698/// that host along the very line, a corner of the cap's and none of the
4699/// edge's. A piece lies between the surfaces of `hosts`, the faces the
4700/// edge lay between where it was asked for.
4701pub(crate) fn refind_edges(
4702    model: &Model,
4703    solid: &Shape,
4704    edge: &Shape,
4705    hosts: &[Shape],
4706    tol: Tolerances,
4707) -> OgeomResult<Vec<Shape>> {
4708    use ogeom_geom::Curve3d as _;
4709    use ogeom_topo::ShapeType;
4710    let (curve, range) = edge_curve(model, edge, tol)?;
4711    // The edge's own stretch of its curve, for a candidate to lie within:
4712    // the wall's bottom edge is two pieces either side of a scoop, on one
4713    // line, and asking for the left one must not blend the right.
4714    let within = |t: f64| -> bool {
4715        let slack = (range.1 - range.0).abs() * 1e-3;
4716        let t = if curve.is_periodic() {
4717            let (lo, hi) = curve.domain();
4718            let period = hi - lo;
4719            if period > 0.0 {
4720                range.0 + (t - range.0).rem_euclid(period)
4721            } else {
4722                t
4723            }
4724        } else {
4725            t
4726        };
4727        t >= range.0.min(range.1) - slack && t <= range.0.max(range.1) + slack
4728    };
4729    // A candidate lying on the edge's curve lies in its box: one that does
4730    // not reach the box is not projected at all.
4731    let reach = ogeom_algo::shape_bounds(model, edge, tol)?.expanded(tol.confusion() * 100.0);
4732    let mut matches: Vec<Shape> = Vec::new();
4733    for candidate in ogeom_topo::explore_unique(model, solid, ShapeType::Edge)? {
4734        if candidate.is_same(edge) {
4735            return Ok(vec![candidate]);
4736        }
4737        if !ogeom_algo::shape_bounds(model, &candidate, tol)?.intersects(&reach) {
4738            continue;
4739        }
4740        let Ok((c_curve, c_range)) = edge_curve(model, &candidate, tol) else {
4741            continue;
4742        };
4743        let mut all_on = true;
4744        for i in 0..3 {
4745            let t = c_range.0 + (c_range.1 - c_range.0) * f64::from(i) / 2.0;
4746            let p = c_curve.point_at(t, tol)?;
4747            let projected = ogeom_algo::project_on_curve(&curve, p, 64, tol)?;
4748            if projected.distance > tol.confusion() * 100.0
4749                || (i == 1 && !within(projected.parameter))
4750            {
4751                all_on = false;
4752                break;
4753            }
4754        }
4755        if !all_on || matches.iter().any(|m| m.is_same(&candidate)) {
4756            continue;
4757        }
4758        let mid = c_curve.point_at(f64::midpoint(c_range.0, c_range.1), tol)?;
4759        let mut normals: Vec<Vector> = Vec::new();
4760        for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
4761            let touches = ogeom_topo::explore_unique(model, &face, ShapeType::Edge)?
4762                .iter()
4763                .any(|e| crate::support::same_occurrence(model, e, &candidate, tol));
4764            if let (true, Some(normal)) = (
4765                touches,
4766                crate::marched::face_normal_near(model, &face, mid, tol)?,
4767            ) {
4768                normals.push(normal);
4769            }
4770        }
4771        // Two faces that agree on their normal there are one smooth
4772        // surface; anything else the seat machinery speaks to in its own
4773        // words.
4774        if matches!(normals.as_slice(), [first, second]
4775            if first.cross(*second).magnitude() <= 1e-2)
4776        {
4777            continue;
4778        }
4779        let mut host_normals: Vec<Vector> = Vec::with_capacity(hosts.len());
4780        for host in hosts {
4781            if let Some(normal) = crate::marched::face_normal_near(model, host, mid, tol)? {
4782                host_normals.push(normal);
4783            }
4784        }
4785        let along = |a: &[Vector], b: &[Vector]| {
4786            a.iter()
4787                .all(|n| b.iter().any(|m| n.cross(*m).magnitude() <= 1e-4))
4788        };
4789        if let ([_, _], [_, _]) = (normals.as_slice(), host_normals.as_slice())
4790            && !(along(&normals, &host_normals) && along(&host_normals, &normals))
4791        {
4792            continue;
4793        }
4794        matches.push(candidate);
4795    }
4796    if matches.is_empty() {
4797        ogeom_bail!(
4798            Construction,
4799            "an earlier blend in the chain consumed this edge; the chain's \
4800             members interfere"
4801        );
4802    }
4803    Ok(matches)
4804}
4805
4806/// The rim fillet over an arc of the rim rather than its whole turn.
4807///
4808/// The same four-seat torus geometry as [`revolved_fillet`], restricted to
4809/// the arc's angular window and closed by two planar end caps in the
4810/// meridian half-planes at its ends. Every face is a chart rectangle or a
4811/// planar triangle, every edge an arc or a segment, every pcurve exact,
4812/// which is what lets a chain's neighbouring wedges melt at their shared
4813/// caps.
4814#[allow(clippy::too_many_arguments, reason = "the seat's own, and the section")]
4815pub(crate) fn revolved_arc_fillet(
4816    model: &mut Model,
4817    solid: &Shape,
4818    edge: &Shape,
4819    rim: &CircleCurve,
4820    radius: f64,
4821    conic: Option<&crate::asym::RimConic>,
4822    mates: Option<(usize, &[Mate])>,
4823    tol: Tolerances,
4824) -> OgeomResult<Built> {
4825    use ogeom_geom::Curve3d as _;
4826    let seat = revolved_seat(model, solid, edge, rim, tol)?;
4827
4828    // The ball's place in the meridian, or the conic section's contacts;
4829    // and how far the section reaches, which scales its run-out.
4830    let ball = conic.map_or_else(|| seat.ball(radius), |c| c.contacts(&seat));
4831    let radius = conic.map_or(radius, crate::asym::RimConic::size);
4832    let tube_rho = ball.rho;
4833    if tube_rho <= tol.confusion() {
4834        ogeom_bail!(
4835            Construction,
4836            "a fillet of radius {radius} swallows the axis of a rim of \
4837             radius {}",
4838            seat.radius
4839        );
4840    }
4841    let tube_level = seat.centre + seat.up * ball.height;
4842    let wall_level = seat.centre + seat.up * ball.wall_height;
4843
4844    // The arc's window in the seat's own parameterization: the edge's stored
4845    // circle may be framed anyhow, so the ends are read back through the
4846    // seat frame and the window is the one that holds the arc's midpoint.
4847    let seat_frame = seat.frame_at(seat.centre, tol)?;
4848    let (curve, crange) = edge_curve(model, edge, tol)?;
4849    let angle_of = |t: f64| -> OgeomResult<f64> {
4850        let local = seat_frame.to_local(curve.point_at(t, tol)?);
4851        Ok(local.y.atan2(local.x))
4852    };
4853    let tau_turn = core::f64::consts::TAU;
4854    let raw0 = angle_of(crange.0)?;
4855    let raw1 = angle_of(crange.1)?;
4856    let mid = angle_of(f64::midpoint(crange.0, crange.1))?;
4857    let (theta0, theta1) = {
4858        let span = (raw1 - raw0).rem_euclid(tau_turn);
4859        let inside = (mid - raw0).rem_euclid(tau_turn) <= span;
4860        if inside {
4861            (raw0, raw0 + span)
4862        } else {
4863            (raw1, raw1 + (tau_turn - span))
4864        }
4865    };
4866    if theta1 - theta0 <= tol.angular() {
4867        ogeom_bail!(Construction, "the rim arc has no length to blend along");
4868    }
4869    let dir_of = |theta: f64| -> Vector {
4870        seat_frame.x().vector() * theta.cos() + seat_frame.y().vector() * theta.sin()
4871    };
4872    // At an end where the meridian cap would stop short of an oblique face
4873    // the arc ends on, the band runs on past the end, its window widened
4874    // until the ball's section stands clear of the material (a plane leaves
4875    // the section's triangle last at a corner: the rim, the wall's contact
4876    // and the cap's), and a quarter radius further; the cut trims it
4877    // against the face, as a straight band's run-out is.
4878    let mut trims: Vec<(Point, Vector)> = Vec::new();
4879    let (theta0, theta1) = {
4880        let mut window = [theta0, theta1];
4881        let step = (radius / seat.radius) / 8.0;
4882        for (slot, sign) in [(0_usize, -1.0), (1, 1.0)] {
4883            let end = window[slot];
4884            let at = seat.centre + dir_of(end) * seat.radius;
4885            let ahead = seat.up.cross(dir_of(end)) * sign;
4886            let Some(face) = rim_end_stops_short(model, solid, edge, &seat, mates, at, ahead, tol)?
4887            else {
4888                continue;
4889            };
4890            let probe = crate::support::material_probe(model, solid, tol)?;
4891            let (on, back) = face;
4892            let mut past = None;
4893            let mut k = 1.0_f64;
4894            // Over four radii along the rim, as a straight band's run-out
4895            // is read: a section that has not left the material by then, or
4896            // whose corner leaves it other than across the face the arc ends
4897            // on, meets a step or a ledge past the end rather than a ramp
4898            // across it, and the band stops at the end.
4899            while k * step * seat.radius <= radius * 4.0 {
4900                let theta = end + sign * k * step;
4901                // The corners stand on the hosts; each is read a hundredth
4902                // of the way in towards the others, inside the corner.
4903                let (apex, wall, cap) = (
4904                    seat.centre + dir_of(theta) * seat.radius,
4905                    wall_level + dir_of(theta) * ball.wall_rho,
4906                    seat.centre + dir_of(theta) * tube_rho,
4907                );
4908                let section = [
4909                    apex + ((wall - apex) + (cap - apex)) * 0.01,
4910                    wall + (apex - wall) * 0.01 + (cap - wall) * 0.01,
4911                    cap + (apex - cap) * 0.01 + (wall - cap) * 0.01,
4912                ];
4913                let mut clear = true;
4914                let mut ledge = false;
4915                for p in section {
4916                    if probe.holds(model, p, tol)? == ogeom_algo::Containment::In {
4917                        clear = false;
4918                    } else if (p - on).dot(back) > tol.confusion() {
4919                        ledge = true;
4920                    }
4921                }
4922                if ledge {
4923                    break;
4924                }
4925                if clear {
4926                    past = Some(k * step + 2.0 * step);
4927                    break;
4928                }
4929                k += 1.0;
4930            }
4931            if let Some(past) = past {
4932                window[slot] = end + sign * past;
4933                trims.push(face);
4934            }
4935        }
4936        if window[1] - window[0] >= tau_turn - tol.angular() {
4937            ogeom_bail!(
4938                Construction,
4939                "a rim arc's band run on past both its ends would close the turn"
4940            );
4941        }
4942        (window[0], window[1])
4943    };
4944    let sweep = theta1 - theta0;
4945
4946    // Corner points at each end: A on the rim, W down the wall, C in on the
4947    // cap; and the frames the arcs run on.
4948    let dir_at = |theta: f64| -> Vector {
4949        seat_frame.x().vector() * theta.cos() + seat_frame.y().vector() * theta.sin()
4950    };
4951    // Every wedge chart is anchored at the arc's start: the window then
4952    // lives in (0, sweep), clear of the chart's own seam whatever part of
4953    // the rim the arc covers.
4954    let wedge_frame_at = |origin: Point| -> OgeomResult<Frame> {
4955        Frame::new(
4956            origin,
4957            Direction::new(seat.up, tol)?,
4958            Direction::new(dir_at(theta0), tol)?,
4959            tol,
4960        )
4961    };
4962    let apex_at = |theta: f64| seat.centre + dir_at(theta) * seat.radius;
4963    let wall_at = |theta: f64| wall_level + dir_at(theta) * ball.wall_rho;
4964    let cap_at = |theta: f64| seat.centre + dir_at(theta) * tube_rho;
4965
4966    // Six shared corner vertices, nine shared edges: a wire chains through
4967    // vertex objects, and neighbouring faces sew through shared edges.
4968    let va0 = make_vertex(model, apex_at(theta0)).shape;
4969    let va1 = make_vertex(model, apex_at(theta1)).shape;
4970    let vw0 = make_vertex(model, wall_at(theta0)).shape;
4971    let vw1 = make_vertex(model, wall_at(theta1)).shape;
4972    let vc0 = make_vertex(model, cap_at(theta0)).shape;
4973    let vc1 = make_vertex(model, cap_at(theta1)).shape;
4974
4975    let ring_arc = |model: &mut Model,
4976                    origin: Point,
4977                    r: f64,
4978                    from: &Shape,
4979                    to: &Shape|
4980     -> OgeomResult<Shape> {
4981        let circle = Circle::new(wedge_frame_at(origin)?, r, tol)?;
4982        let curve = Curve::Circle(CircleCurve::new(circle));
4983        Ok(make_edge_between(model, curve, (0.0, sweep), from, to, tol)?.shape)
4984    };
4985    // The ball's arc in the meridian at `theta`, from the wall contact to
4986    // the cap contact: the tube's own circle, in the meridian plane, a
4987    // quarter of it beside a cylinder.
4988    let quarter = |model: &mut Model, theta: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
4989        if let Some(c) = conic {
4990            return Ok(make_edge_between(
4991                model,
4992                c.meridian(&seat, theta, tol)?,
4993                (0.0, 1.0),
4994                from,
4995                to,
4996                tol,
4997            )?
4998            .shape);
4999        }
5000        let dir = dir_at(theta);
5001        let centre_m = tube_level + dir * tube_rho;
5002        let x_dir = Direction::new((dir - seat.up * seat.slope) * (seat.sigma * seat.tau), tol)?;
5003        let z_dir = Direction::new(dir.cross(seat.up) * seat.sigma, tol)?;
5004        let frame = Frame::new(centre_m, z_dir, x_dir, tol)?;
5005        let circle = Circle::new(frame, radius, tol)?;
5006        let curve = Curve::Circle(CircleCurve::new(circle));
5007        Ok(make_edge_between(model, curve, (0.0, ball.sweep), from, to, tol)?.shape)
5008    };
5009
5010    let arc_apex = ring_arc(model, seat.centre, seat.radius, &va0, &va1)?;
5011    let arc_wall = ring_arc(model, wall_level, ball.wall_rho, &vw0, &vw1)?;
5012    let arc_cap = ring_arc(model, seat.centre, tube_rho, &vc0, &vc1)?;
5013    let ruling0 = segment_between(model, (&va0, apex_at(theta0)), (&vw0, wall_at(theta0)), tol)?;
5014    let ruling1 = segment_between(model, (&vw1, wall_at(theta1)), (&va1, apex_at(theta1)), tol)?;
5015    let radial0 = segment_between(model, (&vc0, cap_at(theta0)), (&va0, apex_at(theta0)), tol)?;
5016    let radial1 = segment_between(model, (&va1, apex_at(theta1)), (&vc1, cap_at(theta1)), tol)?;
5017    let quarter0 = quarter(model, theta0, &vw0, &vc0)?;
5018    let quarter1 = quarter(model, theta1, &vw1, &vc1)?;
5019    // Each face keeps itself on the left of its ring in its own chart: a
5020    // ring given turning the other way is walked back.
5021    let walked = |edges: Vec<Shape>, back: bool| -> Vec<Shape> {
5022        if back {
5023            edges.iter().rev().map(Shape::reversed).collect()
5024        } else {
5025            edges
5026        }
5027    };
5028
5029    // The wall patch: the solid wall's own chart between the contact ring
5030    // and the rim, over the window, its chart reaching past the rim on
5031    // both sides whichever side of the cap the wall stands on. The ring
5032    // runs the contact ring forward and the rim back, anticlockwise in the
5033    // chart where the contact ring stands below the rim.
5034    let wall_patch = {
5035        let below = radius + 1.0;
5036        let surface = seat.wall_surface(
5037            (-below, below),
5038            (ball.wall_height.min(0.0), ball.wall_height.max(0.0)),
5039            Direction::new(dir_at(theta0), tol)?,
5040            tol,
5041        )?;
5042        let edges = walked(
5043            vec![
5044                arc_wall.clone(),
5045                ruling1.clone(),
5046                arc_apex.reversed(),
5047                ruling0.clone(),
5048            ],
5049            seat.tau < 0.0,
5050        );
5051        let face = face_from_edges(model, surface, &edges, tol)?;
5052        if seat.sigma * seat.tau < 0.0 {
5053            face.reversed()
5054        } else {
5055            face
5056        }
5057    };
5058
5059    // The cap sector: the cap plane between the rim and the contact ring.
5060    // The ring runs the rim forward about `up`, which turns it about the
5061    // plane's normal `up * tau` where the rim is the wider ring and the cap
5062    // faces up, or the narrower and it faces down.
5063    let cap_sector = {
5064        let plane = Plane::through(seat.centre, Direction::new(seat.up * seat.tau, tol)?);
5065        let reach = (seat.radius + radius + 1.0) * 2.0;
5066        let surface: SurfaceGeometry =
5067            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
5068        let rim_wider = seat.radius > tube_rho;
5069        let edges = walked(
5070            vec![
5071                arc_apex.clone(),
5072                radial1.clone(),
5073                arc_cap.reversed(),
5074                radial0.clone(),
5075            ],
5076            rim_wider != (seat.tau > 0.0),
5077        );
5078        face_from_edges(model, surface, &edges, tol)?
5079    };
5080
5081    // The blend: the quarter-tube patch over the window, reversed as the
5082    // full rim's band is: its natural normal points into the wedge. The
5083    // ring runs the wall's contact forward and the cap's back, anticlockwise
5084    // in the torus chart where the tube's angle grows from the wall's contact
5085    // to the cap's, which is where `sigma` is positive.
5086    let blend_patch = if let Some(c) = conic {
5087        c.band_sector(
5088            model,
5089            &seat,
5090            (theta0, sweep),
5091            [&arc_wall, &arc_cap, &quarter0, &quarter1],
5092            tol,
5093        )?
5094    } else {
5095        let surface: SurfaceGeometry = TorusSurface::new(Torus::new(
5096            wedge_frame_at(tube_level)?,
5097            tube_rho,
5098            radius,
5099            tol,
5100        )?)
5101        .into();
5102        let edges = walked(
5103            vec![
5104                arc_wall.clone(),
5105                quarter1.clone(),
5106                arc_cap.reversed(),
5107                quarter0.reversed(),
5108            ],
5109            seat.sigma < 0.0,
5110        );
5111        face_from_edges(model, surface, &edges, tol)?.reversed()
5112    };
5113
5114    // The end caps: planar triangles in the meridian half-planes, outward
5115    // along the rim's travel: behind it at the start, ahead at the end.
5116    // Each ring is walked back where the corners it passes in turn
5117    // against `outward`, so the cap keeps itself on the left: the quarter
5118    // arc bulges toward the rim corner and never past it, so the corners'
5119    // turn is the ring's.
5120    let tangent_at = |theta: f64| seat.up.cross(dir_at(theta));
5121    let end_cap = |model: &mut Model,
5122                   theta: f64,
5123                   outward: Vector,
5124                   edges: Vec<Shape>,
5125                   [p, q, r]: [Point; 3]|
5126     -> OgeomResult<Shape> {
5127        let plane = Plane::through(apex_at(theta), Direction::new(outward, tol)?);
5128        let reach = (seat.radius + radius + 1.0) * 2.0;
5129        let surface: SurfaceGeometry =
5130            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
5131        let edges = walked(edges, (q - p).cross(r - p).dot(outward) < 0.0);
5132        face_from_edges(model, surface, &edges, tol)
5133    };
5134    let cap_start = end_cap(
5135        model,
5136        theta0,
5137        -tangent_at(theta0),
5138        vec![ruling0.clone(), quarter0.clone(), radial0.clone()],
5139        [apex_at(theta0), wall_at(theta0), cap_at(theta0)],
5140    )?;
5141    let cap_end = end_cap(
5142        model,
5143        theta1,
5144        tangent_at(theta1),
5145        vec![ruling1.clone(), radial1.clone(), quarter1.reversed()],
5146        [wall_at(theta1), apex_at(theta1), cap_at(theta1)],
5147    )?;
5148
5149    let faces = [wall_patch, cap_sector, blend_patch, cap_start, cap_end];
5150    if !seat.additive() || trims.is_empty() {
5151        return apply_wedge(model, solid, Some(edge), &faces, seat.additive(), tol);
5152    }
5153    let mut wedge = crate::support::wedge_solid(model, &faces, false, tol)?;
5154    let span = (seat.radius + radius) * 4.0;
5155    for plane in trims {
5156        wedge = crate::support::trimmed_wedge(model, &wedge, plane, span, tol)?;
5157    }
5158    crate::support::apply_wedge_solid(model, solid, Some(edge), &wedge, true, tol)
5159}