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}