Skip to main content

ogeom_fillet/
chamfer.rs

1//! Chamfers: the bevel that replaces an edge.
2//!
3//! A chamfer along a straight edge between two planar faces is a wedge
4//! subtracted, and the wedge's own faces lie *exactly* on the solid's
5//! (coplanar, materials aligned), which is the boolean's same-domain case.
6//!
7//! Three spellings, one construction. The symmetric chamfer cuts the same
8//! distance along both faces; the distance-distance form cuts a named
9//! distance along a named face and another along its neighbour; the
10//! distance-angle form cuts a distance along the named face and leaves it at
11//! an angle, with the second distance derived where that bevel meets the
12//! other face. All three end in the same wedge subtraction.
13//!
14//! Two seats per spelling. A straight edge between planes takes the
15//! triangular prism above; the circular rim where a coaxial cylinder or cone
16//! meets a perpendicular planar cap takes a revolved wedge whose bevel is a
17//! *cone*, its leg on a cone's wall running along the slant:
18//! the same flanks the rim fillet builds, with the quarter-tube exchanged
19//! for the slant, and the same melt taking the legs away.
20
21use crate::chamfer_sphere::{SphereSeat, sphere_bevel, sphere_seat};
22use crate::support::{
23    Blamed, RevolvedSeat, Seat, apply_wedge, edge_curve, planar_face, planar_seat, revolved_flanks,
24    revolved_seat,
25};
26use ogeom_algo::{Built, make_revolution_band};
27use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
28use ogeom_geom::{ConeSurface, Curve, SurfaceGeometry};
29use ogeom_math::{Cone, Direction, Frame};
30use ogeom_topo::{Model, Shape};
31
32/// Bevel a straight edge of a solid, cutting `distance` back along each of
33/// its two faces.
34///
35/// The edge must be straight, convex, and shared by exactly two planar faces;
36/// the distances are equal (the symmetric chamfer). The result is the boolean
37/// difference with a wedge whose legs run along the two faces, so the
38/// history reads as a cut: the two faces are modified into their trimmed
39/// pieces, the edge's neighbourhood gains the bevel face.
40///
41/// A seam (an edge between two faces of one shell on one surface facing
42/// one way, as a boolean leaves where it joined two flats) is no edge of
43/// the part's form, and naming one names nothing: the solid comes back as
44/// it is. See [`chamfer_edges_with`].
45///
46/// What `solid` shares with other shapes is copied before it is
47/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
48///
49/// # Errors
50///
51/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the edge is
52/// not straight, not convex, not shared by exactly two planar faces of
53/// `solid`, or `distance` is not a usable length.
54pub fn chamfer_edge(
55    model: &mut Model,
56    solid: &Shape,
57    edge: &Shape,
58    distance: f64,
59    tol: Tolerances,
60) -> OgeomResult<Built> {
61    ogeom_algo::on_own_nodes(
62        model,
63        solid,
64        std::slice::from_ref(edge),
65        |model, solid, parts| chamfer_edge_own(model, solid, &parts[0], distance, tol),
66    )
67}
68
69/// [`chamfer_edge`] on a shape every node below which is its own.
70fn chamfer_edge_own(
71    model: &mut Model,
72    solid: &Shape,
73    edge: &Shape,
74    distance: f64,
75    tol: Tolerances,
76) -> OgeomResult<Built> {
77    if crate::engine::is_seam(model, solid, edge, tol)? {
78        return Ok(unchanged(solid));
79    }
80    let specs = [(edge.clone(), Chamfer::Symmetric(distance))];
81    if let Some(built) = engine_chamfer(model, solid, &specs, tol)? {
82        return Ok(built);
83    }
84    wedge_for(model, solid, edge, &specs[0].1, tol)?.apply(model, solid, edge, tol)
85}
86
87/// Bevel a straight edge, cutting `on_face` back along `face` and `on_other`
88/// along the edge's other face.
89///
90/// The asymmetric chamfer: `face` names which side the first distance applies
91/// to, and must be one of the two faces meeting at the edge.
92///
93/// What `solid` shares with other shapes is copied before it is
94/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
95///
96/// # Errors
97///
98/// As [`chamfer_edge`], and additionally if `face` is not one of the edge's
99/// two faces.
100pub fn chamfer_edge_distances(
101    model: &mut Model,
102    solid: &Shape,
103    edge: &Shape,
104    face: &Shape,
105    on_face: f64,
106    on_other: f64,
107    tol: Tolerances,
108) -> OgeomResult<Built> {
109    ogeom_algo::on_own_nodes(
110        model,
111        solid,
112        &[edge.clone(), face.clone()],
113        |model, solid, parts| {
114            chamfer_edge_distances_own(model, solid, &parts[0], &parts[1], on_face, on_other, tol)
115        },
116    )
117}
118
119/// [`chamfer_edge_distances`] on a shape every node below which is its own.
120fn chamfer_edge_distances_own(
121    model: &mut Model,
122    solid: &Shape,
123    edge: &Shape,
124    face: &Shape,
125    on_face: f64,
126    on_other: f64,
127    tol: Tolerances,
128) -> OgeomResult<Built> {
129    let spec = Chamfer::Distances {
130        face: face.clone(),
131        on_face,
132        on_other,
133    };
134    if crate::engine::is_seam(model, solid, edge, tol)? {
135        return Ok(unchanged(solid));
136    }
137    let specs = [(edge.clone(), spec)];
138    if let Some(built) = engine_chamfer(model, solid, &specs, tol)? {
139        return Ok(built);
140    }
141    wedge_for(model, solid, edge, &specs[0].1, tol)?.apply(model, solid, edge, tol)
142}
143
144/// Bevel a straight edge, cutting `distance` back along `face` and leaving it
145/// at `angle` radians from that face.
146///
147/// The distance-angle chamfer: the second distance is where the bevel,
148/// departing the named face at the given angle, meets the other face. An
149/// angle of `π/4` on a square edge reproduces the symmetric chamfer.
150///
151/// What `solid` shares with other shapes is copied before it is
152/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
153///
154/// # Errors
155///
156/// As [`chamfer_edge_distances`], and additionally if the bevel at that angle
157/// never reaches the other face.
158pub fn chamfer_edge_angle(
159    model: &mut Model,
160    solid: &Shape,
161    edge: &Shape,
162    face: &Shape,
163    distance: f64,
164    angle: f64,
165    tol: Tolerances,
166) -> OgeomResult<Built> {
167    ogeom_algo::on_own_nodes(
168        model,
169        solid,
170        &[edge.clone(), face.clone()],
171        |model, solid, parts| {
172            chamfer_edge_angle_own(model, solid, &parts[0], &parts[1], distance, angle, tol)
173        },
174    )
175}
176
177/// [`chamfer_edge_angle`] on a shape every node below which is its own.
178fn chamfer_edge_angle_own(
179    model: &mut Model,
180    solid: &Shape,
181    edge: &Shape,
182    face: &Shape,
183    distance: f64,
184    angle: f64,
185    tol: Tolerances,
186) -> OgeomResult<Built> {
187    let spec = Chamfer::Angle {
188        face: face.clone(),
189        distance,
190        angle,
191    };
192    if crate::engine::is_seam(model, solid, edge, tol)? {
193        return Ok(unchanged(solid));
194    }
195    let specs = [(edge.clone(), spec)];
196    if let Some(built) = engine_chamfer(model, solid, &specs, tol)? {
197        return Ok(built);
198    }
199    wedge_for(model, solid, edge, &specs[0].1, tol)?.apply(model, solid, edge, tol)
200}
201
202/// The shapes chamfer specs name: each edge, then the face its chamfer
203/// runs along where it names one.
204fn chamfer_parts(specs: &[(Shape, Chamfer)]) -> Vec<Shape> {
205    let mut parts = Vec::with_capacity(specs.len() * 2);
206    for (edge, spec) in specs {
207        parts.push(edge.clone());
208        match spec {
209            Chamfer::Symmetric(_) => {}
210            Chamfer::Distances { face, .. } | Chamfer::Angle { face, .. } => {
211                parts.push(face.clone());
212            }
213        }
214    }
215    parts
216}
217
218/// The specs again, naming `parts` in the order [`chamfer_parts`] lists
219/// them.
220fn chamfer_specs(specs: &[(Shape, Chamfer)], parts: &[Shape]) -> Vec<(Shape, Chamfer)> {
221    let mut parts = parts.iter().cloned();
222    let mut next = |was: &Shape| parts.next().unwrap_or_else(|| was.clone());
223    specs
224        .iter()
225        .map(|(edge, spec)| {
226            let edge = next(edge);
227            let spec = match spec {
228                Chamfer::Symmetric(d) => Chamfer::Symmetric(*d),
229                Chamfer::Distances {
230                    face,
231                    on_face,
232                    on_other,
233                } => Chamfer::Distances {
234                    face: next(face),
235                    on_face: *on_face,
236                    on_other: *on_other,
237                },
238                Chamfer::Angle {
239                    face,
240                    distance,
241                    angle,
242                } => Chamfer::Angle {
243                    face: next(face),
244                    distance: *distance,
245                    angle: *angle,
246                },
247            };
248            (edge, spec)
249        })
250        .collect()
251}
252
253/// One edge's chamfer, in any of the three spellings.
254#[derive(Debug, Clone)]
255pub enum Chamfer {
256    /// The same distance back along both faces, as [`chamfer_edge`].
257    Symmetric(f64),
258    /// `on_face` along `face` and `on_other` along the other face, as
259    /// [`chamfer_edge_distances`].
260    Distances {
261        /// The face the first distance runs along.
262        face: Shape,
263        /// The distance along `face`.
264        on_face: f64,
265        /// The distance along the edge's other face.
266        on_other: f64,
267    },
268    /// `distance` along `face`, leaving it at `angle` radians, as
269    /// [`chamfer_edge_angle`].
270    Angle {
271        /// The face the distance runs along.
272        face: Shape,
273        /// The distance along `face`.
274        distance: f64,
275        /// The bevel's angle from `face`, in radians.
276        angle: f64,
277    },
278}
279
280/// Bevel several edges of a solid as one operation, each by `distance`.
281///
282/// As [`chamfer_edges_with`] with the symmetric chamfer on every edge.
283///
284/// # Errors
285///
286/// As [`chamfer_edges_with`].
287pub fn chamfer_edges(
288    model: &mut Model,
289    solid: &Shape,
290    edges: &[Shape],
291    distance: f64,
292    tol: Tolerances,
293) -> OgeomResult<Built> {
294    let specs: Vec<(Shape, Chamfer)> = edges
295        .iter()
296        .map(|e| (e.clone(), Chamfer::Symmetric(distance)))
297        .collect();
298    chamfer_edges_with(model, solid, &specs, tol)
299}
300
301/// Bevel several edges of a solid as one operation, each with its own
302/// chamfer.
303///
304/// The whole request is read on the solid as it stands before the call, so
305/// the result is the same for every order of `specs`. Where two bevels meet
306/// at a vertex they mitre along the line their planes share, with neither a
307/// step nor a cap, at a face's reflex corner as at a convex one. Where three
308/// or more meet, the vertex is closed by the facet through the points where
309/// their contact lines cross on the faces round it
310/// ([`ChamferCorner::Facet`](crate::ChamferCorner::Facet)); the mitre of
311/// their planes is [`chamfer_edges_cornered`](crate::chamfer_edges_cornered)
312/// with [`ChamferCorner::Point`](crate::ChamferCorner::Point).
313///
314/// A solid bounded by planes is chamfered by the blend engine in one pass:
315/// every face set back from its bevelled edges, every bevel the plane
316/// between its two setback lines, every vertex of three bevels its facet. A
317/// request past what its faces hold (a face its setbacks consume), and any
318/// other solid, are chamfered by wedges built on the solid as given and
319/// applied together, mitred where bevels meet.
320///
321/// A seam is no edge of the part's form: an edge between two faces of one
322/// shell lying on one surface facing one way
323/// ([`ogeom_heal::same_domain`]), as a boolean leaves where it joined two
324/// flats, the surface running on across it and the material on one side.
325/// Mainstream CAD merges such faces after the boolean, so the edge cannot
326/// be picked; here a spec naming one names nothing and is passed over, and
327/// a request naming nothing but seams returns the solid as it is. A solid
328/// holding seams is read by the blend engine with them dissolved, as
329/// [`ogeom_heal::unify_same_domain`] and [`ogeom_heal::merge_edges`] merge
330/// them: the faces on one plane come out one face, the pieces of an edge
331/// split along its length one bevel, and the history tells of the solid
332/// as given (each face modified into the face holding it, each seam and
333/// each vertex the seams left inside an edge deleted). Coplanar faces
334/// facing opposite ways, the material changing side across the edge
335/// between them, meet at a real edge.
336///
337/// What `solid` shares with other shapes is copied before it is
338/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
339///
340/// # Errors
341///
342/// As [`chamfer_edge`], [`chamfer_edge_distances`] and
343/// [`chamfer_edge_angle`] per edge, judged on the solid as it stands before
344/// the call; and [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
345/// if no edges are given.
346pub fn chamfer_edges_with(
347    model: &mut Model,
348    solid: &Shape,
349    specs: &[(Shape, Chamfer)],
350    tol: Tolerances,
351) -> OgeomResult<Built> {
352    chamfer_edges_blamed(model, solid, specs, tol).map_err(|blamed| blamed.error)
353}
354
355/// [`chamfer_edges_with`], its refusal naming the specs it concerns.
356///
357/// A solid bounded by planes goes through the blend engine
358/// ([`crate::engine`]): one pass by setbacks, each vertex of three bevels
359/// closed by a facet. Any other solid is chamfered by wedges, mitred.
360pub(crate) fn chamfer_edges_blamed(
361    model: &mut Model,
362    solid: &Shape,
363    specs: &[(Shape, Chamfer)],
364    tol: Tolerances,
365) -> Result<Built, Blamed> {
366    if specs.is_empty() {
367        return Err(
368            ogeom_core::ogeom_err!(Construction, "a chain of no edges bevels nothing").into(),
369        );
370    }
371    let (specs, index) = without_seams(model, solid, specs, tol)?;
372    if specs.is_empty() {
373        return Ok(unchanged(solid));
374    }
375    if let Some(built) = engine_chamfer(model, solid, &specs, tol)? {
376        return Ok(built);
377    }
378    chamfer_edges_mitred_blamed(model, solid, &specs, tol).map_err(|mut blamed| {
379        for i in &mut blamed.edges {
380            *i = index[*i];
381        }
382        blamed
383    })
384}
385
386/// Chamfer specs, each edge with its chamfer.
387pub(crate) type Specs = Vec<(Shape, Chamfer)>;
388
389/// `specs` without those naming a seam of `solid` (an edge between two
390/// faces on one surface facing one way, no edge of the part's form), with
391/// the index in `specs` of each kept.
392pub(crate) fn without_seams(
393    model: &Model,
394    solid: &Shape,
395    specs: &[(Shape, Chamfer)],
396    tol: Tolerances,
397) -> OgeomResult<(Specs, Vec<usize>)> {
398    let mut kept = Vec::with_capacity(specs.len());
399    let mut index = Vec::with_capacity(specs.len());
400    for (i, spec) in specs.iter().enumerate() {
401        if !crate::engine::is_seam(model, solid, &spec.0, tol)? {
402            kept.push(spec.clone());
403            index.push(i);
404        }
405    }
406    Ok((kept, index))
407}
408
409/// `solid` as given: what a request naming nothing but seams makes.
410pub(crate) fn unchanged(solid: &Shape) -> Built {
411    let mut built = Built::from_nothing(solid.clone());
412    built.history.modify(solid, solid.clone());
413    built
414}
415
416/// The chamfer of `specs` by the blend engine, or `None` where the solid
417/// or a spec lies outside its classes. A solid holding seams is chamfered
418/// with them dissolved, as [`ogeom_heal::unify_same_domain`] merges them;
419/// no spec may name a seam.
420pub(crate) fn engine_chamfer(
421    model: &mut Model,
422    solid: &Shape,
423    specs: &[(Shape, Chamfer)],
424    tol: Tolerances,
425) -> OgeomResult<Option<Built>> {
426    use crate::engine::Dissolved;
427    match crate::engine::dissolve(model, solid, tol)? {
428        Dissolved::Plain => {
429            let Some(request) = crate::engine::Request::from_chamfers(model, solid, specs, tol)?
430            else {
431                return Ok(None);
432            };
433            crate::engine::run(model, solid, &request, tol)
434        }
435        Dissolved::Outside => Ok(None),
436        Dissolved::Into(seamless) => {
437            let Some(specs) = seamless.specs(specs) else {
438                return Ok(None);
439            };
440            let Some(request) =
441                crate::engine::Request::from_chamfers(model, &seamless.solid, &specs, tol)?
442            else {
443                return Ok(None);
444            };
445            let Some(built) = crate::engine::run(model, &seamless.solid, &request, tol)? else {
446                return Ok(None);
447            };
448            Ok(Some(Built::new(
449                built.shape,
450                seamless.carry(&built.history),
451            )))
452        }
453    }
454}
455
456/// The chamfer of `specs` by wedges built on the solid as given and
457/// applied together, mitred where bevels meet: three bevels at a convex
458/// vertex meet at the point their planes share.
459pub(crate) fn chamfer_edges_mitred(
460    model: &mut Model,
461    solid: &Shape,
462    specs: &[(Shape, Chamfer)],
463    tol: Tolerances,
464) -> OgeomResult<Built> {
465    chamfer_edges_mitred_blamed(model, solid, specs, tol).map_err(|blamed| blamed.error)
466}
467
468/// [`chamfer_edges_mitred`], its refusal naming the specs it concerns.
469fn chamfer_edges_mitred_blamed(
470    model: &mut Model,
471    solid: &Shape,
472    specs: &[(Shape, Chamfer)],
473    tol: Tolerances,
474) -> Result<Built, Blamed> {
475    let mut blame = Vec::new();
476    ogeom_algo::on_own_nodes(
477        model,
478        solid,
479        &chamfer_parts(specs),
480        |model, solid, parts| {
481            chamfer_edges_with_own(model, solid, &chamfer_specs(specs, parts), tol).map_err(
482                |blamed| {
483                    blame = blamed.edges;
484                    blamed.error
485                },
486            )
487        },
488    )
489    .map_err(|error| Blamed {
490        edges: blame,
491        error,
492    })
493}
494
495/// [`chamfer_edges_with`] on a shape every node below which is its own.
496///
497/// A wedge refused names its spec; one that does not apply names its
498/// spec, then the specs before it whose edges share a vertex with its
499/// edge, whose bevels it was applied after.
500fn chamfer_edges_with_own(
501    model: &mut Model,
502    solid: &Shape,
503    specs: &[(Shape, Chamfer)],
504    tol: Tolerances,
505) -> Result<Built, Blamed> {
506    if specs.is_empty() {
507        return Err(
508            ogeom_core::ogeom_err!(Construction, "a chain of no edges bevels nothing").into(),
509        );
510    }
511    let mut wedges = Vec::with_capacity(specs.len());
512    for (i, (edge, spec)) in specs.iter().enumerate() {
513        wedges.push(
514            wedge_for(model, solid, edge, spec, tol).map_err(|error| Blamed {
515                edges: vec![i],
516                error,
517            })?,
518        );
519    }
520    // A bevel on a marched seat is built against the solid as it stands
521    // when its turn comes: its edge is found again there, between the faces
522    // it lay between as asked.
523    let marched = specs
524        .iter()
525        .any(|(edge, _)| matches!(seat_kind(model, edge, tol), Ok(SeatKind::Marched)));
526    let given = if marched {
527        let mut given = Vec::with_capacity(specs.len());
528        for (edge, _) in specs {
529            given.push(faces_holding(model, solid, edge)?);
530        }
531        given
532    } else {
533        Vec::new()
534    };
535    let mut built = Built::from_nothing(solid.clone());
536    let mut rings: Vec<(usize, WholeRing)> = Vec::new();
537    for (i, ((edge, _), wedge)) in specs.iter().zip(wedges).enumerate() {
538        // The arcs a boolean split one sphere rim into each chamfer the
539        // whole ring: the first cuts it, the others asking the same legs
540        // are taken with it, and others asking other legs contradict it.
541        if let Some(ring) = wedge.whole_ring {
542            if let Some((j, first)) = rings.iter().find(|(_, r)| r.same_circle(&ring, tol)) {
543                if first.same_legs(&ring, tol) {
544                    continue;
545                }
546                return Err(Blamed {
547                    edges: vec![i, *j],
548                    error: ogeom_core::ogeom_err!(
549                        Construction,
550                        "two arcs of one sphere rim ask for different chamfers \
551                         of its whole ring"
552                    ),
553                });
554            }
555            rings.push((i, ring));
556        }
557        let applied = match &wedge.marched {
558            Some(spec) => marched_step(model, &built, edge, spec, &given[i], &specs[..i], tol),
559            None => wedge.apply(model, &built.shape, edge, tol),
560        };
561        let step = match applied {
562            Ok(step) => step,
563            Err(error) => {
564                let mut edges = vec![i];
565                let ends = |e: &Shape| -> OgeomResult<Vec<ogeom_topo::TShapeId>> {
566                    Ok(ogeom_algo::edge_vertices(model, e)?
567                        .map(|(a, b)| vec![a.node(), b.node()])
568                        .unwrap_or_default())
569                };
570                let own = ends(edge)?;
571                for (j, (other, _)) in specs[..i].iter().enumerate() {
572                    if ends(other)?.iter().any(|v| own.contains(v)) {
573                        edges.push(j);
574                    }
575                }
576                return Err(Blamed { edges, error });
577            }
578        };
579        built = Built {
580            shape: step.shape.clone(),
581            history: built.history.then(&step.history),
582        };
583    }
584    Ok(built)
585}
586
587/// The seat of a rim's bevel: a revolved seat, its wall a cylinder or a
588/// cone, or a sphere centred on the rim's axis.
589enum RimSeat {
590    Revolved(RevolvedSeat),
591    Sphere(SphereSeat),
592}
593
594fn rim_seat(
595    model: &Model,
596    solid: &Shape,
597    edge: &Shape,
598    rim: &ogeom_geom::CircleCurve,
599    tol: Tolerances,
600) -> OgeomResult<RimSeat> {
601    match revolved_seat(model, solid, edge, rim, tol) {
602        Ok(seat) => Ok(RimSeat::Revolved(seat)),
603        Err(refused) => match sphere_seat(model, solid, edge, rim, tol)? {
604            Some(seat) => Ok(RimSeat::Sphere(seat)),
605            None => Err(refused),
606        },
607    }
608}
609
610/// How far along the axis a leg of `on_wall` down the wall's ruling
611/// reaches: the leg itself on a cylinder, its run along the slant on a
612/// cone.
613fn wall_depth(seat: &RevolvedSeat, on_wall: f64) -> f64 {
614    if seat.slope == 0.0 {
615        on_wall
616    } else {
617        on_wall / seat.slope.hypot(1.0)
618    }
619}
620
621/// The angle at the rim between the cap's leg and the wall's, in the
622/// rim's section: square on a cylinder, and on a cone the cone's lean
623/// from it, obtuse where the wall leans out under the cap.
624fn rim_corner(seat: &RevolvedSeat) -> f64 {
625    // In the section, the cap's leg runs `-sigma tau` radially and the
626    // wall's runs `(-slope tau, -tau)`, both from the rim.
627    (seat.sigma * seat.slope / seat.slope.hypot(1.0)).acos()
628}
629
630/// The wedge one chamfer cuts, built on `solid` as it stands.
631fn wedge_for(
632    model: &mut Model,
633    solid: &Shape,
634    edge: &Shape,
635    spec: &Chamfer,
636    tol: Tolerances,
637) -> OgeomResult<Wedge> {
638    match legs(model, solid, edge, spec, tol)? {
639        Legs::Straight(seat, distances) => bevel(model, &seat, distances, tol),
640        Legs::Rim(seat, on_wall, on_cap) => {
641            revolved_bevel(model, solid, edge, &seat, on_wall, on_cap, tol)
642        }
643        Legs::Marched => Ok(Wedge {
644            faces: Vec::new(),
645            solid: None,
646            additive: false,
647            whole_ring: None,
648            marched: Some(spec.clone()),
649        }),
650        Legs::Sphere(seat, on_wall, on_cap) => {
651            // Each contact stays on its face at every station the walk
652            // reads, or the wedge would cut past the face.
653            let per = |face: &Shape| sphere_leg(&seat, face, on_wall, on_cap);
654            if crate::fits::legs_largest(model, solid, edge, 1.0, per, tol)? < 1.0 {
655                ogeom_bail!(
656                    Construction,
657                    "a chamfer on the rim between a plane and a sphere runs past \
658                     one of its faces"
659                );
660            }
661            let closed =
662                ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
663            let Curve::Circle(rim) = edge_curve(model, edge, tol)?.0 else {
664                ogeom_bail!(Construction, "a sphere's rim is not a circle");
665            };
666            let whole = closed
667                || crate::chamfer_sphere::rim_closes(model, solid, &seat, &rim.circle(), tol)?;
668            // An arc that stops short of its circle is the section turned
669            // through the arc's own sweep.
670            let span = if whole {
671                None
672            } else {
673                let frame =
674                    Frame::new(seat.centre, Direction::new(seat.up, tol)?, seat.x_ref, tol)?;
675                Some(arc_window(model, edge, &frame, tol)?)
676            };
677            Ok(Wedge {
678                faces: sphere_bevel(model, &seat, on_wall, on_cap, span, tol)?,
679                solid: None,
680                additive: seat.tau < 0.0,
681                whole_ring: whole.then_some(WholeRing {
682                    centre: seat.centre,
683                    radius: seat.radius,
684                    up: seat.up,
685                    on_wall,
686                    on_cap,
687                }),
688                marched: None,
689            })
690        }
691    }
692}
693
694/// How far a sphere seat's bevel runs along `face`: its leg on the wall or
695/// the cap, nothing on any other face.
696fn sphere_leg(seat: &SphereSeat, face: &Shape, on_wall: f64, on_cap: f64) -> f64 {
697    if face.is_same(&seat.wall_face) {
698        on_wall
699    } else if face.is_same(&seat.cap_face) {
700        on_cap
701    } else {
702        0.0
703    }
704}
705
706/// The largest measure up to `up_to` at which a chamfer of `spec`'s
707/// spelling on `edge` of `solid` is not refused for running past its
708/// faces: for [`Chamfer::Symmetric`] the distance, for
709/// [`Chamfer::Distances`] the largest common scale of its two distances,
710/// and for [`Chamfer::Angle`] the distance along its face at its angle;
711/// the measure `spec` itself names is not read. None where no measure
712/// leaving both legs longer than the confusion distance fits.
713///
714/// Read from the seat, without building a wedge. On a straight edge
715/// between planes, a convex bevel's leg runs at most as far as its face
716/// reaches back from the edge, the bound [`chamfer_edges_with`] refuses
717/// past, and the answer is exact against it; a concave one fuses and runs
718/// past no face. On a rim, the cap's leg stops short of the axis, and
719/// each leg's contact stays on its face at the stations
720/// [`largest_fillet_radius`](crate::largest_fillet_radius) reads, a
721/// confusion distance short.
722///
723/// # Errors
724///
725/// As [`chamfer_edges_with`] where the seat or spelling is refused at any
726/// measure, and [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
727/// if `up_to` is not a usable length; as
728/// [`largest_fillet_radius`](crate::largest_fillet_radius) where a
729/// contact's walk on a rim depends on the measure.
730pub fn largest_chamfer_distance(
731    model: &Model,
732    solid: &Shape,
733    edge: &Shape,
734    spec: &Chamfer,
735    up_to: f64,
736    tol: Tolerances,
737) -> OgeomResult<Option<f64>> {
738    let conf = tol.confusion();
739    if !up_to.is_finite() || up_to <= conf {
740        ogeom_bail!(
741            Construction,
742            "a largest distance up to {up_to} asks for nothing"
743        );
744    }
745    // The legs at a measure of one, which scale with it.
746    let unit = match spec {
747        Chamfer::Symmetric(_) => Chamfer::Symmetric(1.0),
748        Chamfer::Distances {
749            face,
750            on_face,
751            on_other,
752        } => Chamfer::Distances {
753            face: face.clone(),
754            on_face: *on_face,
755            on_other: *on_other,
756        },
757        Chamfer::Angle { face, angle, .. } => Chamfer::Angle {
758            face: face.clone(),
759            distance: 1.0,
760            angle: *angle,
761        },
762    };
763    let mut largest = up_to;
764    let shortest = match legs(model, solid, edge, &unit, tol)? {
765        Legs::Straight(seat, per) => {
766            if seat.convex {
767                for (i, leg) in per.into_iter().enumerate() {
768                    if leg > 0.0 {
769                        let along = seat.leg(i, tol)?;
770                        let reach = crate::support::face_reach(
771                            model,
772                            &seat.faces[i],
773                            seat.start,
774                            along,
775                            tol,
776                        )?;
777                        largest = largest.min(reach / leg);
778                    }
779                }
780            }
781            per[0].min(per[1])
782        }
783        Legs::Rim(seat, on_wall, on_cap) => {
784            // The cap's leg ends at the radius `radius - sigma tau on_cap`,
785            // which stays above the confusion distance.
786            let inward = seat.sigma * seat.tau * on_cap;
787            if inward > 0.0 {
788                largest = largest.min((seat.radius - 2.0 * conf) / inward);
789            }
790            // A cone's wall leg ends at the radius the wall has a depth
791            // down, which narrows toward the apex with the measure.
792            let narrowing = seat.slope * seat.tau * wall_depth(&seat, on_wall);
793            if narrowing > 0.0 {
794                largest = largest.min((seat.radius - 2.0 * conf) / narrowing);
795            }
796            let per = |face: &Shape| {
797                if face.is_same(&seat.wall_face) {
798                    on_wall
799                } else if face.is_same(&seat.cap_face) {
800                    on_cap
801                } else {
802                    0.0
803                }
804            };
805            largest = largest.min(crate::fits::legs_largest(
806                model, solid, edge, largest, per, tol,
807            )?);
808            on_wall.min(on_cap)
809        }
810        Legs::Marched => ogeom_bail!(
811            NotDone,
812            "the largest distance of a chamfer on a marched seat is not read \
813             from the seat"
814        ),
815        Legs::Sphere(seat, on_wall, on_cap) => {
816            // The derived leg of an angle on a sphere's rim does not scale
817            // with the measure: the wall's section is an arc.
818            if matches!(spec, Chamfer::Angle { .. }) {
819                ogeom_bail!(
820                    NotDone,
821                    "the largest distance of a chamfer at an angle on a sphere's \
822                     rim is not read from the seat"
823                );
824            }
825            let inward = seat.sigma * seat.tau * on_cap;
826            if inward > 0.0 {
827                largest = largest.min((seat.radius - 2.0 * conf) / inward);
828            }
829            largest = largest.min((seat.to_pole() - 2.0 * conf) / on_wall);
830            let per = |face: &Shape| sphere_leg(&seat, face, on_wall, on_cap);
831            largest = largest.min(crate::fits::legs_largest(
832                model, solid, edge, largest, per, tol,
833            )?);
834            on_wall.min(on_cap)
835        }
836    };
837    Ok((largest * shortest > conf).then_some(largest))
838}
839
840/// The seat a chamfer stands on and how far its legs run along each face.
841pub(crate) enum Legs {
842    /// A straight edge between planes, its legs along the seat's faces in
843    /// order.
844    Straight(Seat, [f64; 2]),
845    /// A rim on a cylinder or a cone: its legs down the wall and in along the
846    /// cap.
847    Rim(RevolvedSeat, f64, f64),
848    /// A rim on a sphere: its legs down the sphere's great circle and along
849    /// the cap.
850    Sphere(SphereSeat, f64, f64),
851    /// A crease on a marched seat, its legs read station by station when
852    /// the bevel is built.
853    Marched,
854}
855
856/// Read one chamfer's seat and legs from `solid` as it stands.
857pub(crate) fn legs(
858    model: &Model,
859    solid: &Shape,
860    edge: &Shape,
861    spec: &Chamfer,
862    tol: Tolerances,
863) -> OgeomResult<Legs> {
864    match spec {
865        Chamfer::Symmetric(distance) => match seat_kind(model, edge, tol)? {
866            SeatKind::Straight => {
867                let seat = planar_seat(model, solid, edge, tol)?;
868                Ok(Legs::Straight(seat, [*distance, *distance]))
869            }
870            SeatKind::Marched => Ok(Legs::Marched),
871            SeatKind::Rim(rim) => match rim_seat(model, solid, edge, &rim, tol)? {
872                RimSeat::Revolved(seat) => Ok(Legs::Rim(seat, *distance, *distance)),
873                RimSeat::Sphere(seat) => Ok(Legs::Sphere(seat, *distance, *distance)),
874            },
875        },
876        Chamfer::Distances {
877            face,
878            on_face,
879            on_other,
880        } => match seat_kind(model, edge, tol)? {
881            SeatKind::Straight => {
882                let seat = planar_seat(model, solid, edge, tol)?;
883                let i = seat_side(&seat, face)?;
884                let mut distances = [0.0; 2];
885                distances[i] = *on_face;
886                distances[1 - i] = *on_other;
887                Ok(Legs::Straight(seat, distances))
888            }
889            SeatKind::Marched => Ok(Legs::Marched),
890            SeatKind::Rim(rim) => match rim_seat(model, solid, edge, &rim, tol)? {
891                RimSeat::Revolved(seat) => {
892                    let (on_wall, on_cap) =
893                        revolved_side(&seat.wall_face, &seat.cap_face, face, *on_face, *on_other)?;
894                    Ok(Legs::Rim(seat, on_wall, on_cap))
895                }
896                RimSeat::Sphere(seat) => {
897                    let (on_wall, on_cap) =
898                        revolved_side(&seat.wall_face, &seat.cap_face, face, *on_face, *on_other)?;
899                    Ok(Legs::Sphere(seat, on_wall, on_cap))
900                }
901            },
902        },
903        Chamfer::Angle {
904            face,
905            distance,
906            angle,
907        } => {
908            let (distance, angle) = (*distance, *angle);
909            if !angle.is_finite() || angle <= tol.angular() {
910                ogeom_bail!(
911                    Construction,
912                    "a chamfer at an angle of {angle} cuts nothing"
913                );
914            }
915            match seat_kind(model, edge, tol)? {
916                SeatKind::Straight => {
917                    let seat = planar_seat(model, solid, edge, tol)?;
918                    let i = seat_side(&seat, face)?;
919                    // In the cross-section: from the contact on the named
920                    // face, the bevel leaves at `angle` into the wedge's own
921                    // side: the material on a convex edge, the open dihedral
922                    // on a concave one. Where it crosses the other leg's ray
923                    // is the derived distance; no crossing, no chamfer.
924                    let sign = if seat.convex { 1.0 } else { -1.0 };
925                    let a = seat.leg(i, tol)? * sign;
926                    let b = seat.leg(1 - i, tol)? * sign;
927                    let inward = -seat.normals[i] * sign;
928                    let denominator = angle.sin().mul_add(b.dot(a), angle.cos() * b.dot(inward));
929                    if denominator <= tol.angular() {
930                        ogeom_bail!(
931                            Construction,
932                            "the bevel at that angle never meets the edge's other face"
933                        );
934                    }
935                    let derived = distance * angle.sin() / denominator;
936                    let mut distances = [0.0; 2];
937                    distances[i] = distance;
938                    distances[1 - i] = derived;
939                    Ok(Legs::Straight(seat, distances))
940                }
941                SeatKind::Marched => Ok(Legs::Marched),
942                SeatKind::Rim(rim) => {
943                    // In the rim's section the legs make the triangle with
944                    // the bevel: `angle` at the named face's contact and the
945                    // corner's own angle at the rim. A cylinder's seat is
946                    // square, the derived distance the plain tangent; past
947                    // the two angles' sum of a half turn the bevel walks
948                    // away from the other face instead of toward it.
949                    let seat = match rim_seat(model, solid, edge, &rim, tol)? {
950                        RimSeat::Revolved(seat) => seat,
951                        RimSeat::Sphere(seat) => {
952                            // On a sphere the section's wall is an arc: the
953                            // other contact is where the line meets it.
954                            let on_cap_face = if seat.cap_face.is_same(face) {
955                                true
956                            } else if seat.wall_face.is_same(face) {
957                                false
958                            } else {
959                                ogeom_bail!(
960                                    Construction,
961                                    "the named face does not meet the edge being chamfered"
962                                )
963                            };
964                            let (on_wall, on_cap) =
965                                seat.angle_legs(on_cap_face, distance, angle, tol)?;
966                            return Ok(Legs::Sphere(seat, on_wall, on_cap));
967                        }
968                    };
969                    let corner = rim_corner(&seat);
970                    let past = if seat.slope == 0.0 {
971                        angle >= core::f64::consts::FRAC_PI_2 - tol.angular()
972                    } else {
973                        angle + corner >= core::f64::consts::PI - tol.angular()
974                    };
975                    if past {
976                        ogeom_bail!(
977                            Construction,
978                            "the bevel at that angle never meets the edge's other face"
979                        );
980                    }
981                    let derived = if seat.slope == 0.0 {
982                        distance * angle.tan()
983                    } else {
984                        distance * angle.sin() / (angle + corner).sin()
985                    };
986                    let (on_wall, on_cap) =
987                        revolved_side(&seat.wall_face, &seat.cap_face, face, distance, derived)?;
988                    Ok(Legs::Rim(seat, on_wall, on_cap))
989                }
990            }
991        }
992    }
993}
994
995/// A chamfer's wedge, built and not yet applied: its faces, and whether it
996/// fuses (a concave edge) or cuts (a convex one).
997struct Wedge {
998    faces: Vec<Shape>,
999    /// The wedge already closed into a solid, where it was built as one.
1000    solid: Option<Shape>,
1001    additive: bool,
1002    /// The whole ring a sphere's rim chamfers from any of the arcs a
1003    /// boolean split it into.
1004    whole_ring: Option<WholeRing>,
1005    /// The spelling of a bevel on a marched seat, built against the solid
1006    /// as it stands when its turn in the request comes.
1007    marched: Option<Chamfer>,
1008}
1009
1010/// A sphere rim's whole ring and the legs its bevel runs.
1011#[derive(Debug, Clone, Copy)]
1012struct WholeRing {
1013    centre: ogeom_math::Point,
1014    radius: f64,
1015    up: ogeom_math::Vector,
1016    on_wall: f64,
1017    on_cap: f64,
1018}
1019
1020impl WholeRing {
1021    /// Whether `other` is a ring on the same circle and the same cap.
1022    fn same_circle(&self, other: &Self, tol: Tolerances) -> bool {
1023        self.centre.distance(other.centre) <= tol.confusion()
1024            && (self.radius - other.radius).abs() <= tol.confusion()
1025            && self.up.dot(other.up) >= 1.0 - tol.angular()
1026    }
1027
1028    /// Whether `other`'s legs are this ring's.
1029    fn same_legs(&self, other: &Self, tol: Tolerances) -> bool {
1030        (self.on_wall - other.on_wall).abs() <= tol.confusion()
1031            && (self.on_cap - other.on_cap).abs() <= tol.confusion()
1032    }
1033}
1034
1035impl Wedge {
1036    fn apply(
1037        self,
1038        model: &mut Model,
1039        solid: &Shape,
1040        edge: &Shape,
1041        tol: Tolerances,
1042    ) -> OgeomResult<Built> {
1043        let mut built = match self.solid {
1044            Some(wedge) => {
1045                let mut result = if self.additive {
1046                    ogeom_bool::fuse(model, solid, &wedge, tol)?
1047                } else {
1048                    ogeom_bool::cut(model, solid, &wedge, tol)?
1049                };
1050                result.history.delete(edge);
1051                result
1052            }
1053            None => apply_wedge(model, solid, Some(edge), &self.faces, self.additive, tol)?,
1054        };
1055        crate::support::credit_new_faces(model, solid, edge, &mut built)?;
1056        Ok(built)
1057    }
1058}
1059
1060/// Which seat a chamfer is standing on, read from the edge's curve.
1061enum SeatKind {
1062    /// A straight edge between planes: the triangular-prism wedge.
1063    Straight,
1064    /// A circular rim: the revolved wedge with a conical bevel.
1065    Rim(ogeom_geom::CircleCurve),
1066    /// Any other crease: the marched seat, its bevel ruled.
1067    Marched,
1068}
1069
1070fn seat_kind(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<SeatKind> {
1071    let (curve, _) = edge_curve(model, edge, tol)?;
1072    match curve {
1073        Curve::Line(_) => Ok(SeatKind::Straight),
1074        Curve::Circle(c) => Ok(SeatKind::Rim(c)),
1075        _ => Ok(SeatKind::Marched),
1076    }
1077}
1078
1079/// Assign a named face's distance to the wall or the cap.
1080///
1081/// On the wall the distance runs axially down from the rim; on the cap it
1082/// runs radially in from it.
1083fn revolved_side(
1084    wall_face: &Shape,
1085    cap_face: &Shape,
1086    face: &Shape,
1087    on_face: f64,
1088    on_other: f64,
1089) -> OgeomResult<(f64, f64)> {
1090    if wall_face.is_same(face) {
1091        Ok((on_face, on_other))
1092    } else if cap_face.is_same(face) {
1093        Ok((on_other, on_face))
1094    } else {
1095        ogeom_bail!(
1096            Construction,
1097            "the named face does not meet the edge being chamfered"
1098        )
1099    }
1100}
1101
1102/// The revolved wedge with the quarter-tube exchanged for a slant: legs
1103/// `on_wall` down the wall along its ruling and `on_cap` radially along the
1104/// cap, and the cone between the two tangency rings as the bevel.
1105fn revolved_bevel(
1106    model: &mut Model,
1107    solid: &Shape,
1108    edge: &Shape,
1109    seat: &RevolvedSeat,
1110    on_wall: f64,
1111    on_cap: f64,
1112    tol: Tolerances,
1113) -> OgeomResult<Wedge> {
1114    for distance in [on_wall, on_cap] {
1115        if !distance.is_finite() || distance <= tol.confusion() {
1116            ogeom_bail!(Construction, "a chamfer of {distance} cuts nothing");
1117        }
1118    }
1119    let cap_rho = seat.sigma.mul_add(-(seat.tau * on_cap), seat.radius);
1120    if cap_rho <= tol.confusion() {
1121        ogeom_bail!(
1122            Construction,
1123            "a chamfer of {on_cap} along the cap swallows the axis of a rim \
1124             of radius {}",
1125            seat.radius
1126        );
1127    }
1128    // A cone's wall leg runs its distance along the slant, a depth down
1129    // the axis, to the wall's radius there.
1130    let depth = wall_depth(seat, on_wall);
1131    let wall_rho = seat.radius_at(-seat.tau * depth);
1132    if wall_rho <= tol.confusion() {
1133        ogeom_bail!(
1134            Construction,
1135            "a chamfer of {on_wall} down the wall runs past the apex of its cone"
1136        );
1137    }
1138    if let Some((start, sweep)) = arc_span(model, edge, seat, tol)? {
1139        let legs = (depth, cap_rho, on_wall.max(on_cap));
1140        return sector_bevel(model, solid, edge, seat, legs, (start, sweep), tol);
1141    }
1142    let flanks = revolved_flanks(model, seat, depth, cap_rho, tol)?;
1143
1144    // The bevel: the cone through both tangency rings (reference radius
1145    // `cap_rho` at the cap's level, the wall's radius a depth below), a
1146    // cylinder where the two radii agree. Unlike the fillet's quarter-tube,
1147    // whose away-from-the-tube normal tracks the wedge seat by seat, the
1148    // cone's natural normal always points away from the axis, and the
1149    // wedge sits on the axis side of the slant exactly when `sigma` and
1150    // `tau` agree.
1151    let bevel_band = {
1152        let slope = (cap_rho - wall_rho) / (seat.tau * depth);
1153        // The domain covers the band's two rows (the cap ring at zero and
1154        // the wall ring a depth away) with a margin that stays clear of the
1155        // apex, where the surface degenerates.
1156        let rows = (
1157            0.0_f64.min(-seat.tau * depth),
1158            0.0_f64.max(-seat.tau * depth),
1159        );
1160        let frame = seat.frame_at(seat.centre, tol)?;
1161        let surface: SurfaceGeometry = if slope.atan().abs() <= tol.angular() {
1162            let pad = 0.1 * depth;
1163            ogeom_geom::CylinderSurface::new(
1164                ogeom_math::Cylinder::new(frame, cap_rho, tol)?,
1165                (rows.0 - pad, rows.1 + pad),
1166            )?
1167            .into()
1168        } else {
1169            // Beside a cone's wall the bevel's apex can come near its
1170            // rows; the margin then stops halfway to it.
1171            let pad = if seat.slope == 0.0 {
1172                0.1 * depth
1173            } else {
1174                let apex = -cap_rho / slope;
1175                let clear = (apex - rows.0).abs().min((apex - rows.1).abs());
1176                (0.1 * depth).min(0.5 * clear)
1177            };
1178            let cone = Cone::new(frame, cap_rho, slope.atan(), tol)?;
1179            ConeSurface::new(cone, (rows.0 - pad, rows.1 + pad))?.into()
1180        };
1181        let band = make_revolution_band(model, &surface, &flanks.wall_ring, &flanks.cap_ring, tol)?;
1182        if seat.sigma * seat.tau > 0.0 {
1183            band.reversed()
1184        } else {
1185            band
1186        }
1187    };
1188
1189    Ok(Wedge {
1190        faces: vec![flanks.wall_band, flanks.annulus, bevel_band],
1191        solid: None,
1192        additive: seat.additive(),
1193        whole_ring: None,
1194        marched: None,
1195    })
1196}
1197
1198/// Where a rim that is only an arc of its circle starts, and how far it
1199/// turns, as angles about the seat's axis from its `x_ref`, turning
1200/// positively about `up`. `None` for a whole circle, or an arc of one whose
1201/// wall runs on round the rest of the turn.
1202fn arc_span(
1203    model: &Model,
1204    edge: &Shape,
1205    seat: &RevolvedSeat,
1206    tol: Tolerances,
1207) -> OgeomResult<Option<(f64, f64)>> {
1208    let (curve, range) = edge_curve(model, edge, tol)?;
1209    let closed = ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
1210    let Curve::Circle(arc) = &curve else {
1211        return Ok(None);
1212    };
1213    if closed || crate::support::runs_whole_turn(model, seat, arc, range, tol)? {
1214        return Ok(None);
1215    }
1216    let frame = seat.frame_at(seat.centre, tol)?;
1217    Ok(Some(arc_window(model, edge, &frame, tol)?))
1218}
1219
1220/// Where an arc of a rim starts and how far it turns, as angles about
1221/// `frame`'s `z` from its `x`, whose origin is the rim's centre.
1222fn arc_window(
1223    model: &Model,
1224    edge: &Shape,
1225    frame: &Frame,
1226    tol: Tolerances,
1227) -> OgeomResult<(f64, f64)> {
1228    use ogeom_geom::Curve3d as _;
1229    let (curve, range) = edge_curve(model, edge, tol)?;
1230    let centre = frame.origin();
1231    let angle = |t: f64| -> OgeomResult<f64> {
1232        let d = curve.point_at(t, tol)? - centre;
1233        Ok(d.dot(frame.y().vector()).atan2(d.dot(frame.x().vector())))
1234    };
1235    let turn = core::f64::consts::TAU;
1236    let (a, b, m) = (
1237        angle(range.0)?,
1238        angle(range.1)?,
1239        angle(0.5 * (range.0 + range.1))?,
1240    );
1241    // The arc runs from `a` to `b` one way round or the other: the way
1242    // that passes through its middle.
1243    let forward = (b - a).rem_euclid(turn);
1244    let start = if (m - a).rem_euclid(turn) <= forward {
1245        a
1246    } else {
1247        b
1248    };
1249    let sweep = if start == a { forward } else { turn - forward };
1250    Ok((start, sweep))
1251}
1252
1253/// The wedge of a chamfer along an arc of a rim: the bevel's cross-section,
1254/// the triangle between the rim and the two tangency rings, turned about the
1255/// rim's axis through the arc alone. Its ends are the triangles a straight
1256/// bevel meeting the arc at a tangent ends in, so the bevels of a chain of
1257/// lines and arcs abut exactly; a whole ring would cut the material on the
1258/// far side of the axis as well.
1259///
1260/// Where the arc ends on a planar face oblique to the crease there, the
1261/// section in the meridian through that end would stop short of the face
1262/// and leave the corner between them: the bevel runs on past the end until
1263/// its section has left the solid across that face, within four of its
1264/// longer leg, as a straight band's run-out is read, and is cut back to the
1265/// face's plane.
1266fn sector_bevel(
1267    model: &mut Model,
1268    solid: &Shape,
1269    edge: &Shape,
1270    seat: &RevolvedSeat,
1271    (depth, cap_rho, leg): (f64, f64, f64),
1272    (start, sweep): (f64, f64),
1273    tol: Tolerances,
1274) -> OgeomResult<Wedge> {
1275    let frame = seat.frame_at(seat.centre, tol)?;
1276    let radial_at =
1277        |theta: f64| frame.x().vector() * theta.cos() + frame.y().vector() * theta.sin();
1278    let section_at = |theta: f64| -> [ogeom_math::Point; 3] {
1279        let radial = radial_at(theta);
1280        let rim = seat.centre + radial * seat.radius;
1281        let wall = if seat.slope == 0.0 {
1282            rim - seat.up * (seat.tau * depth)
1283        } else {
1284            let height = -seat.tau * depth;
1285            seat.centre + seat.up * height + radial * seat.radius_at(height)
1286        };
1287        [rim, wall, seat.centre + radial * cap_rho]
1288    };
1289    let mut window = [start, start + sweep];
1290    let mut trims = Vec::new();
1291    let hosts = [&seat.cap_face, &seat.wall_face];
1292    for (slot, sign) in [(0_usize, -1.0), (1, 1.0)] {
1293        let end = window[slot];
1294        let at = seat.centre + radial_at(end) * seat.radius;
1295        let ahead = seat.up.cross(radial_at(end)) * sign;
1296        if !crate::marched::crease_terminates_at(model, solid, edge, hosts, at, tol)? {
1297            continue;
1298        }
1299        let Some(face) = crate::support::oblique_end_face(model, solid, at, ahead, tol)? else {
1300            continue;
1301        };
1302        let step = (leg / seat.radius) / 8.0;
1303        let reach = 4.0 * leg / seat.radius;
1304        let section = |t: f64| Ok(section_at(end + sign * t));
1305        if let Some(past) =
1306            crate::support::run_out_past(model, solid, face, step, reach, &section, tol)?
1307        {
1308            window[slot] = end + sign * past;
1309            trims.push(face);
1310        }
1311    }
1312    // A bevel that would close the turn running on keeps its ends.
1313    if window[1] - window[0] >= core::f64::consts::TAU - tol.angular() {
1314        window = [start, start + sweep];
1315        trims.clear();
1316    }
1317    let [rim, wall, cap] = section_at(window[0]);
1318    let across = seat.up.cross(radial_at(window[0]));
1319    let profile = crate::support::planar_face(model, &[rim, wall, cap], across, tol)?;
1320    let axis = ogeom_math::Axis::new(seat.centre, frame.z());
1321    let turn = window[1] - window[0];
1322    let mut wedge = ogeom_algo::make_revolution(model, &profile, axis, turn, tol)?.shape;
1323    let span = (seat.radius + leg) * 4.0;
1324    for plane in trims {
1325        wedge = crate::support::trimmed_wedge(model, &wedge, plane, span, tol)?;
1326    }
1327    Ok(Wedge {
1328        faces: Vec::new(),
1329        solid: Some(wedge),
1330        additive: seat.additive(),
1331        whole_ring: None,
1332        marched: None,
1333    })
1334}
1335
1336/// Which side of the seat a named face is, by identity.
1337fn seat_side(seat: &Seat, face: &Shape) -> OgeomResult<usize> {
1338    if seat.faces[0].is_same(face) {
1339        Ok(0)
1340    } else if seat.faces[1].is_same(face) {
1341        Ok(1)
1342    } else {
1343        ogeom_bail!(
1344            Construction,
1345            "the named face does not meet the edge being chamfered"
1346        )
1347    }
1348}
1349
1350/// The one construction under all three spellings: the wedge with legs
1351/// `distances[i]` along face `i`, subtracted.
1352fn bevel(
1353    model: &mut Model,
1354    seat: &Seat,
1355    distances: [f64; 2],
1356    tol: Tolerances,
1357) -> OgeomResult<Wedge> {
1358    for distance in distances {
1359        if !distance.is_finite() || distance <= tol.confusion() {
1360            ogeom_bail!(Construction, "a chamfer of {distance} cuts nothing");
1361        }
1362    }
1363    // On a concave edge every leg mirrors: the wedge sits in the open
1364    // dihedral, its legs walk the faces' planes into it, and its strips face
1365    // the material they will melt against with *opposed* orientation, which
1366    // is exactly what a fuse cancels.
1367    let sign = if seat.convex { 1.0 } else { -1.0 };
1368    let a = seat.leg(0, tol)? * sign;
1369    let b = seat.leg(1, tol)? * sign;
1370    // A setback runs back across each face; past the face's far side it
1371    // would cut through the face and on into whatever lies beyond it.
1372    if seat.convex {
1373        for (i, leg) in [a, b].into_iter().enumerate() {
1374            let reach = crate::support::face_reach(model, &seat.faces[i], seat.start, leg, tol)?;
1375            if distances[i] > reach + tol.confusion() {
1376                ogeom_bail!(
1377                    Construction,
1378                    "a chamfer of {} on the edge from {:?} to {:?} runs past its face, \
1379                     which reaches {reach} back from the edge",
1380                    distances[i],
1381                    seat.start,
1382                    seat.end
1383                );
1384            }
1385        }
1386    }
1387
1388    let travel = seat.end - seat.start;
1389    let apex0 = seat.start;
1390    let apex1 = seat.end;
1391    let a0 = apex0 + a * distances[0];
1392    let b0 = apex0 + b * distances[1];
1393    let a1 = a0 + travel;
1394    let b1 = b0 + travel;
1395
1396    // The bevel's outward normal: perpendicular to the cut line and the edge,
1397    // pointing from the apex toward the cut. For equal distances this is the
1398    // leg bisector exactly.
1399    let bevel_out = {
1400        let across = b0 - a0;
1401        let mut n = seat.along.cross(across);
1402        let m = n.magnitude();
1403        if m <= tol.confusion() {
1404            ogeom_bail!(Construction, "the chamfer's cut line has no direction");
1405        }
1406        n /= m;
1407        if n.dot(a0 - apex0) < 0.0 {
1408            n = -n;
1409        }
1410        n
1411    };
1412
1413    // The wedge: a triangular prism whose apex line is the edge and whose
1414    // legs run the distances along each face. Built from five explicit planar
1415    // faces rather than swept, because a sweep's walls are extrusion
1416    // surfaces even when they are geometrically planes, and the boolean's
1417    // same-domain resolution (which is what makes the coplanar legs melt
1418    // into the solid's own faces) recognises coincidence between *planes*.
1419    let faces = [
1420        planar_face(model, &[apex0, a0, b0], -seat.along, tol)?,
1421        planar_face(model, &[apex1, a1, b1], seat.along, tol)?,
1422        planar_face(model, &[apex0, a0, a1, apex1], seat.normals[0] * sign, tol)?,
1423        planar_face(model, &[apex0, b0, b1, apex1], seat.normals[1] * sign, tol)?,
1424        planar_face(model, &[a0, b0, b1, a1], bevel_out, tol)?,
1425    ];
1426    Ok(Wedge {
1427        faces: faces.to_vec(),
1428        solid: None,
1429        additive: !seat.convex,
1430        whole_ring: None,
1431        marched: None,
1432    })
1433}
1434
1435/// The faces of `solid` holding `edge`.
1436fn faces_holding(model: &Model, solid: &Shape, edge: &Shape) -> Result<Vec<Shape>, Blamed> {
1437    let mut found = Vec::new();
1438    for face in ogeom_topo::explore_unique(model, solid, ogeom_topo::ShapeType::Face)? {
1439        if ogeom_topo::explore_unique(model, &face, ogeom_topo::ShapeType::Edge)?
1440            .iter()
1441            .any(|e| e.is_same(edge))
1442        {
1443            found.push(face);
1444        }
1445    }
1446    Ok(found)
1447}
1448
1449/// A bevel on a marched seat, applied to the solid the request's earlier
1450/// bevels (`earlier`) left (`so_far`, with their history): the edge found
1451/// again there between the faces `given` it lay between as asked, a face
1452/// the spelling names followed to what those bevels made of it, each piece
1453/// of the edge bevelled in turn.
1454///
1455/// A bevel runs on through a vertex where the crease carries on (an open
1456/// marched bevel runs out as the marched fillet does), so an edge an
1457/// earlier bevel of the same spelling meeting it at a vertex has taken
1458/// away is bevelled already; one an earlier bevel of another spelling has
1459/// taken is refused.
1460fn marched_step(
1461    model: &mut Model,
1462    so_far: &Built,
1463    edge: &Shape,
1464    spec: &Chamfer,
1465    given: &[Shape],
1466    earlier: &[(Shape, Chamfer)],
1467    tol: Tolerances,
1468) -> OgeomResult<Built> {
1469    let solid = &so_far.shape;
1470    let faces_now = ogeom_topo::explore_unique(model, solid, ogeom_topo::ShapeType::Face)?;
1471    let now = |named: &Shape| -> Shape {
1472        if faces_now.iter().any(|f| f.is_same(named)) {
1473            return named.clone();
1474        }
1475        so_far
1476            .history
1477            .modified(named)
1478            .iter()
1479            .find(|f| faces_now.iter().any(|g| g.is_same(f)))
1480            .cloned()
1481            .unwrap_or_else(|| named.clone())
1482    };
1483    let asked = spec;
1484    let spec = &match spec {
1485        Chamfer::Symmetric(d) => Chamfer::Symmetric(*d),
1486        Chamfer::Distances {
1487            face,
1488            on_face,
1489            on_other,
1490        } => Chamfer::Distances {
1491            face: now(face),
1492            on_face: *on_face,
1493            on_other: *on_other,
1494        },
1495        Chamfer::Angle {
1496            face,
1497            distance,
1498            angle,
1499        } => Chamfer::Angle {
1500            face: now(face),
1501            distance: *distance,
1502            angle: *angle,
1503        },
1504    };
1505    // An edge nothing of which is left is reported consumed.
1506    let pieces = match crate::fillet::refind_edges(model, solid, edge, given, tol) {
1507        Ok(pieces) => pieces,
1508        Err(ogeom_core::OgeomError::Construction(_)) => Vec::new(),
1509        Err(e) => return Err(e),
1510    };
1511    if pieces.is_empty() {
1512        let ends = |e: &Shape| -> OgeomResult<Vec<ogeom_topo::TShapeId>> {
1513            Ok(ogeom_algo::edge_vertices(model, e)?
1514                .map(|(a, b)| vec![a.node(), b.node()])
1515                .unwrap_or_default())
1516        };
1517        let own = ends(edge)?;
1518        for (other, theirs) in earlier {
1519            if same_spelling(theirs, asked, tol) && ends(other)?.iter().any(|v| own.contains(v)) {
1520                let mut done = Built::from_nothing(solid.clone());
1521                done.history.delete(edge);
1522                return Ok(done);
1523            }
1524        }
1525        ogeom_bail!(
1526            Construction,
1527            "a bevel on a marched seat whose edge an earlier bevel of the request, \
1528             of another spelling, took away is not built"
1529        );
1530    }
1531    let mut built = Built::from_nothing(solid.clone());
1532    for piece in pieces {
1533        let mut step =
1534            crate::chamfer_marched::marched_bevel(model, &built.shape, &piece, spec, given, tol)?;
1535        crate::support::credit_new_faces(model, &built.shape, &piece, &mut step)?;
1536        step.history.delete(edge);
1537        built = Built {
1538            shape: step.shape.clone(),
1539            history: built.history.then(&step.history),
1540        };
1541    }
1542    Ok(built)
1543}
1544
1545/// Whether two spellings bevel alike: the same kind, the same measures, the
1546/// same face named.
1547fn same_spelling(a: &Chamfer, b: &Chamfer, tol: Tolerances) -> bool {
1548    let near = |x: f64, y: f64| (x - y).abs() <= tol.confusion();
1549    match (a, b) {
1550        (Chamfer::Symmetric(x), Chamfer::Symmetric(y)) => near(*x, *y),
1551        (
1552            Chamfer::Distances {
1553                face: fa,
1554                on_face: oa,
1555                on_other: ra,
1556            },
1557            Chamfer::Distances {
1558                face: fb,
1559                on_face: ob,
1560                on_other: rb,
1561            },
1562        ) => fa.is_same(fb) && near(*oa, *ob) && near(*ra, *rb),
1563        (
1564            Chamfer::Angle {
1565                face: fa,
1566                distance: da,
1567                angle: aa,
1568            },
1569            Chamfer::Angle {
1570                face: fb,
1571                distance: db,
1572                angle: ab,
1573            },
1574        ) => fa.is_same(fb) && near(*da, *db) && (aa - ab).abs() <= tol.angular(),
1575        _ => false,
1576    }
1577}