Skip to main content

ogeom_fillet/
chamfer.rs

1//! Chamfers: the bevel that replaces an edge.
2//!
3//! P4's opening stone, built deliberately on M3's shoulders: a chamfer along
4//! a straight edge between two planar faces is a wedge subtracted, and the
5//! wedge's own faces lie *exactly* on the solid's (coplanar, materials
6//! aligned), which is the same-domain case the boolean learned to resolve.
7//!
8//! Three spellings, one construction. The symmetric chamfer cuts the same
9//! distance along both faces; the distance-distance form cuts a named
10//! distance along a named face and another along its neighbour; the
11//! distance-angle form cuts a distance along the named face and leaves it at
12//! an angle, with the second distance derived where that bevel meets the
13//! other face. All three end in the same wedge subtraction.
14//!
15//! Two seats per spelling. A straight edge between planes takes the
16//! triangular prism above; the circular rim where a cylindrical wall meets a
17//! perpendicular planar cap takes a revolved wedge whose bevel is a *cone*:
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::support::{
22    RevolvedSeat, Seat, apply_wedge, edge_curve, planar_face, planar_seat, revolved_flanks,
23    revolved_seat,
24};
25use ogeom_algo::{Built, make_revolution_band};
26use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
27use ogeom_geom::{ConeSurface, Curve, SurfaceGeometry};
28use ogeom_math::Cone;
29use ogeom_topo::{Model, Shape};
30
31/// Bevel a straight edge of a solid, cutting `distance` back along each of
32/// its two faces.
33///
34/// The edge must be straight, convex, and shared by exactly two planar faces;
35/// the distances are equal (the symmetric chamfer). The result is the boolean
36/// difference with a wedge whose legs run along the two faces, so the
37/// history reads as a cut: the two faces are modified into their trimmed
38/// pieces, the edge's neighbourhood gains the bevel face.
39///
40/// # Errors
41///
42/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the edge is
43/// not straight, not convex, not shared by exactly two planar faces of
44/// `solid`, or `distance` is not a usable length.
45pub fn chamfer_edge(
46    model: &mut Model,
47    solid: &Shape,
48    edge: &Shape,
49    distance: f64,
50    tol: Tolerances,
51) -> OgeomResult<Built> {
52    wedge_for(model, solid, edge, &Chamfer::Symmetric(distance), tol)?
53        .apply(model, solid, edge, tol)
54}
55
56/// Bevel a straight edge, cutting `on_face` back along `face` and `on_other`
57/// along the edge's other face.
58///
59/// The asymmetric chamfer: `face` names which side the first distance applies
60/// to, and must be one of the two faces meeting at the edge.
61///
62/// # Errors
63///
64/// As [`chamfer_edge`], and additionally if `face` is not one of the edge's
65/// two faces.
66pub fn chamfer_edge_distances(
67    model: &mut Model,
68    solid: &Shape,
69    edge: &Shape,
70    face: &Shape,
71    on_face: f64,
72    on_other: f64,
73    tol: Tolerances,
74) -> OgeomResult<Built> {
75    let spec = Chamfer::Distances {
76        face: face.clone(),
77        on_face,
78        on_other,
79    };
80    wedge_for(model, solid, edge, &spec, tol)?.apply(model, solid, edge, tol)
81}
82
83/// Bevel a straight edge, cutting `distance` back along `face` and leaving it
84/// at `angle` radians from that face.
85///
86/// The distance-angle chamfer: the second distance is where the bevel,
87/// departing the named face at the given angle, meets the other face. An
88/// angle of `π/4` on a square edge reproduces the symmetric chamfer.
89///
90/// # Errors
91///
92/// As [`chamfer_edge_distances`], and additionally if the bevel at that angle
93/// never reaches the other face.
94pub fn chamfer_edge_angle(
95    model: &mut Model,
96    solid: &Shape,
97    edge: &Shape,
98    face: &Shape,
99    distance: f64,
100    angle: f64,
101    tol: Tolerances,
102) -> OgeomResult<Built> {
103    let spec = Chamfer::Angle {
104        face: face.clone(),
105        distance,
106        angle,
107    };
108    wedge_for(model, solid, edge, &spec, tol)?.apply(model, solid, edge, tol)
109}
110
111/// One edge's chamfer, in any of the three spellings.
112#[derive(Debug, Clone)]
113pub enum Chamfer {
114    /// The same distance back along both faces, as [`chamfer_edge`].
115    Symmetric(f64),
116    /// `on_face` along `face` and `on_other` along the other face, as
117    /// [`chamfer_edge_distances`].
118    Distances {
119        /// The face the first distance runs along.
120        face: Shape,
121        /// The distance along `face`.
122        on_face: f64,
123        /// The distance along the edge's other face.
124        on_other: f64,
125    },
126    /// `distance` along `face`, leaving it at `angle` radians, as
127    /// [`chamfer_edge_angle`].
128    Angle {
129        /// The face the distance runs along.
130        face: Shape,
131        /// The distance along `face`.
132        distance: f64,
133        /// The bevel's angle from `face`, in radians.
134        angle: f64,
135    },
136}
137
138/// Bevel several edges of a solid as one operation, each by `distance`.
139///
140/// As [`chamfer_edges_with`] with the symmetric chamfer on every edge.
141///
142/// # Errors
143///
144/// As [`chamfer_edges_with`].
145pub fn chamfer_edges(
146    model: &mut Model,
147    solid: &Shape,
148    edges: &[Shape],
149    distance: f64,
150    tol: Tolerances,
151) -> OgeomResult<Built> {
152    let specs: Vec<(Shape, Chamfer)> = edges
153        .iter()
154        .map(|e| (e.clone(), Chamfer::Symmetric(distance)))
155        .collect();
156    chamfer_edges_with(model, solid, &specs, tol)
157}
158
159/// Bevel several edges of a solid as one operation, each with its own
160/// chamfer.
161///
162/// Every wedge is built on the solid as it stands before the call, and then
163/// every wedge is applied. That is what makes the bevels mitre: where two
164/// meet at a vertex, each wedge still reaches the corner, the two cut the
165/// region above either bevel plane, and the planes meet along their own
166/// line with neither a step nor a cap. One edge at a time
167/// ([`chamfer_edge`] in a loop) builds the second wedge on an edge the
168/// first bevel has already shortened, and each corner keeps a small
169/// tetrahedron and two extra faces. Where three bevels meet at a convex
170/// vertex the three planes meet at one point, the mitre of three planes.
171///
172/// # Errors
173///
174/// As [`chamfer_edge`], [`chamfer_edge_distances`] and
175/// [`chamfer_edge_angle`] per edge, judged on the solid as it stands before
176/// the call; and [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
177/// if no edges are given.
178pub fn chamfer_edges_with(
179    model: &mut Model,
180    solid: &Shape,
181    specs: &[(Shape, Chamfer)],
182    tol: Tolerances,
183) -> OgeomResult<Built> {
184    if specs.is_empty() {
185        ogeom_bail!(Construction, "a chain of no edges bevels nothing");
186    }
187    let wedges: Vec<Wedge> = specs
188        .iter()
189        .map(|(edge, spec)| wedge_for(model, solid, edge, spec, tol))
190        .collect::<OgeomResult<_>>()?;
191    let mut built = Built::from_nothing(solid.clone());
192    for ((edge, _), wedge) in specs.iter().zip(wedges) {
193        let step = wedge.apply(model, &built.shape, edge, tol)?;
194        built = Built {
195            shape: step.shape.clone(),
196            history: built.history.then(&step.history),
197        };
198    }
199    Ok(built)
200}
201
202/// The wedge one chamfer cuts, built on `solid` as it stands.
203fn wedge_for(
204    model: &mut Model,
205    solid: &Shape,
206    edge: &Shape,
207    spec: &Chamfer,
208    tol: Tolerances,
209) -> OgeomResult<Wedge> {
210    match spec {
211        Chamfer::Symmetric(distance) => match seat_kind(model, edge, tol)? {
212            SeatKind::Straight => {
213                let seat = planar_seat(model, solid, edge, tol)?;
214                bevel(model, &seat, [*distance, *distance], tol)
215            }
216            SeatKind::Rim(rim) => {
217                let seat = revolved_seat(model, solid, edge, &rim, tol)?;
218                revolved_bevel(model, &seat, *distance, *distance, tol)
219            }
220        },
221        Chamfer::Distances {
222            face,
223            on_face,
224            on_other,
225        } => match seat_kind(model, edge, tol)? {
226            SeatKind::Straight => {
227                let seat = planar_seat(model, solid, edge, tol)?;
228                let i = seat_side(&seat, face)?;
229                let mut distances = [0.0; 2];
230                distances[i] = *on_face;
231                distances[1 - i] = *on_other;
232                bevel(model, &seat, distances, tol)
233            }
234            SeatKind::Rim(rim) => {
235                let seat = revolved_seat(model, solid, edge, &rim, tol)?;
236                let (on_wall, on_cap) = revolved_side(&seat, face, *on_face, *on_other)?;
237                revolved_bevel(model, &seat, on_wall, on_cap, tol)
238            }
239        },
240        Chamfer::Angle {
241            face,
242            distance,
243            angle,
244        } => {
245            let (distance, angle) = (*distance, *angle);
246            if !angle.is_finite() || angle <= tol.angular() {
247                ogeom_bail!(
248                    Construction,
249                    "a chamfer at an angle of {angle} cuts nothing"
250                );
251            }
252            match seat_kind(model, edge, tol)? {
253                SeatKind::Straight => {
254                    let seat = planar_seat(model, solid, edge, tol)?;
255                    let i = seat_side(&seat, face)?;
256                    // In the cross-section: from the contact on the named
257                    // face, the bevel leaves at `angle` into the wedge's own
258                    // side: the material on a convex edge, the open dihedral
259                    // on a concave one. Where it crosses the other leg's ray
260                    // is the derived distance; no crossing, no chamfer.
261                    let sign = if seat.convex { 1.0 } else { -1.0 };
262                    let a = seat.leg(i, tol)? * sign;
263                    let b = seat.leg(1 - i, tol)? * sign;
264                    let inward = -seat.normals[i] * sign;
265                    let denominator = angle.sin().mul_add(b.dot(a), angle.cos() * b.dot(inward));
266                    if denominator <= tol.angular() {
267                        ogeom_bail!(
268                            Construction,
269                            "the bevel at that angle never meets the edge's other face"
270                        );
271                    }
272                    let derived = distance * angle.sin() / denominator;
273                    let mut distances = [0.0; 2];
274                    distances[i] = distance;
275                    distances[1 - i] = derived;
276                    bevel(model, &seat, distances, tol)
277                }
278                SeatKind::Rim(rim) => {
279                    // The rim's seat is square by construction (the cap is
280                    // perpendicular to the wall), so the derived distance is
281                    // the plain tangent, and past a right angle the bevel
282                    // walks away from the other face instead of toward it.
283                    if angle >= core::f64::consts::FRAC_PI_2 - tol.angular() {
284                        ogeom_bail!(
285                            Construction,
286                            "the bevel at that angle never meets the edge's other face"
287                        );
288                    }
289                    let seat = revolved_seat(model, solid, edge, &rim, tol)?;
290                    let derived = distance * angle.tan();
291                    let (on_wall, on_cap) = revolved_side(&seat, face, distance, derived)?;
292                    revolved_bevel(model, &seat, on_wall, on_cap, tol)
293                }
294            }
295        }
296    }
297}
298
299/// A chamfer's wedge, built and not yet applied: its faces, and whether it
300/// fuses (a concave edge) or cuts (a convex one).
301struct Wedge {
302    faces: Vec<Shape>,
303    additive: bool,
304}
305
306impl Wedge {
307    fn apply(
308        self,
309        model: &mut Model,
310        solid: &Shape,
311        edge: &Shape,
312        tol: Tolerances,
313    ) -> OgeomResult<Built> {
314        apply_wedge(model, solid, Some(edge), &self.faces, self.additive, tol)
315    }
316}
317
318/// Which seat a chamfer is standing on, read from the edge's curve.
319enum SeatKind {
320    /// A straight edge between planes: the triangular-prism wedge.
321    Straight,
322    /// A circular rim: the revolved wedge with a conical bevel.
323    Rim(ogeom_geom::CircleCurve),
324}
325
326fn seat_kind(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<SeatKind> {
327    let (curve, _) = edge_curve(model, edge, tol)?;
328    match curve {
329        Curve::Line(_) => Ok(SeatKind::Straight),
330        Curve::Circle(c) => Ok(SeatKind::Rim(c)),
331        _ => ogeom_bail!(
332            Construction,
333            "chamfering an edge that is neither straight nor circular needs \
334             the marching blend machinery"
335        ),
336    }
337}
338
339/// Assign a named face's distance to the wall or the cap.
340///
341/// On the wall the distance runs axially down from the rim; on the cap it
342/// runs radially in from it.
343fn revolved_side(
344    seat: &RevolvedSeat,
345    face: &Shape,
346    on_face: f64,
347    on_other: f64,
348) -> OgeomResult<(f64, f64)> {
349    if seat.wall_face.node() == face.node() {
350        Ok((on_face, on_other))
351    } else if seat.cap_face.node() == face.node() {
352        Ok((on_other, on_face))
353    } else {
354        ogeom_bail!(
355            Construction,
356            "the named face does not meet the edge being chamfered"
357        )
358    }
359}
360
361/// The revolved wedge with the quarter-tube exchanged for a slant: legs
362/// `on_wall` axially down the wall and `on_cap` radially along the cap, and
363/// the cone between the two tangency rings as the bevel.
364fn revolved_bevel(
365    model: &mut Model,
366    seat: &RevolvedSeat,
367    on_wall: f64,
368    on_cap: f64,
369    tol: Tolerances,
370) -> OgeomResult<Wedge> {
371    for distance in [on_wall, on_cap] {
372        if !distance.is_finite() || distance <= tol.confusion() {
373            ogeom_bail!(Construction, "a chamfer of {distance} cuts nothing");
374        }
375    }
376    let cap_rho = seat.sigma.mul_add(-(seat.tau * on_cap), seat.radius);
377    if cap_rho <= tol.confusion() {
378        ogeom_bail!(
379            Construction,
380            "a chamfer of {on_cap} along the cap swallows the axis of a rim \
381             of radius {}",
382            seat.radius
383        );
384    }
385    let flanks = revolved_flanks(model, seat, on_wall, cap_rho, tol)?;
386
387    // The bevel: the cone through both tangency rings (reference radius
388    // `cap_rho` at the cap's level, the rim's radius a wall-depth below).
389    // Unlike the fillet's quarter-tube, whose away-from-the-tube normal
390    // tracks the wedge seat by seat, the cone's natural normal always points
391    // away from the axis, and the wedge sits on the axis side of the slant
392    // exactly when `sigma` and `tau` agree.
393    let bevel_band = {
394        let slope = (cap_rho - seat.radius) / (seat.tau * on_wall);
395        let cone = Cone::new(seat.frame_at(seat.centre, tol)?, cap_rho, slope.atan(), tol)?;
396        // The domain covers the band's two rows (the cap ring at zero and
397        // the wall ring a depth away) with a margin that stays clear of the
398        // apex, where the surface degenerates.
399        let rows = (
400            0.0_f64.min(-seat.tau * on_wall),
401            0.0_f64.max(-seat.tau * on_wall),
402        );
403        let pad = 0.1 * on_wall;
404        let surface: SurfaceGeometry = ConeSurface::new(cone, (rows.0 - pad, rows.1 + pad))?.into();
405        let band = make_revolution_band(model, &surface, &flanks.wall_ring, &flanks.cap_ring, tol)?;
406        if seat.sigma * seat.tau > 0.0 {
407            band.reversed()
408        } else {
409            band
410        }
411    };
412
413    Ok(Wedge {
414        faces: vec![flanks.wall_band, flanks.annulus, bevel_band],
415        additive: seat.additive(),
416    })
417}
418
419/// Which side of the seat a named face is, by identity.
420fn seat_side(seat: &Seat, face: &Shape) -> OgeomResult<usize> {
421    if seat.faces[0].node() == face.node() {
422        Ok(0)
423    } else if seat.faces[1].node() == face.node() {
424        Ok(1)
425    } else {
426        ogeom_bail!(
427            Construction,
428            "the named face does not meet the edge being chamfered"
429        )
430    }
431}
432
433/// The one construction under all three spellings: the wedge with legs
434/// `distances[i]` along face `i`, subtracted.
435fn bevel(
436    model: &mut Model,
437    seat: &Seat,
438    distances: [f64; 2],
439    tol: Tolerances,
440) -> OgeomResult<Wedge> {
441    for distance in distances {
442        if !distance.is_finite() || distance <= tol.confusion() {
443            ogeom_bail!(Construction, "a chamfer of {distance} cuts nothing");
444        }
445    }
446    // On a concave edge every leg mirrors: the wedge sits in the open
447    // dihedral, its legs walk the faces' planes into it, and its strips face
448    // the material they will melt against with *opposed* orientation, which
449    // is exactly what a fuse cancels.
450    let sign = if seat.convex { 1.0 } else { -1.0 };
451    let a = seat.leg(0, tol)? * sign;
452    let b = seat.leg(1, tol)? * sign;
453    // A setback runs back across each face; past the face's far side it
454    // would cut through the face and on into whatever lies beyond it.
455    if seat.convex {
456        for (i, leg) in [a, b].into_iter().enumerate() {
457            let reach = crate::support::face_reach(model, &seat.faces[i], seat.start, leg, tol)?;
458            if distances[i] > reach + tol.confusion() {
459                ogeom_bail!(
460                    Construction,
461                    "a chamfer of {} on the edge from {:?} to {:?} runs past its face, \
462                     which reaches {reach} back from the edge",
463                    distances[i],
464                    seat.start,
465                    seat.end
466                );
467            }
468        }
469    }
470
471    let travel = seat.end - seat.start;
472    let apex0 = seat.start;
473    let apex1 = seat.end;
474    let a0 = apex0 + a * distances[0];
475    let b0 = apex0 + b * distances[1];
476    let a1 = a0 + travel;
477    let b1 = b0 + travel;
478
479    // The bevel's outward normal: perpendicular to the cut line and the edge,
480    // pointing from the apex toward the cut. For equal distances this is the
481    // leg bisector exactly.
482    let bevel_out = {
483        let across = b0 - a0;
484        let mut n = seat.along.cross(across);
485        let m = n.magnitude();
486        if m <= tol.confusion() {
487            ogeom_bail!(Construction, "the chamfer's cut line has no direction");
488        }
489        n /= m;
490        if n.dot(a0 - apex0) < 0.0 {
491            n = -n;
492        }
493        n
494    };
495
496    // The wedge: a triangular prism whose apex line is the edge and whose
497    // legs run the distances along each face. Built from five explicit planar
498    // faces rather than swept, because a sweep's walls are extrusion
499    // surfaces even when they are geometrically planes, and the boolean's
500    // same-domain resolution (which is what makes the coplanar legs melt
501    // into the solid's own faces) recognises coincidence between *planes*.
502    let faces = [
503        planar_face(model, &[apex0, a0, b0], -seat.along, tol)?,
504        planar_face(model, &[apex1, a1, b1], seat.along, tol)?,
505        planar_face(model, &[apex0, a0, a1, apex1], seat.normals[0] * sign, tol)?,
506        planar_face(model, &[apex0, b0, b1, apex1], seat.normals[1] * sign, tol)?,
507        planar_face(model, &[a0, b0, b1, a1], bevel_out, tol)?,
508    ];
509    Ok(Wedge {
510        faces: faces.to_vec(),
511        additive: !seat.convex,
512    })
513}