Skip to main content

ogeom_fillet/
asym.rs

1//! Asymmetric rounds: a blend that meets one face a distance `d1` from the
2//! edge and the other `d2`, its section a conic tangent to both faces there.
3//!
4//! In the plane square to a straight crease, with `A` the crease and `P1`,
5//! `P2` the two contacts, the section is the rational quadratic Bézier arc
6//! with control points `P1`, `A`, `P2` and weights `1`, `w`, `1`. It is
7//! tangent to `P1A` at `P1` and to `AP2` at `P2`, so the band meets both
8//! faces with their tangent planes along its contact lines; and the arc's
9//! middle stands on the line from the chord's middle `M` to `A`, a share
10//! `rho = w / (1 + w)` of the way from `M`. A conic of shape `rho` takes it
11//! as asked; the elliptical profile takes the weight of the circular round
12//! of the same dihedral, `w = sin(alpha / 2)` with `alpha` the angle at `A`
13//! between the faces, which makes the arc the image of that round's arc
14//! under the affine map fixing `A` and stretching each face's leg to its
15//! own distance: an ellipse, and with equal distances the circle itself.
16//!
17//! The band along a straight crease is that arc swept along the crease: an
18//! extrusion of a rational B-spline, exact, which a plane crosses in closed
19//! form. Everything else about the seat (where its ends run out, a face
20//! overhanging its corner, the wedge cut or fused) is the circular round's
21//! own, with the section's corners and middle standing for the ball's.
22
23use crate::support::{Seat, face_from_edges, planar_face, segment_between};
24use ogeom_algo::{Built, make_edge_between, make_vertex};
25use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_geom::{BSplineCurve, Curve, ExtrusionSurface, PlaneSurface};
27use ogeom_math::bspline::Weighted;
28use ogeom_math::{Direction, KnotVector, Plane, Point, Vector};
29use ogeom_topo::{Model, Shape};
30
31/// The shape of an asymmetric round's section, between its two contacts.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub enum RoundProfile {
34    /// The ellipse tangent to each face at its distance: the circular
35    /// round's section stretched along each face to that face's distance.
36    /// With equal distances it is the circular round of
37    /// [`fillet_edge`](crate::fillet_edge).
38    Elliptical,
39    /// The conic tangent to each face at its distance whose middle stands
40    /// the share `rho` of the way from the middle of the chord between the
41    /// contacts to the crease: near `0` a bevel, `0.5` a parabola, near `1`
42    /// the sharp edge. `rho` lies strictly between `0` and `1`.
43    Conic(f64),
44}
45
46impl RoundProfile {
47    /// The middle control point's weight for a section whose faces' legs
48    /// from the crease make the cosine `cos_alpha` with each other.
49    pub(crate) fn weight(self, cos_alpha: f64) -> OgeomResult<f64> {
50        match self {
51            Self::Elliptical => Ok(((1.0 - cos_alpha) * 0.5).max(0.0).sqrt()),
52            Self::Conic(rho) => {
53                if !(rho > 0.0 && rho < 1.0) {
54                    ogeom_bail!(
55                        Construction,
56                        "a conic round's rho of {rho} is not strictly between 0 and 1"
57                    );
58                }
59                Ok(rho / (1.0 - rho))
60            }
61        }
62    }
63}
64
65/// What a straight seat's band sweeps along its crease.
66#[derive(Debug, Clone, Copy)]
67pub(crate) enum Section {
68    /// The rolling ball's arc, of this radius.
69    Ball(f64),
70    /// A conic arc tangent to each face of the seat at its distance from
71    /// the crease, in the seat's face order, of weight `w` on the crease.
72    Conic {
73        /// The distance along each face of the seat.
74        d: [f64; 2],
75        /// The weight of the crease's control point.
76        w: f64,
77    },
78}
79
80impl Section {
81    /// The section's reach from the crease: the ball's radius, or the
82    /// farther distance, which scales the steps an end's run-out takes.
83    pub(crate) fn size(self) -> f64 {
84        match self {
85            Self::Ball(radius) => radius,
86            Self::Conic { d, .. } => d[0].max(d[1]),
87        }
88    }
89
90    /// From a point of the seat's crease to a point well inside the band's
91    /// section: the ball's centre, or the conic arc's middle. `None` where
92    /// the faces meet too sharply or too flat to seat a ball.
93    pub(crate) fn centre(self, seat: &Seat, tol: Tolerances) -> OgeomResult<Option<Vector>> {
94        match self {
95            Self::Ball(radius) => crate::fillet::seat_centre(seat, radius, tol),
96            Self::Conic { d, w } => {
97                let f = legs(seat, tol)?;
98                Ok(Some((f[0] * d[0] + f[1] * d[1]) / (2.0 * (1.0 + w))))
99            }
100        }
101    }
102
103    /// Points of the band's section square to the crease at `at`, with
104    /// `inward` from [`Section::centre`]: the crease and the two contacts
105    /// first, then each leg's middle and the arc's.
106    pub(crate) fn points(
107        self,
108        seat: &Seat,
109        at: Point,
110        inward: Vector,
111        tol: Tolerances,
112    ) -> OgeomResult<[Point; 6]> {
113        match self {
114            Self::Ball(radius) => Ok(crate::fillet::section_at(seat, at, inward, radius)),
115            Self::Conic { d, .. } => {
116                let f = legs(seat, tol)?;
117                let touch = [at + f[0] * d[0], at + f[1] * d[1]];
118                Ok([
119                    at,
120                    touch[0],
121                    touch[1],
122                    at.midpoint(touch[0]),
123                    at.midpoint(touch[1]),
124                    at + inward,
125                ])
126            }
127        }
128    }
129}
130
131/// On each face of the seat, the unit direction square to the crease that
132/// runs along the face away from it.
133pub(crate) fn legs(seat: &Seat, tol: Tolerances) -> OgeomResult<[Vector; 2]> {
134    let sign = if seat.convex { 1.0 } else { -1.0 };
135    Ok([seat.leg(0, tol)? * sign, seat.leg(1, tol)? * sign])
136}
137
138/// The conic band of a straight seat: the wedge between the faces' legs
139/// and the swept arc, cut from the solid on a convex crease and fused on a
140/// concave one, trimmed first by each of `trims` as the circular round's.
141pub(crate) fn seated_conic(
142    model: &mut Model,
143    solid: &Shape,
144    seat: &Seat,
145    section: Section,
146    edge: Option<&Shape>,
147    trims: &[(Point, Vector)],
148    tol: Tolerances,
149) -> OgeomResult<Built> {
150    let Section::Conic { d, w } = section else {
151        ogeom_bail!(Construction, "a conic band asked of a ball's section");
152    };
153    // The faces in the order that turns positively about the crease, as
154    // the circular round orders them, so the arc and the extrusion's chart
155    // run from the first face's contact to the second's.
156    let (first, second) = if seat
157        .along
158        .dot(seat.normals[0].cross(seat.normals[1]))
159        .is_sign_positive()
160    {
161        (0, 1)
162    } else {
163        (1, 0)
164    };
165    let n1 = seat.normals[first];
166    let n2 = seat.normals[second];
167    let sweep = seat.along.dot(n1.cross(n2)).atan2(n1.dot(n2));
168    if sweep <= tol.angular() {
169        ogeom_bail!(Construction, "the faces are parallel; there is no corner");
170    }
171    let sign = if seat.convex { 1.0 } else { -1.0 };
172    let f = legs(seat, tol)?;
173    let (fa, fb) = (f[first], f[second]);
174    let (da, db) = (d[first], d[second]);
175
176    let travel = seat.end - seat.start;
177    let length = travel.magnitude();
178    let apex0 = seat.start;
179    let apex1 = seat.end;
180    let contact_a0 = apex0 + fa * da;
181    let contact_b0 = apex0 + fb * db;
182    let contact_a1 = contact_a0 + travel;
183    let contact_b1 = contact_b0 + travel;
184    let along_dir = Direction::new(seat.along, tol)?;
185
186    // The section's arc at height `h` along the crease, from the first
187    // face's contact to the second's.
188    let arc_curve = |h: f64| -> OgeomResult<Curve> {
189        let lift = seat.along * h;
190        conic_arc([contact_a0 + lift, apex0 + lift, contact_b0 + lift], w, tol)
191    };
192    let arc = |model: &mut Model, h: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
193        Ok(make_edge_between(model, arc_curve(h)?, (0.0, 1.0), from, to, tol)?.shape)
194    };
195
196    // The legs, in the solid's own faces' planes.
197    let leg_a = planar_face(
198        model,
199        &[apex0, contact_a0, contact_a1, apex1],
200        n1 * sign,
201        tol,
202    )?;
203    let leg_b = planar_face(
204        model,
205        &[apex0, contact_b0, contact_b1, apex1],
206        n2 * sign,
207        tol,
208    )?;
209
210    let reach = da.max(db);
211    let cap = |model: &mut Model,
212               apex: Point,
213               ca: Point,
214               cb: Point,
215               h: f64,
216               outward: Vector|
217     -> OgeomResult<Shape> {
218        let plane = Plane::through(apex, Direction::new(outward, tol)?);
219        let span = (apex.distance(ca).max(apex.distance(cb)) + reach).max(1.0) * 2.0;
220        let surface = PlaneSurface::over(plane, (-span, span), (-span, span))?;
221        let apex_v = make_vertex(model, apex).shape;
222        let ca_v = make_vertex(model, ca).shape;
223        let cb_v = make_vertex(model, cb).shape;
224        let edges = vec![
225            segment_between(model, (&apex_v, apex), (&ca_v, ca), tol)?,
226            arc(model, h, &ca_v, &cb_v)?,
227            segment_between(model, (&cb_v, cb), (&apex_v, apex), tol)?,
228        ];
229        let edges: Vec<Shape> = if (ca - apex).cross(cb - apex).dot(outward) < 0.0 {
230            edges.iter().rev().map(Shape::reversed).collect()
231        } else {
232            edges
233        };
234        face_from_edges(model, surface.into(), &edges, tol)
235    };
236    let cap0 = cap(model, apex0, contact_a0, contact_b0, 0.0, -seat.along)?;
237    let cap1 = cap(model, apex1, contact_a1, contact_b1, length, seat.along)?;
238
239    // The band: the arc swept along the crease. Its chart's normal, the
240    // arc's tangent across the sweep, faces the crease as a cylinder's
241    // faces away from its axis, so the face enters the wedge reversed as
242    // the circular round's does.
243    let band = {
244        let surface = ExtrusionSurface::new(arc_curve(0.0)?, along_dir, length)?;
245        let a0_v = make_vertex(model, contact_a0).shape;
246        let b0_v = make_vertex(model, contact_b0).shape;
247        let a1_v = make_vertex(model, contact_a1).shape;
248        let b1_v = make_vertex(model, contact_b1).shape;
249        let edges = vec![
250            arc(model, 0.0, &a0_v, &b0_v)?,
251            segment_between(model, (&b0_v, contact_b0), (&b1_v, contact_b1), tol)?,
252            arc(model, length, &a1_v, &b1_v)?.reversed(),
253            segment_between(model, (&a0_v, contact_a0), (&a1_v, contact_a1), tol)?.reversed(),
254        ];
255        face_from_edges(model, surface.into(), &edges, tol)?
256    };
257
258    let faces = [leg_a, leg_b, cap0, cap1, band.reversed()];
259    let mut wedge = crate::support::wedge_solid(model, &faces, false, tol)?;
260    let span = length + (reach * 2.0) * 4.0;
261    for &plane in trims {
262        wedge = crate::support::trimmed_wedge(model, &wedge, plane, span, tol)?;
263    }
264    crate::support::apply_wedge_solid(model, solid, edge, &wedge, !seat.convex, tol)
265}
266
267/// How far from 1 a piece of a conic arc's weight may stand before the arc
268/// is split there: a piece nearer the polynomial arc of weight 1 keeps the
269/// rules that integrate and intersect it near their polynomial accuracy.
270/// The circle's quarter, of weight `1/sqrt(2)`, stays whole.
271const WEIGHT_RANGE: (f64, f64) = (0.7, 1.25);
272
273/// The conic arc with control points `p`, weight `w` on the middle one, as
274/// a rational B-spline on `[0, 1]`: the arc split at its middle, each half
275/// again, until every piece's weight in standard form lies within
276/// [`WEIGHT_RANGE`]. Splitting the standard arc at its middle gives two
277/// standard arcs of weight `sqrt((1 + w) / 2)` each, the same curve; the
278/// pieces meet at double knots.
279pub(crate) fn conic_arc(p: [Point; 3], w: f64, tol: Tolerances) -> OgeomResult<Curve> {
280    let (control, weight, knots) = conic_pieces(p, w);
281    let control = control
282        .into_iter()
283        .enumerate()
284        .map(|(i, q)| Weighted::new(q, if i % 2 == 1 { weight } else { 1.0 }, tol))
285        .collect::<OgeomResult<Vec<_>>>()?;
286    Ok(Curve::BSpline(BSplineCurve::rational(
287        KnotVector::new(knots, 2)?,
288        control,
289    )?))
290}
291
292/// The pieces [`conic_arc`] splits its arc into: their control points end
293/// to end (each piece's first, middle and, for the last, its end), the
294/// middle points' common weight, the end points' being 1, and the knots
295/// on `[0, 1]`. Each control point is an affine combination of `p` whose
296/// coefficients depend on `w` alone, so a family of arcs whose control
297/// points move affinely splits into control points that move alike.
298pub(crate) fn conic_pieces(p: [Point; 3], w: f64) -> (Vec<Point>, f64, Vec<f64>) {
299    let mut pieces = vec![p];
300    let mut weight = w;
301    for _ in 0..8 {
302        if weight >= WEIGHT_RANGE.0 && weight <= WEIGHT_RANGE.1 {
303            break;
304        }
305        pieces = pieces
306            .into_iter()
307            .flat_map(|[p0, a, p2]| {
308                let near =
309                    Point::from_vector((p0.to_vector() + a.to_vector() * weight) / (1.0 + weight));
310                let far =
311                    Point::from_vector((a.to_vector() * weight + p2.to_vector()) / (1.0 + weight));
312                let middle = near.midpoint(far);
313                [[p0, near, middle], [middle, far, p2]]
314            })
315            .collect();
316        weight = ((1.0 + weight) * 0.5).sqrt();
317    }
318    let n = pieces.len();
319    let mut knots = vec![0.0; 3];
320    let mut control = Vec::with_capacity(2 * n + 1);
321    for (i, [p0, a, _]) in pieces.iter().enumerate() {
322        control.push(*p0);
323        control.push(*a);
324        if i + 1 < n {
325            #[allow(clippy::cast_precision_loss, reason = "at most 256 pieces")]
326            let k = (i + 1) as f64 / n as f64;
327            knots.extend([k, k]);
328        }
329    }
330    control.push(pieces[n - 1][2]);
331    knots.extend([1.0; 3]);
332    (control, weight, knots)
333}
334
335/// Round a straight edge between two planar faces with a band that meets
336/// `face` `on_face` from the edge and the edge's other face `on_other`
337/// from it, its section `profile` between the two contacts.
338///
339/// The band is tangent to each face along its contact line. Naming the
340/// other face with the distances swapped builds the same solid, to the
341/// bit. With equal distances the elliptical profile is the circular round
342/// [`fillet_edge`](crate::fillet_edge) builds on the edge, by the radius
343/// whose ball touches each face that far from the edge: the same call.
344/// Otherwise the band is the conic arc swept along the edge, exact, and
345/// its ends are the circular round's: square caps where the edge ends at
346/// a wall, run out across a face that cuts the corner away past an end,
347/// trimmed by a face overhanging the corner.
348///
349/// What `solid` shares with other shapes is copied before it is worked on
350/// ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
351///
352/// # Errors
353///
354/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
355/// where `face` is not one of the edge's two faces, a distance is not a
356/// usable length, a conic's `rho` is not strictly between 0 and 1, the
357/// edge is not straight between two planes, or a contact sets back past
358/// its face's side; with equal distances and the elliptical profile, as
359/// [`fillet_edge`](crate::fillet_edge).
360#[allow(
361    clippy::too_many_arguments,
362    reason = "the chamfer's distances' own, and the profile"
363)]
364pub fn fillet_edge_distances(
365    model: &mut Model,
366    solid: &Shape,
367    edge: &Shape,
368    face: &Shape,
369    on_face: f64,
370    on_other: f64,
371    profile: RoundProfile,
372    tol: Tolerances,
373) -> OgeomResult<Built> {
374    for d in [on_face, on_other] {
375        if !d.is_finite() || d <= tol.confusion() {
376            ogeom_bail!(
377                Construction,
378                "a round set back {d} from its edge rounds nothing"
379            );
380        }
381    }
382    if let RoundProfile::Conic(_) = profile {
383        profile.weight(0.0)?;
384    }
385    if profile == RoundProfile::Elliptical
386        && on_face == on_other
387        && constant_dihedral(model, solid, edge, tol)?
388    {
389        // The circular round, by the same call.
390        let radius = equal_round(model, solid, edge, face, on_face, tol)?;
391        return crate::fillet_edge(model, solid, edge, radius, tol);
392    }
393    ogeom_algo::on_own_nodes(
394        model,
395        solid,
396        &[edge.clone(), face.clone()],
397        |model, solid, parts| {
398            fillet_edge_distances_own(
399                model, solid, &parts[0], &parts[1], on_face, on_other, profile, tol,
400            )
401        },
402    )
403}
404
405/// [`fillet_edge_distances`] on a shape every node below which is its own.
406#[allow(clippy::too_many_arguments, reason = "fillet_edge_distances' own")]
407fn fillet_edge_distances_own(
408    model: &mut Model,
409    solid: &Shape,
410    edge: &Shape,
411    face: &Shape,
412    on_face: f64,
413    on_other: f64,
414    profile: RoundProfile,
415    tol: Tolerances,
416) -> OgeomResult<Built> {
417    if let Some(rim) = rim_conic(model, solid, edge, face, on_face, on_other, profile, tol)? {
418        let mut built = if rim.whole {
419            rim_round(model, solid, edge, &rim, tol)?
420        } else {
421            rim_fits(model, solid, edge, &rim, tol)?;
422            let size = rim.size();
423            crate::fillet::revolved_arc_fillet(
424                model,
425                solid,
426                edge,
427                &rim.circle,
428                size,
429                Some(&rim),
430                None,
431                tol,
432            )?
433        };
434        crate::support::credit_new_faces(model, solid, edge, &mut built)?;
435        return Ok(built);
436    }
437    if !straight_between_planes(model, solid, edge, tol)? {
438        // Any other crease: the marched band through the conic sections,
439        // each leg walked along its face's section.
440        let weight = match profile {
441            RoundProfile::Elliptical => crate::marched::ConicWeight::Elliptical,
442            RoundProfile::Conic(_) => crate::marched::ConicWeight::Fixed(profile.weight(0.0)?),
443        };
444        let spec = crate::Chamfer::Distances {
445            face: face.clone(),
446            on_face,
447            on_other,
448        };
449        return crate::chamfer_marched::marched_bevel_with(
450            model,
451            solid,
452            edge,
453            &spec,
454            &[],
455            crate::marched::Chord::Conic(weight),
456            tol,
457        );
458    }
459    let Some(section) =
460        straight_section(model, solid, edge, face, on_face, on_other, profile, tol)?
461    else {
462        ogeom_bail!(
463            Construction,
464            "the circular round asked as an asymmetric one"
465        );
466    };
467    contacts_fit(model, solid, edge, section, tol)?;
468    let mut built = crate::fillet::planar_fillet(model, solid, edge, section, None, tol)?;
469    crate::support::credit_new_faces(model, solid, edge, &mut built)?;
470    Ok(built)
471}
472
473/// The conic section of a straight edge between planes asked as `face`,
474/// `on_face`, `on_other` and `profile`, its distances in the seat's face
475/// order; `None` where it is the circular round's (equal distances and the
476/// elliptical profile).
477#[allow(clippy::too_many_arguments, reason = "the request's own")]
478pub(crate) fn straight_section(
479    model: &Model,
480    solid: &Shape,
481    edge: &Shape,
482    face: &Shape,
483    on_face: f64,
484    on_other: f64,
485    profile: RoundProfile,
486    tol: Tolerances,
487) -> OgeomResult<Option<Section>> {
488    let (seat, named) = straight_seat(model, solid, edge, face, tol)?;
489    if profile == RoundProfile::Elliptical && on_face == on_other {
490        return Ok(None);
491    }
492    let mut d = [0.0; 2];
493    d[named] = on_face;
494    d[1 - named] = on_other;
495    let f = legs(&seat, tol)?;
496    let w = profile.weight(f[0].dot(f[1]).clamp(-1.0, 1.0))?;
497    Ok(Some(Section::Conic { d, w }))
498}
499
500/// The straight seat of an asymmetric round, and which of its faces is
501/// `face`; refused by name where the edge is not straight between two
502/// planes or `face` is not beside it.
503pub(crate) fn straight_seat(
504    model: &Model,
505    solid: &Shape,
506    edge: &Shape,
507    face: &Shape,
508    tol: Tolerances,
509) -> OgeomResult<(Seat, usize)> {
510    let (curve, _) = crate::support::edge_curve(model, edge, tol)?;
511    if !matches!(curve, Curve::Line(_)) || !crate::support::hosts_planar(model, solid, edge, tol)? {
512        ogeom_bail!(
513            Construction,
514            "an asymmetric round is built on a straight edge between two planes or a \
515             rim where a planar cap meets a coaxial cylinder or cone; this edge is \
516             neither; see docs/CAPABILITIES.md, fillet.asymmetric-rounds"
517        );
518    }
519    let seat = crate::support::planar_seat(model, solid, edge, tol)?;
520    let named = if seat.faces[0].is_same(face) {
521        0
522    } else if seat.faces[1].is_same(face) {
523        1
524    } else {
525        ogeom_bail!(
526            Construction,
527            "the face an asymmetric round's first distance runs along is not one of the \
528             edge's two faces"
529        );
530    };
531    Ok((seat, named))
532}
533
534/// The radius of the circular round an elliptical round of `distance` on
535/// each face is, refused as [`fillet_edge_distances`] refuses the request.
536pub(crate) fn equal_round(
537    model: &Model,
538    solid: &Shape,
539    edge: &Shape,
540    face: &Shape,
541    distance: f64,
542    tol: Tolerances,
543) -> OgeomResult<f64> {
544    if !distance.is_finite() || distance <= tol.confusion() {
545        ogeom_bail!(
546            Construction,
547            "a round set back {distance} from its edge rounds nothing"
548        );
549    }
550    let profile = RoundProfile::Elliptical;
551    if let Some(rim) = rim_conic(model, solid, edge, face, distance, distance, profile, tol)? {
552        return Ok(rim.equal_radius(distance));
553    }
554    straight_section(model, solid, edge, face, distance, distance, profile, tol)?;
555    equal_radius(model, solid, edge, distance, tol)
556}
557
558/// The radius of the circular round whose ball touches each face of the
559/// straight edge `distance` from it.
560fn equal_radius(
561    model: &Model,
562    solid: &Shape,
563    edge: &Shape,
564    distance: f64,
565    tol: Tolerances,
566) -> OgeomResult<f64> {
567    let seat = crate::support::planar_seat(model, solid, edge, tol)?;
568    let f = legs(&seat, tol)?;
569    let cos = f[0].dot(f[1]).clamp(-1.0, 1.0);
570    let sin = f[0].cross(f[1]).magnitude();
571    if sin <= tol.angular() || 1.0 + cos <= tol.angular() {
572        ogeom_bail!(
573            Construction,
574            "the faces meet too sharply or too flat to round"
575        );
576    }
577    Ok(distance * sin / (1.0 + cos))
578}
579
580/// Refuses by name where a contact of the conic band, read at a quarter,
581/// the middle and three quarters of the edge, stands off its face and off
582/// every other face of the solid: the band would set back past the face's
583/// side.
584fn contacts_fit(
585    model: &Model,
586    solid: &Shape,
587    edge: &Shape,
588    section: Section,
589    tol: Tolerances,
590) -> OgeomResult<()> {
591    let Section::Conic { d, .. } = section else {
592        return Ok(());
593    };
594    let seat = crate::support::planar_seat(model, solid, edge, tol)?;
595    let f = legs(&seat, tol)?;
596    for fraction in [0.25, 0.5, 0.75] {
597        let p = seat.start + (seat.end - seat.start) * fraction;
598        for i in 0..2 {
599            let contact = p + f[i] * d[i];
600            if !crate::support::on_face_or_boundary(model, solid, &seat.faces[i], contact, tol)? {
601                ogeom_bail!(
602                    Construction,
603                    "an asymmetric round sets back {} along a face of the edge through \
604                     {p:?}, past that face's side; see docs/CAPABILITIES.md, \
605                     fillet.asymmetric-rounds",
606                    d[i]
607                );
608            }
609        }
610    }
611    Ok(())
612}
613
614/// One asymmetric round of a [`blend_edges`](crate::blend_edges) request.
615pub(crate) struct Asymmetric {
616    /// The edge.
617    pub edge: Shape,
618    /// The face the first distance runs along.
619    pub face: Shape,
620    /// The distance along `face`.
621    pub on_face: f64,
622    /// The distance along the other face.
623    pub on_other: f64,
624    /// The section.
625    pub profile: RoundProfile,
626}
627
628/// The request's rounds and bevels of one radius each, built by
629/// `symmetric`, then each asymmetric round in the order of its edge's node
630/// on what they leave, its edge and face traced through them.
631///
632/// An asymmetric round meeting another edge of the request at a vertex is
633/// refused by name: the corner where it meets another band is not closed.
634pub(crate) fn after_symmetric(
635    model: &mut Model,
636    others: &[Shape],
637    mut asymmetric: Vec<Asymmetric>,
638    symmetric: impl FnOnce(&mut Model) -> OgeomResult<Built>,
639    tol: Tolerances,
640) -> OgeomResult<Built> {
641    asymmetric.sort_by_key(|a| a.edge.node());
642    let ends = |model: &Model, edge: &Shape| -> OgeomResult<Vec<Shape>> {
643        Ok(match ogeom_algo::edge_vertices(model, edge)? {
644            Some((a, b)) if a.is_same(&b) => vec![a],
645            Some((a, b)) => vec![a, b],
646            None => Vec::new(),
647        })
648    };
649    let n = asymmetric.len();
650    // Each round's chain, as the least index in it.
651    let mut chain: Vec<usize> = (0..n).collect();
652    let root = |chain: &[usize], mut i: usize| {
653        while chain[i] != i {
654            i = chain[i];
655        }
656        i
657    };
658    let mut meeting: std::collections::BTreeMap<ogeom_topo::TShapeId, usize> =
659        std::collections::BTreeMap::new();
660    for (i, a) in asymmetric.iter().enumerate() {
661        let own = ends(model, &a.edge)?;
662        for v in &own {
663            *meeting.entry(v.node()).or_default() += 1;
664        }
665        for other in others {
666            if ends(model, other)?
667                .iter()
668                .any(|v| own.iter().any(|w| w.is_same(v)))
669            {
670                ogeom_bail!(
671                    Construction,
672                    "an asymmetric round meets another blended edge at a vertex, and the \
673                     corner where they meet is not closed yet; see docs/CAPABILITIES.md, \
674                     fillet.asymmetric-rounds"
675                );
676            }
677        }
678        for (j, b) in asymmetric.iter().enumerate().skip(i + 1) {
679            let theirs = ends(model, &b.edge)?;
680            let Some(v) = own.iter().find(|v| theirs.iter().any(|w| w.is_same(v))) else {
681                continue;
682            };
683            if !continues(model, a, b, v, tol)? {
684                ogeom_bail!(
685                    Construction,
686                    "two asymmetric rounds meet at a vertex other than as one tangent chain \
687                     naming one face with the same distances and section, and the corner \
688                     where they meet is not closed yet; see docs/CAPABILITIES.md, \
689                     fillet.asymmetric-rounds"
690                );
691            }
692            let (ri, rj) = (root(&chain, i), root(&chain, j));
693            chain[ri.max(rj)] = ri.min(rj);
694        }
695    }
696    if meeting.values().any(|&k| k > 2) {
697        ogeom_bail!(
698            Construction,
699            "three asymmetric rounds meet at a vertex, and the corner where they meet is \
700             not closed yet; see docs/CAPABILITIES.md, fillet.asymmetric-rounds"
701        );
702    }
703    let mut built = symmetric(model)?;
704    let mut groups: std::collections::BTreeMap<usize, Vec<usize>> =
705        std::collections::BTreeMap::new();
706    for i in 0..n {
707        groups.entry(root(&chain, i)).or_default().push(i);
708    }
709    for members in groups.into_values() {
710        let mut traced = Vec::with_capacity(members.len());
711        for &i in &members {
712            traced.push(trace_request(model, &built, &asymmetric[i], tol)?);
713        }
714        if let [(edge, face)] = traced.as_slice() {
715            let a = &asymmetric[members[0]];
716            let next = fillet_edge_distances(
717                model,
718                &built.shape,
719                edge,
720                face,
721                a.on_face,
722                a.on_other,
723                a.profile,
724                tol,
725            )?;
726            built = Built::new(next.shape.clone(), built.history.then(&next.history));
727            continue;
728        }
729        // A tangent chain: each band set aside capped in its end sections,
730        // which the bands either side of a junction share, and the bands
731        // then applied together.
732        let solid = built.shape.clone();
733        let (made, wedges) = crate::support::collecting_wedges(|| -> OgeomResult<()> {
734            for (&i, (edge, face)) in members.iter().zip(&traced) {
735                let a = &asymmetric[i];
736                fillet_edge_distances_own(
737                    model, &solid, edge, face, a.on_face, a.on_other, a.profile, tol,
738                )?;
739            }
740            Ok(())
741        });
742        made?;
743        let set_aside = wedges
744            .into_iter()
745            .filter_map(|w| w.edges.first().cloned().map(|e| (e, w)))
746            .collect();
747        built = crate::fillet::apply_set_aside(model, built, set_aside, tol)?;
748    }
749    Ok(built)
750}
751
752/// Whether two asymmetric rounds meeting at `vertex` continue each other:
753/// the same face named, the same distances and section, and their edges
754/// tangent there.
755fn continues(
756    model: &Model,
757    a: &Asymmetric,
758    b: &Asymmetric,
759    vertex: &Shape,
760    tol: Tolerances,
761) -> OgeomResult<bool> {
762    if !a.face.is_same(&b.face)
763        || a.on_face != b.on_face
764        || a.on_other != b.on_other
765        || a.profile != b.profile
766    {
767        return Ok(false);
768    }
769    let at = {
770        let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
771            return Ok(false);
772        };
773        vertex.transform(model.datums())?.apply(data.point)
774    };
775    let tangent = |edge: &Shape| -> OgeomResult<Vector> {
776        use ogeom_geom::Curve3d as _;
777        let (curve, (t0, t1)) = crate::support::edge_curve(model, edge, tol)?;
778        let t = if curve.point_at(t0, tol)?.distance(at) <= curve.point_at(t1, tol)?.distance(at) {
779            t0
780        } else {
781            t1
782        };
783        let d = curve.d1_at(t, tol)?;
784        Ok(d / d.magnitude())
785    };
786    Ok(tangent(&a.edge)?.cross(tangent(&b.edge)?).magnitude() <= tol.angular() * 10.0)
787}
788
789/// An asymmetric round's edge and named face as they stand on what the
790/// request's rounds and bevels of one radius built.
791fn trace_request(
792    model: &Model,
793    built: &Built,
794    a: &Asymmetric,
795    tol: Tolerances,
796) -> OgeomResult<(Shape, Shape)> {
797    let images = built.history.trace(&a.edge);
798    // A rim's band takes its whole turn from any arc of it, so a rim split
799    // into arcs by the others stands for itself.
800    let one_rim = !images.is_empty() && {
801        let circle = |e: &Shape| match crate::support::edge_curve(model, e, tol) {
802            Ok((Curve::Circle(c), _)) => Some(c.circle()),
803            _ => None,
804        };
805        match circle(&a.edge) {
806            Some(rim) => images.iter().all(|e| {
807                circle(e).is_some_and(|c| {
808                    c.centre().distance(rim.centre()) <= tol.confusion()
809                        && (c.radius() - rim.radius()).abs() <= tol.confusion()
810                })
811            }),
812            None => false,
813        }
814    };
815    let edge = match images {
816        [edge] => edge.clone(),
817        [edge, ..] if one_rim => edge.clone(),
818        _ => ogeom_bail!(
819            Construction,
820            "an asymmetric round's edge does not come through the request's other blends \
821             as one edge"
822        ),
823    };
824    // The named face's image beside the edge, on what the request's other
825    // blends left.
826    let beside = faces_beside(model, &built.shape, &edge, tol)?;
827    let images = built.history.trace(&a.face);
828    let Some(face) = beside
829        .iter()
830        .find(|f| images.iter().any(|g| g.is_same(f)))
831        .cloned()
832    else {
833        ogeom_bail!(
834            Construction,
835            "the face an asymmetric round's first distance runs along is not one of the \
836             edge's two faces"
837        );
838    };
839    Ok((edge, face))
840}
841
842/// An asymmetric round's section on a rim where a planar cap meets a
843/// coaxial cylinder or cone, in the meridian half-plane through the seat's
844/// reference direction.
845pub(crate) struct RimConic {
846    /// The rim.
847    seat: crate::support::RevolvedSeat,
848    /// The edge's circle.
849    circle: ogeom_geom::CircleCurve,
850    /// Whether the rims on the circle run its whole turn.
851    whole: bool,
852    /// The wall's contact's depth along the axis from the cap's plane.
853    wall_depth: f64,
854    /// The cap's contact ring's radius.
855    cap_rho: f64,
856    /// The section's control points: the wall's contact, the rim, the
857    /// cap's contact.
858    points: [Point; 3],
859    /// The weight of the rim's control point.
860    w: f64,
861}
862
863impl RimConic {
864    /// How far the section reaches from the rim: the farther distance.
865    pub(crate) fn size(&self) -> f64 {
866        let [wall, rim, cap] = self.points;
867        wall.distance(rim).max(cap.distance(rim))
868    }
869
870    /// The contacts in the meridian, as a ball's would stand: the cap's
871    /// ring's radius and the wall's ring's radius and height. The ball's
872    /// own centre height and arc sweep stand for nothing here.
873    pub(crate) fn contacts(
874        &self,
875        seat: &crate::support::RevolvedSeat,
876    ) -> crate::support::RevolvedBall {
877        let wall_height = -seat.tau * self.wall_depth;
878        crate::support::RevolvedBall {
879            rho: self.cap_rho,
880            height: 0.0,
881            wall_rho: seat.radius_at(wall_height),
882            wall_height,
883            sweep: 0.0,
884        }
885    }
886
887    /// The section in the meridian half-plane at `theta` from the seat's
888    /// reference direction, from the wall's contact to the cap's.
889    pub(crate) fn meridian(
890        &self,
891        seat: &crate::support::RevolvedSeat,
892        theta: f64,
893        tol: Tolerances,
894    ) -> OgeomResult<Curve> {
895        let turn = ogeom_math::Transform::rotation(
896            ogeom_math::Axis {
897                location: seat.centre,
898                direction: Direction::new(seat.up, tol)?,
899            },
900            theta,
901        );
902        conic_arc(self.points.map(|p| turn.apply(p)), self.w, tol)
903    }
904
905    /// The band over the window of `sweep` from `theta0`: the section at
906    /// `theta0` revolved through `sweep`, bounded by the wall's and the
907    /// cap's contact arcs (framed from `theta0`, on `(0, sweep)`) and the
908    /// meridian sections at both ends, each given its line in the band's
909    /// chart, facing away from the rim.
910    pub(crate) fn band_sector(
911        &self,
912        model: &mut Model,
913        seat: &crate::support::RevolvedSeat,
914        (theta0, sweep): (f64, f64),
915        [wall, cap, start, end]: [&Shape; 4],
916        tol: Tolerances,
917    ) -> OgeomResult<Shape> {
918        use ogeom_geom::Surface as _;
919        let axis = ogeom_math::Axis {
920            location: seat.centre,
921            direction: Direction::new(seat.up, tol)?,
922        };
923        let profile = self.meridian(seat, theta0, tol)?;
924        let surface: ogeom_geom::SurfaceGeometry =
925            ogeom_geom::RevolutionSurface::over(profile, axis, (0.0, sweep))?.into();
926        let surface_id = model.geometry_mut().add_surface(surface.clone());
927        let line = |from: (f64, f64),
928                    along: (f64, f64),
929                    to: f64|
930         -> OgeomResult<ogeom_geom::PlanarCurve> {
931            Ok(ogeom_geom::Line2d::over(
932                ogeom_math::Axis2::new(
933                    ogeom_math::Point2::new(from.0, from.1),
934                    ogeom_math::Direction2::new(ogeom_math::Vector2::new(along.0, along.1), tol)?,
935                ),
936                0.0,
937                to,
938            )?
939            .into())
940        };
941        for (edge, pcurve, range) in [
942            (wall, line((0.0, 0.0), (1.0, 0.0), sweep)?, (0.0, sweep)),
943            (cap, line((0.0, 1.0), (1.0, 0.0), sweep)?, (0.0, sweep)),
944            (start, line((0.0, 0.0), (0.0, 1.0), 1.0)?, (0.0, 1.0)),
945            (end, line((sweep, 0.0), (0.0, 1.0), 1.0)?, (0.0, 1.0)),
946        ] {
947            ogeom_algo::attach_pcurve(
948                model,
949                edge,
950                pcurve,
951                surface_id,
952                ogeom_topo::Location::identity(),
953                range,
954            )?;
955        }
956        let wire = ogeom_algo::make_wire(
957            model,
958            &[wall.clone(), end.clone(), cap.reversed(), start.reversed()],
959            tol,
960        )?
961        .shape;
962        let face =
963            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
964        let middle = surface.point_at(sweep * 0.5, 0.5, tol)?;
965        let (du, dv) = surface.d1_at(sweep * 0.5, 0.5, tol)?;
966        let rim = ogeom_math::Transform::rotation(axis, theta0 + sweep * 0.5).apply(self.points[1]);
967        Ok(if du.cross(dv).dot(middle - rim) >= 0.0 {
968            face
969        } else {
970            face.reversed()
971        })
972    }
973    /// The radius of the circular round whose ball touches cap and wall
974    /// `distance` from the rim: the section's legs meet at the angle
975    /// `alpha` there, and the ball's tangents from the rim are
976    /// `r cot(alpha / 2)` long.
977    fn equal_radius(&self, distance: f64) -> f64 {
978        let [wall, rim, cap] = self.points;
979        let (a, b) = (wall - rim, cap - rim);
980        let (a, b) = (a / a.magnitude(), b / b.magnitude());
981        let cos = a.dot(b).clamp(-1.0, 1.0);
982        let sin = a.cross(b).magnitude();
983        distance * sin / (1.0 + cos)
984    }
985}
986
987/// The asymmetric round's section on a rim, where `edge` is one: `None`
988/// where it is not a circle; refused by name where it is a circle but not
989/// a whole turn of a rim on a cap and its coaxial wall, `face` is neither,
990/// or a contact would reach the axis.
991#[allow(clippy::too_many_arguments, reason = "the request's own")]
992fn rim_conic(
993    model: &Model,
994    solid: &Shape,
995    edge: &Shape,
996    face: &Shape,
997    on_face: f64,
998    on_other: f64,
999    profile: RoundProfile,
1000    tol: Tolerances,
1001) -> OgeomResult<Option<RimConic>> {
1002    let (curve, range) = crate::support::edge_curve(model, edge, tol)?;
1003    let Curve::Circle(circle) = curve else {
1004        return Ok(None);
1005    };
1006    let seat = match crate::support::revolved_seat(model, solid, edge, &circle, tol) {
1007        Ok(seat) => seat,
1008        // A circle that is no rim of a cap and its coaxial wall is a
1009        // marched crease.
1010        Err(ogeom_core::OgeomError::Construction(_)) => return Ok(None),
1011        Err(e) => return Err(e),
1012    };
1013    let closed = ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
1014    let whole = closed || crate::support::runs_whole_turn(model, &seat, &circle, range, tol)?;
1015    let (on_cap, on_wall) = if seat.cap_face.is_same(face) {
1016        (on_face, on_other)
1017    } else if seat.wall_face.is_same(face) {
1018        (on_other, on_face)
1019    } else {
1020        ogeom_bail!(
1021            Construction,
1022            "the face an asymmetric round's first distance runs along is not one of the \
1023             edge's two faces"
1024        );
1025    };
1026    // Down the wall `on_wall` along its generator, `wall_depth` along the
1027    // axis; in along the cap `on_cap`, toward the material.
1028    let wall_depth = on_wall / seat.slope.hypot(1.0);
1029    let wall_height = -seat.tau * wall_depth;
1030    let wall_rho = seat.radius_at(wall_height);
1031    let cap_rho = seat.radius - seat.sigma * seat.tau * on_cap;
1032    if wall_rho <= tol.confusion() || cap_rho <= tol.confusion() {
1033        ogeom_bail!(
1034            Construction,
1035            "an asymmetric round of {on_cap} on the cap and {on_wall} on the wall reaches \
1036             the axis of a rim of radius {}",
1037            seat.radius
1038        );
1039    }
1040    let x = seat.x_ref.vector();
1041    let points = [
1042        seat.centre + seat.up * wall_height + x * wall_rho,
1043        seat.centre + x * seat.radius,
1044        seat.centre + x * cap_rho,
1045    ];
1046    let (a, b) = (points[0] - points[1], points[2] - points[1]);
1047    let (a, b) = (a / a.magnitude(), b / b.magnitude());
1048    let w = profile.weight(a.dot(b).clamp(-1.0, 1.0))?;
1049    Ok(Some(RimConic {
1050        seat,
1051        circle,
1052        whole,
1053        wall_depth,
1054        cap_rho,
1055        points,
1056        w,
1057    }))
1058}
1059
1060/// The rim's band: the section revolved a whole turn about the axis,
1061/// exact, between the wall's band and the cap's annulus down to its
1062/// contacts, cut from the solid at a convex rim and fused at a concave one.
1063fn rim_round(
1064    model: &mut Model,
1065    solid: &Shape,
1066    edge: &Shape,
1067    rim: &RimConic,
1068    tol: Tolerances,
1069) -> OgeomResult<Built> {
1070    use ogeom_geom::{Curve3d as _, Surface as _};
1071    let seat = &rim.seat;
1072    rim_fits(model, solid, edge, rim, tol)?;
1073    let flanks = crate::support::revolved_flanks(model, seat, rim.wall_depth, rim.cap_rho, tol)?;
1074    let profile = conic_arc(rim.points, rim.w, tol)?;
1075    let middle = profile.point_at(0.5, tol)?;
1076    let axis = ogeom_math::Axis {
1077        location: seat.centre,
1078        direction: Direction::new(seat.up, tol)?,
1079    };
1080    let surface: ogeom_geom::SurfaceGeometry =
1081        ogeom_geom::RevolutionSurface::new(profile, axis, core::f64::consts::TAU)?.into();
1082    let band = ogeom_algo::make_profile_revolution_band(
1083        model,
1084        &surface,
1085        &flanks.wall_ring,
1086        &flanks.cap_ring,
1087        tol,
1088    )?;
1089    // The wedge's material lies between the section and the rim: the band
1090    // faces away from the rim.
1091    let (du, dv) = surface.d1_at(0.0, 0.5, tol)?;
1092    let band = if du.cross(dv).dot(middle - rim.points[1]) < 0.0 {
1093        band.reversed()
1094    } else {
1095        band
1096    };
1097    let faces = [flanks.wall_band, flanks.annulus, band];
1098    crate::support::apply_wedge(model, solid, Some(edge), &faces, seat.additive(), tol)
1099}
1100
1101/// The two faces beside an edge an asymmetric round takes: a rim's cap
1102/// and wall, or a straight edge's two planes.
1103fn faces_beside(
1104    model: &Model,
1105    solid: &Shape,
1106    edge: &Shape,
1107    tol: Tolerances,
1108) -> OgeomResult<[Shape; 2]> {
1109    let (curve, _) = crate::support::edge_curve(model, edge, tol)?;
1110    if let Curve::Circle(circle) = curve {
1111        let seat = crate::support::revolved_seat(model, solid, edge, &circle, tol)?;
1112        return Ok([seat.cap_face, seat.wall_face]);
1113    }
1114    if straight_between_planes(model, solid, edge, tol)? {
1115        return Ok(crate::support::planar_seat(model, solid, edge, tol)?.faces);
1116    }
1117    let mut found: Vec<Shape> = Vec::new();
1118    for f in ogeom_topo::explore_unique(model, solid, ogeom_topo::ShapeType::Face)? {
1119        let holds = ogeom_topo::explore(
1120            model,
1121            &f,
1122            ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Edge),
1123        )?
1124        .iter()
1125        .any(|e| crate::support::same_occurrence(model, e, edge, tol));
1126        if holds {
1127            found.push(f);
1128        }
1129    }
1130    let [a, b] = <[Shape; 2]>::try_from(found).map_err(|_| {
1131        ogeom_core::ogeom_err!(Construction, "an asymmetric round's edge has not two faces")
1132    })?;
1133    Ok([a, b])
1134}
1135
1136/// Whether `edge` is straight between two planes.
1137fn straight_between_planes(
1138    model: &Model,
1139    solid: &Shape,
1140    edge: &Shape,
1141    tol: Tolerances,
1142) -> OgeomResult<bool> {
1143    let (curve, _) = crate::support::edge_curve(model, edge, tol)?;
1144    Ok(matches!(curve, Curve::Line(_)) && crate::support::hosts_planar(model, solid, edge, tol)?)
1145}
1146
1147/// Whether the faces beside `edge` meet at one angle all along it, so an
1148/// elliptical round of equal distances is the circular round: a straight
1149/// edge between planes, or a rim on a cap and its coaxial wall.
1150fn constant_dihedral(
1151    model: &Model,
1152    solid: &Shape,
1153    edge: &Shape,
1154    tol: Tolerances,
1155) -> OgeomResult<bool> {
1156    if straight_between_planes(model, solid, edge, tol)? {
1157        return Ok(true);
1158    }
1159    let (curve, _) = crate::support::edge_curve(model, edge, tol)?;
1160    let Curve::Circle(circle) = curve else {
1161        return Ok(false);
1162    };
1163    Ok(crate::support::revolved_seat(model, solid, edge, &circle, tol).is_ok())
1164}
1165
1166/// Refuses by name where the rim's contacts stand off their faces, read
1167/// across from a quarter, the middle and three quarters of the edge.
1168fn rim_fits(
1169    model: &Model,
1170    solid: &Shape,
1171    edge: &Shape,
1172    rim: &RimConic,
1173    tol: Tolerances,
1174) -> OgeomResult<()> {
1175    use ogeom_geom::Curve3d as _;
1176    let seat = &rim.seat;
1177    let (curve, (t0, t1)) = crate::support::edge_curve(model, edge, tol)?;
1178    for fraction in [0.25, 0.5, 0.75] {
1179        let on_rim = curve.point_at(t0 + (t1 - t0) * fraction, tol)?;
1180        let radial = {
1181            let off = on_rim - seat.centre;
1182            let r = off - seat.up * off.dot(seat.up);
1183            r / r.magnitude()
1184        };
1185        let across = |p: Point| {
1186            let off = p - seat.centre;
1187            let height = off.dot(seat.up);
1188            let r = (off - seat.up * height).magnitude();
1189            seat.centre + seat.up * height + radial * r
1190        };
1191        for (face, p) in [
1192            (&seat.wall_face, rim.points[0]),
1193            (&seat.cap_face, rim.points[2]),
1194        ] {
1195            if !crate::support::on_face_or_boundary(model, solid, face, across(p), tol)? {
1196                ogeom_bail!(
1197                    Construction,
1198                    "an asymmetric round on a rim sets back past the side of a face beside \
1199                     it; see docs/CAPABILITIES.md, fillet.asymmetric-rounds"
1200                );
1201            }
1202        }
1203    }
1204    Ok(())
1205}
1206
1207/// The corners where an asymmetric round meets the request's other edges:
1208/// each vertex, its frame, its bands' setbacks and how each band ends, and
1209/// every edge of the request at any of them.
1210pub(crate) struct AsymmetricCorners {
1211    /// Each corner: its vertex, frame, setbacks and band ends.
1212    pub corners: Vec<(
1213        Shape,
1214        crate::corner::CornerFrame,
1215        Vec<f64>,
1216        Vec<crate::setback::CornerEnd>,
1217    )>,
1218    /// The request's edges at the corners.
1219    pub edges: Vec<Shape>,
1220}
1221
1222/// The corners of `asymmetric`: each vertex where an asymmetric round meets
1223/// another edge of the request other than as one tangent chain. Refused by
1224/// name unless it is a convex corner of three planes whose three edges are
1225/// all in the request, each straight, and no edge at a corner meets an
1226/// edge of the request elsewhere other than at another such corner.
1227pub(crate) fn asymmetric_corners(
1228    model: &Model,
1229    solid: &Shape,
1230    rounds: &[(Shape, f64)],
1231    bevels: &[(Shape, crate::Chamfer)],
1232    asymmetric: &[Asymmetric],
1233    tol: Tolerances,
1234) -> OgeomResult<AsymmetricCorners> {
1235    use crate::setback::CornerEnd;
1236    let ends = |edge: &Shape| -> OgeomResult<Vec<Shape>> {
1237        Ok(match ogeom_algo::edge_vertices(model, edge)? {
1238            Some((a, b)) if a.is_same(&b) => vec![a],
1239            Some((a, b)) => vec![a, b],
1240            None => Vec::new(),
1241        })
1242    };
1243    // Every request edge, by kind: 0 a round, 1 a bevel, 2 an asymmetric
1244    // round, with its index.
1245    let mut all: Vec<(Shape, usize, usize)> = Vec::new();
1246    all.extend(
1247        rounds
1248            .iter()
1249            .enumerate()
1250            .map(|(i, (e, _))| (e.clone(), 0, i)),
1251    );
1252    all.extend(
1253        bevels
1254            .iter()
1255            .enumerate()
1256            .map(|(i, (e, _))| (e.clone(), 1, i)),
1257    );
1258    all.extend(
1259        asymmetric
1260            .iter()
1261            .enumerate()
1262            .map(|(i, a)| (a.edge.clone(), 2, i)),
1263    );
1264    let mut at: std::collections::BTreeMap<ogeom_topo::TShapeId, (Shape, Vec<usize>)> =
1265        std::collections::BTreeMap::new();
1266    for (k, (edge, _, _)) in all.iter().enumerate() {
1267        for v in ends(edge)? {
1268            at.entry(v.node())
1269                .or_insert_with(|| (v.clone(), Vec::new()))
1270                .1
1271                .push(k);
1272        }
1273    }
1274    let solid_edges = ogeom_topo::explore_unique(model, solid, ogeom_topo::ShapeType::Edge)?;
1275    let mut corners = Vec::new();
1276    let mut edges: Vec<Shape> = Vec::new();
1277    for (vertex, here) in at.into_values() {
1278        let asym_here: Vec<usize> = here.iter().copied().filter(|&k| all[k].1 == 2).collect();
1279        if asym_here.is_empty() || here.len() < 2 {
1280            continue;
1281        }
1282        // Two asymmetric rounds continuing each other: a chain's junction.
1283        if here.len() == 2 && asym_here.len() == 2 {
1284            let (a, b) = (&asymmetric[all[here[0]].2], &asymmetric[all[here[1]].2]);
1285            if continues(model, a, b, &vertex, tol)? {
1286                continue;
1287            }
1288        }
1289        let mut meeting = 0;
1290        for e in &solid_edges {
1291            if ends(e)?.iter().any(|v| v.is_same(&vertex)) {
1292                meeting += 1;
1293            }
1294        }
1295        if meeting != 3 || here.len() != 3 {
1296            ogeom_bail!(
1297                Construction,
1298                "an asymmetric round meets another blended edge at a vertex, and such a \
1299                 corner is closed only where three edges meet and all three are blended; \
1300                 see docs/CAPABILITIES.md, fillet.asymmetric-rounds"
1301            );
1302        }
1303        let largest = here
1304            .iter()
1305            .map(|&k| match all[k].1 {
1306                0 => rounds[all[k].2].1,
1307                1 => 0.0,
1308                _ => asymmetric[all[k].2]
1309                    .on_face
1310                    .max(asymmetric[all[k].2].on_other),
1311            })
1312            .fold(0.0, f64::max)
1313            .max(tol.confusion() * 10.0);
1314        let Ok(frame) = crate::corner::corner_frame(model, solid, &vertex, largest, tol) else {
1315            ogeom_bail!(
1316                Construction,
1317                "an asymmetric round meets other blended edges at a vertex that is not a \
1318                 convex corner of planes, and the corner is not closed there; see \
1319                 docs/CAPABILITIES.md, fillet.asymmetric-rounds"
1320            );
1321        };
1322        let n = frame.d.len();
1323        let point = |v: &Shape| -> OgeomResult<Point> {
1324            let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) else {
1325                ogeom_bail!(Dangling, "a vertex is not in this model");
1326            };
1327            Ok(v.transform(model.datums())?.apply(data.point))
1328        };
1329        let mut widths = Vec::with_capacity(n);
1330        let mut kinds = Vec::with_capacity(n);
1331        for k in 0..n {
1332            let (ma, mb) = (frame.m[frame.ring[k]], frame.m[frame.ring[(k + 1) % n]]);
1333            let dk = frame.d[k].vector();
1334            let mut found = None;
1335            for &j in &here {
1336                let edge = &all[j].0;
1337                let Some((a, z)) = ogeom_algo::edge_vertices(model, edge)? else {
1338                    continue;
1339                };
1340                let far = if a.is_same(&vertex) { z } else { a };
1341                let p = point(&far)? - frame.corner;
1342                let length = p.magnitude();
1343                if length <= tol.confusion() || p.dot(dk) / length < 1.0 - tol.angular().sqrt() {
1344                    continue;
1345                }
1346                // The width on each of the frame's two faces.
1347                let on = |normals: [Vector; 2], d: [f64; 2]| -> OgeomResult<(f64, f64)> {
1348                    let pick = |m: Vector| {
1349                        (0..2).find(|&i| normals[i].cross(m).magnitude() <= tol.angular() * 10.0)
1350                    };
1351                    let (Some(ia), Some(ib)) = (pick(ma), pick(mb)) else {
1352                        ogeom_bail!(Construction, "a band at a corner does not lie on its faces");
1353                    };
1354                    Ok((d[ia], d[ib]))
1355                };
1356                found = Some(match all[j].1 {
1357                    0 => {
1358                        let cos = -ma.dot(mb);
1359                        let sin = (1.0 - cos * cos).max(0.0).sqrt();
1360                        let w = rounds[all[j].2].1 * (1.0 + cos) / sin;
1361                        ((w, w), CornerEnd::Round)
1362                    }
1363                    1 => {
1364                        let crate::chamfer::Legs::Straight(seat, d) =
1365                            crate::chamfer::legs(model, solid, edge, &bevels[all[j].2].1, tol)?
1366                        else {
1367                            ogeom_bail!(
1368                                Construction,
1369                                "a corner with an asymmetric round takes bevels on straight \
1370                                 edges between planes"
1371                            );
1372                        };
1373                        (on(seat.normals, d)?, CornerEnd::Bevel)
1374                    }
1375                    _ => {
1376                        let a = &asymmetric[all[j].2];
1377                        let Some(Section::Conic { d, .. }) = straight_section(
1378                            model, solid, edge, &a.face, a.on_face, a.on_other, a.profile, tol,
1379                        )?
1380                        else {
1381                            ogeom_bail!(Construction, "an asymmetric round asked as a round");
1382                        };
1383                        let seat = crate::support::planar_seat(model, solid, edge, tol)?;
1384                        (on(seat.normals, d)?, CornerEnd::Round)
1385                    }
1386                });
1387            }
1388            let Some((w, kind)) = found else {
1389                ogeom_bail!(
1390                    Construction,
1391                    "an edge at a corner of asymmetric rounds is not one of the request's, or \
1392                     is curved"
1393                );
1394            };
1395            widths.push(w);
1396            kinds.push(kind);
1397        }
1398        let setback = crate::setback::least_setbacks_widths(&frame, &widths, tol)?;
1399        for &k in &here {
1400            if !edges.iter().any(|e| e.is_same(&all[k].0)) {
1401                edges.push(all[k].0.clone());
1402            }
1403        }
1404        corners.push((vertex, frame, setback, kinds));
1405    }
1406    // A corner's edge meets the rest of the request only at corners.
1407    for e in &edges {
1408        for v in ends(e)? {
1409            let corner_here = corners.iter().any(|(c, ..)| c.is_same(&v));
1410            let others_here = all.iter().any(|(o, _, _)| {
1411                !o.is_same(e) && ends(o).is_ok_and(|w| w.iter().any(|x| x.is_same(&v)))
1412            });
1413            if others_here && !corner_here {
1414                ogeom_bail!(
1415                    Construction,
1416                    "an edge at a corner of asymmetric rounds meets another blended edge at \
1417                     its far end, and that meeting is not closed yet; see \
1418                     docs/CAPABILITIES.md, fillet.asymmetric-rounds"
1419                );
1420            }
1421        }
1422    }
1423    Ok(AsymmetricCorners { corners, edges })
1424}
1425
1426/// The request's edges at the corners: its rounds, bevels and asymmetric
1427/// rounds.
1428pub(crate) type CornerEdges<'a> = (
1429    &'a [(Shape, f64)],
1430    &'a [(Shape, crate::Chamfer)],
1431    &'a [Asymmetric],
1432);
1433
1434/// The corners' edges built one at a time on `built` (bevels, then rounds,
1435/// then asymmetric rounds, each in the order of its edge's node, traced
1436/// through what came before), and each corner then closed as a round meets
1437/// a bevel: every band cut back to its setback and the opening filled by
1438/// one patch, tangent to each round's band.
1439pub(crate) fn close_asymmetric_corners(
1440    model: &mut Model,
1441    solid: &Shape,
1442    mut built: Built,
1443    corners: &AsymmetricCorners,
1444    (rounds, bevels, asymmetric): CornerEdges<'_>,
1445    tol: Tolerances,
1446) -> OgeomResult<Built> {
1447    if corners.corners.is_empty() {
1448        return Ok(built);
1449    }
1450    let traced_edge = |built: &Built, edge: &Shape| -> OgeomResult<Shape> {
1451        let [one] = built.history.trace(edge) else {
1452            ogeom_bail!(
1453                Construction,
1454                "an edge at a corner of asymmetric rounds does not come through the request's \
1455                 other blends as one edge"
1456            );
1457        };
1458        Ok(one.clone())
1459    };
1460    let traced_face =
1461        |model: &Model, built: &Built, edge: &Shape, face: &Shape| -> OgeomResult<Shape> {
1462            let beside = faces_beside(model, &built.shape, edge, tol)?;
1463            let images = built.history.trace(face);
1464            beside
1465                .iter()
1466                .find(|f| images.iter().any(|g| g.is_same(f)))
1467                .cloned()
1468                .ok_or_else(|| {
1469                    ogeom_core::ogeom_err!(
1470                        Construction,
1471                        "the face a blend at a corner names is not beside its edge"
1472                    )
1473                })
1474        };
1475    let mut bevels: Vec<&(Shape, crate::Chamfer)> = bevels.iter().collect();
1476    bevels.sort_by_key(|(e, _)| e.node());
1477    for (edge, spec) in bevels {
1478        let now = traced_edge(&built, edge)?;
1479        let spec = match spec {
1480            crate::Chamfer::Symmetric(d) => crate::Chamfer::Symmetric(*d),
1481            crate::Chamfer::Distances {
1482                face,
1483                on_face,
1484                on_other,
1485            } => crate::Chamfer::Distances {
1486                face: traced_face(model, &built, &now, face)?,
1487                on_face: *on_face,
1488                on_other: *on_other,
1489            },
1490            crate::Chamfer::Angle {
1491                face,
1492                distance,
1493                angle,
1494            } => crate::Chamfer::Angle {
1495                face: traced_face(model, &built, &now, face)?,
1496                distance: *distance,
1497                angle: *angle,
1498            },
1499        };
1500        let next = crate::chamfer_edges_with(model, &built.shape, &[(now, spec)], tol)?;
1501        built = Built::new(next.shape.clone(), built.history.then(&next.history));
1502    }
1503    let mut rounds: Vec<&(Shape, f64)> = rounds.iter().collect();
1504    rounds.sort_by_key(|(e, _)| e.node());
1505    for (edge, r) in rounds {
1506        let now = traced_edge(&built, edge)?;
1507        let next = crate::fillet_edge(model, &built.shape, &now, *r, tol)?;
1508        built = Built::new(next.shape.clone(), built.history.then(&next.history));
1509    }
1510    let mut asymmetric: Vec<&Asymmetric> = asymmetric.iter().collect();
1511    asymmetric.sort_by_key(|a| a.edge.node());
1512    for a in asymmetric {
1513        let now = traced_edge(&built, &a.edge)?;
1514        let face = traced_face(model, &built, &now, &a.face)?;
1515        let next = fillet_edge_distances(
1516            model,
1517            &built.shape,
1518            &now,
1519            &face,
1520            a.on_face,
1521            a.on_other,
1522            a.profile,
1523            tol,
1524        )?;
1525        built = Built::new(next.shape.clone(), built.history.then(&next.history));
1526    }
1527    for (vertex, frame, setback, kinds) in &corners.corners {
1528        built = crate::setback::close_corner_ends(
1529            model,
1530            solid,
1531            built,
1532            vertex,
1533            frame,
1534            setback,
1535            kinds,
1536            ogeom_geom::Continuity::G1,
1537            tol,
1538        )?;
1539    }
1540    Ok(built)
1541}
1542
1543/// The largest common scale up to `up_to` of an asymmetric round's two
1544/// distances at which [`fillet_edge_distances`] on `edge` is not refused
1545/// for a contact set back past its face's side: the round meeting `face`
1546/// `s * on_face` from the edge and the other face `s * on_other` from it.
1547/// None where no scale leaving both distances longer than the confusion
1548/// distance fits.
1549///
1550/// Read from the seat, without building a band, as
1551/// [`largest_chamfer_distance`](crate::largest_chamfer_distance) reads a
1552/// bevel's legs: each contact walks square to the edge, its own distance
1553/// per unit of scale into its face, and the answer is where the first walk
1554/// leaves its face, a confusion distance short. On a rim the cap's contact
1555/// stops short of the axis. The answer passes the round's own check.
1556///
1557/// # Errors
1558///
1559/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
1560/// `up_to` or a distance is not a usable length, and as
1561/// [`fillet_edge_distances`] where the seat is refused at any scale;
1562/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where the
1563/// scale read from the seat does not pass the round's own check.
1564#[allow(
1565    clippy::too_many_arguments,
1566    reason = "fillet_edge_distances' own, and the bound"
1567)]
1568pub fn largest_fillet_distances(
1569    model: &Model,
1570    solid: &Shape,
1571    edge: &Shape,
1572    face: &Shape,
1573    on_face: f64,
1574    on_other: f64,
1575    up_to: f64,
1576    tol: Tolerances,
1577) -> OgeomResult<Option<f64>> {
1578    let conf = tol.confusion();
1579    if !up_to.is_finite() || up_to <= conf {
1580        ogeom_bail!(
1581            Construction,
1582            "a largest scale up to {up_to} asks for nothing"
1583        );
1584    }
1585    for d in [on_face, on_other] {
1586        if !d.is_finite() || d <= conf {
1587            ogeom_bail!(
1588                Construction,
1589                "a round set back {d} from its edge rounds nothing"
1590            );
1591        }
1592    }
1593    let profile = RoundProfile::Elliptical;
1594    let rim = rim_conic(model, solid, edge, face, on_face, on_other, profile, tol)?;
1595    if rim.is_none() {
1596        straight_seat(model, solid, edge, face, tol)?;
1597    }
1598    let mut largest = crate::fits::legs_largest(
1599        model,
1600        solid,
1601        edge,
1602        up_to,
1603        |f| if f.is_same(face) { on_face } else { on_other },
1604        tol,
1605    )?;
1606    if let Some(rim) = &rim {
1607        // The cap's contact walking in toward the axis reaches it at the
1608        // rim's radius.
1609        let seat = &rim.seat;
1610        if seat.sigma * seat.tau > 0.0 {
1611            let on_cap = if seat.cap_face.is_same(face) {
1612                on_face
1613            } else {
1614                on_other
1615            };
1616            largest = largest.min((seat.radius - conf) / on_cap);
1617        }
1618    }
1619    if largest * on_face.min(on_other) <= conf {
1620        return Ok(None);
1621    }
1622    let (a, b) = (largest * on_face, largest * on_other);
1623    let checked = crate::support::keeping_face_meshes(|| -> OgeomResult<()> {
1624        match rim_conic(model, solid, edge, face, a, b, profile, tol)? {
1625            Some(rim) => rim_fits(model, solid, edge, &rim, tol),
1626            None => {
1627                let (seat, named) = straight_seat(model, solid, edge, face, tol)?;
1628                let mut d = [0.0; 2];
1629                d[named] = a;
1630                d[1 - named] = b;
1631                let f = legs(&seat, tol)?;
1632                let w = profile.weight(f[0].dot(f[1]).clamp(-1.0, 1.0))?;
1633                contacts_fit(model, solid, edge, Section::Conic { d, w }, tol)
1634            }
1635        }
1636    });
1637    if let Err(refusal) = checked {
1638        ogeom_bail!(
1639            NotDone,
1640            "the largest scale read from the seat, {largest}, does not pass the round's \
1641             check: {refusal}"
1642        );
1643    }
1644    Ok(Some(largest))
1645}