Skip to main content

brep_cam/cutter/
mod.rs

1//! Cutter profiles: the rotationally symmetric shape of the tool below the
2//! shank, as the drop-cutter, push-cutter, preview and simulation see it.
3//!
4//! Cutter-local coordinates: the axis is +Z, the tool REFERENCE POINT is the
5//! origin. For flat and cone tools that is the physical tip/bottom; for ball
6//! and bull tools it is the lowest point of the ball/toroid. `height_at_radius(r)`
7//! is the profile height above the reference at radial distance `r`;
8//! `radius_at_height(h)` the largest cutting radius at local height `h`.
9//!
10//! Profiles are piecewise: each [`ProfileSegment`] names its surface kind and
11//! the radius and height intervals it owns, so compound cutters (ball-cone)
12//! evaluate segment by segment and contact solvers can pick the formulation
13//! for the surface they touch.
14
15use crate::model::{Tool, ToolKind};
16use serde::{Deserialize, Serialize};
17
18mod extra;
19mod solid;
20
21pub use extra::{DovetailCutter, FrustumCutter, LollipopCutter};
22pub use solid::{ProfilePoint, ToolMesh, ToolMeshGroup, ToolPart, ToolProfile, DEFAULT_CHORD, DEFAULT_RADIAL_SEGMENTS};
23
24/// The five selected cutter shapes.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum CutterKind {
28    Flat,
29    Ball,
30    Bull,
31    Cone,
32    BallCone,
33    /// Flat tip widening into a cone (chamfer mill, tapered flat mill).
34    Frustum,
35    /// Ball on a neck (undercutting).
36    Lollipop,
37    /// Inverted cone on a neck (undercutting).
38    Dovetail,
39}
40
41impl CutterKind {
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            CutterKind::Flat => "flat",
45            CutterKind::Ball => "ball",
46            CutterKind::Bull => "bull",
47            CutterKind::Cone => "cone",
48            CutterKind::BallCone => "ball-cone",
49            CutterKind::Frustum => "frustum",
50            CutterKind::Lollipop => "lollipop",
51            CutterKind::Dovetail => "dovetail",
52        }
53    }
54}
55
56impl std::fmt::Display for CutterKind {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(self.as_str())
59    }
60}
61
62/// Why a cutter was refused. User-facing through `Display`.
63#[derive(Clone, Debug, PartialEq)]
64pub enum CutterError {
65    NonFinite { field: &'static str },
66    NotPositive { field: &'static str },
67    /// Bull: `0 < corner_radius < radius` violated.
68    CornerRadiusOutOfRange { corner_radius: f64, radius: f64 },
69    /// Cone / ball-cone: `0 < included_angle < 180` violated.
70    IncludedAngleOutOfRange { included_angle_deg: f64 },
71    /// Ball-cone: `ball_diameter <= maximum_diameter` violated.
72    BallLargerThanMaximum { ball_diameter: f64, maximum_diameter: f64 },
73    /// The tool kind has no cutter profile (drills, taps, lathe inserts, ...).
74    UnsupportedToolKind { kind: ToolKind },
75    /// A field the tool kind requires is missing.
76    MissingField { kind: ToolKind, field: &'static str },
77    /// Two consecutive profile segments do not meet in height at `radius`.
78    JoinGap { radius: f64, gap: f64 },
79    /// Two consecutive curved segments meet with a corner at `radius`.
80    JoinNotTangent { radius: f64, sine: f64 },
81    /// Frustum: the tip must be narrower than the tool.
82    TipNotSmallerThanDiameter { tip_diameter: f64, diameter: f64 },
83    /// Lollipop / dovetail: the neck must be narrower than the tool.
84    NeckNotSmallerThanDiameter { neck_diameter: f64, diameter: f64 },
85}
86
87impl std::fmt::Display for CutterError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            CutterError::NonFinite { field } => write!(f, "cutter {field} is not finite"),
91            CutterError::NotPositive { field } => write!(f, "cutter {field} must be positive"),
92            CutterError::CornerRadiusOutOfRange { corner_radius, radius } => write!(
93                f,
94                "corner radius {corner_radius} must be greater than 0 and smaller than the tool radius {radius}"
95            ),
96            CutterError::IncludedAngleOutOfRange { included_angle_deg } => {
97                write!(f, "included angle {included_angle_deg} must be between 0 and 180 degrees")
98            }
99            CutterError::BallLargerThanMaximum { ball_diameter, maximum_diameter } => write!(
100                f,
101                "ball diameter {ball_diameter} must not exceed the maximum diameter {maximum_diameter}"
102            ),
103            CutterError::UnsupportedToolKind { kind } => {
104                write!(f, "tool kind {} has no milling cutter profile", kind.as_str())
105            }
106            CutterError::MissingField { kind, field } => {
107                write!(f, "tool kind {} needs {field}", kind.as_str())
108            }
109            CutterError::JoinGap { radius, gap } => {
110                write!(f, "cutter profile has a height gap of {gap} at radius {radius}")
111            }
112            CutterError::JoinNotTangent { radius, sine } => {
113                write!(f, "cutter profile has a corner at radius {radius} (join sine {sine})")
114            }
115            CutterError::TipNotSmallerThanDiameter { tip_diameter, diameter } => {
116                write!(f, "tip diameter {tip_diameter} must be smaller than the diameter {diameter}")
117            }
118            CutterError::NeckNotSmallerThanDiameter { neck_diameter, diameter } => {
119                write!(f, "neck diameter {neck_diameter} must be smaller than the diameter {diameter}")
120            }
121        }
122    }
123}
124
125impl std::error::Error for CutterError {}
126
127/// Surface of one profile segment.
128#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub enum SegmentKind {
131    /// Horizontal disc at `z_offset`.
132    Flat,
133    /// Sphere of radius `profile_radius` centred on the axis at `z_offset`.
134    Sphere,
135    /// Torus: tube radius `profile_radius`, ring radius `ring_radius`, centre height `z_offset`.
136    Torus,
137    /// Cone with half-angle `half_angle_rad`, apex on the axis at `z_offset`.
138    Cone,
139    /// Vertical cylinder of radius `profile_radius`.
140    Cylinder,
141    /// Upper half of a sphere (radius DEcreasing with height) centred on the
142    /// axis at `z_offset`: a lollipop above its equator. Never a drop contact.
143    SphereUpper,
144    /// Inverted cone (radius decreasing with height) with half-angle
145    /// `half_angle_rad`, apex on the axis at `z_offset` ABOVE the segment: a
146    /// dovetail flank. Never a drop contact.
147    ConeInverted,
148}
149
150/// One piece of a piecewise profile, with the parameters its surface needs.
151#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct ProfileSegment {
154    pub kind: SegmentKind,
155    /// Radial interval `[r0, r1]` this segment owns.
156    pub radius_range: [f64; 2],
157    /// Height interval `[h0, h1]` this segment owns.
158    pub height_range: [f64; 2],
159    /// Sphere/torus tube radius or cylinder radius; `0` otherwise.
160    pub profile_radius: f64,
161    /// Torus ring radius; `0` otherwise.
162    pub ring_radius: f64,
163    /// Cone half-angle in radians; `0` otherwise.
164    pub half_angle_rad: f64,
165    /// Height of the sphere/torus centre, the cone apex, or the flat disc.
166    pub z_offset: f64,
167}
168
169impl ProfileSegment {
170    pub fn contains_radius(&self, r: f64) -> bool {
171        r >= self.radius_range[0] && r <= self.radius_range[1]
172    }
173
174    pub fn contains_height(&self, h: f64) -> bool {
175        h >= self.height_range[0] && h <= self.height_range[1]
176    }
177}
178
179impl ProfileSegment {
180    /// Relative distance to a sphere/torus equator below which the tangent is
181    /// taken as vertical. Round-off there is ~1e-16·ρ; a 50 µm ball with a 1°
182    /// taper has its join ~1e-6 (2e-5·ρ) from the equator and must keep its
183    /// real tangent, so the threshold is relative, not `distance_eps`.
184    pub const EQUATOR_SNAP: f64 = 1e-9;
185
186    /// Unit tangent `(dr, dh)` of the profile curve at radius `r` on this
187    /// segment, pointing outward/upward. Analytic; within `EQUATOR_SNAP` (relative) of
188    /// a sphere's or torus's equator the tangent is snapped to vertical, because
189    /// `sqrt(ρ² - d²)` there turns a one-ulp error in `d` into a ~1e-8 angle.
190    /// `tol` is kept for callers that want to pass the policy object; the snap is relative.
191    pub fn tangent_at(&self, r: f64, tol: &crate::numeric::Tolerance) -> [f64; 2] {
192        let _ = tol;
193        let unit = |dr: f64, dh: f64| {
194            let n = (dr * dr + dh * dh).sqrt();
195            if n > 0.0 {
196                [dr / n, dh / n]
197            } else {
198                [1.0, 0.0]
199            }
200        };
201        let round = |rho: f64, d: f64| {
202            if rho - d.abs() <= Self::EQUATOR_SNAP * rho {
203                [0.0, d.signum()]
204            } else {
205                unit((rho * rho - d * d).max(0.0).sqrt(), d)
206            }
207        };
208        match self.kind {
209            SegmentKind::Flat => [1.0, 0.0],
210            SegmentKind::Cylinder => [0.0, 1.0],
211            SegmentKind::Cone => unit(self.half_angle_rad.sin(), self.half_angle_rad.cos()),
212            SegmentKind::Sphere => round(self.profile_radius, r),
213            SegmentKind::Torus => round(self.profile_radius, r - self.ring_radius),
214            SegmentKind::SphereUpper => {
215                let t = round(self.profile_radius, r);
216                [-t[0], t[1]]
217            }
218            SegmentKind::ConeInverted => unit(-self.half_angle_rad.sin(), self.half_angle_rad.cos()),
219        }
220    }
221}
222
223/// Every consecutive pair of segments meets in height, and meets tangentially
224/// unless the pair is one of the physical sharp corners (flat bottom or cone
225/// into the cylindrical shank). `distance_eps` bounds the height gap,
226/// `angle_eps` the sine of the angle between the two tangents.
227pub fn check_segment_joins(segments: &[ProfileSegment], tol: &crate::numeric::Tolerance) -> Result<(), CutterError> {
228    for pair in segments.windows(2) {
229        let (a, b) = (&pair[0], &pair[1]);
230        let radius = a.radius_range[1];
231        let gap = (a.height_range[1] - b.height_range[0]).abs();
232        if gap > tol.distance_eps {
233            return Err(CutterError::JoinGap { radius, gap });
234        }
235        // Physical sharp corners: a flat bottom's edge, a cone or inverted cone
236        // meeting the shank, a lollipop's upper ball meeting its neck.
237        let sharp_allowed = matches!(
238            (a.kind, b.kind),
239            (SegmentKind::Flat, SegmentKind::Cylinder)
240                | (SegmentKind::Cone, SegmentKind::Cylinder)
241                | (SegmentKind::Flat, SegmentKind::Cone)
242                | (SegmentKind::Flat, SegmentKind::ConeInverted)
243                | (SegmentKind::ConeInverted, SegmentKind::Cylinder)
244                | (SegmentKind::SphereUpper, SegmentKind::Cylinder)
245        );
246        if sharp_allowed {
247            continue;
248        }
249        let ta = a.tangent_at(radius, tol);
250        let tb = b.tangent_at(radius, tol);
251        let sine = (ta[0] * tb[1] - ta[1] * tb[0]).abs();
252        if sine > tol.angle_eps {
253            return Err(CutterError::JoinNotTangent { radius, sine });
254        }
255    }
256    Ok(())
257}
258
259/// The contract every cutter implements.
260/// `Sync`: a profile is shared read-only by parallel workers (feature
261/// `parallel`); every implementor is plain data.
262pub trait CutterProfile: Sync {
263    fn kind(&self) -> CutterKind;
264    /// Maximum cutting radius.
265    fn radius(&self) -> f64;
266    fn diameter(&self) -> f64 {
267        2.0 * self.radius()
268    }
269    /// Usable cutting height above the reference point.
270    fn cutting_length(&self) -> f64;
271    /// Shank length above the cutting length, for collision checks and preview.
272    fn shaft_length(&self) -> f64;
273    /// Shank radius; equals the cutting radius unless the tool says otherwise.
274    fn shaft_radius(&self) -> f64 {
275        self.radius()
276    }
277    /// Height at which the curved/sloped profile ends and the cylindrical part begins.
278    fn profile_height(&self) -> f64;
279    /// Profile height above the reference at radial distance `r`; `None` outside `[0, radius]`.
280    fn height_at_radius(&self, r: f64) -> Option<f64>;
281    /// Largest cutting radius at local height `h`; `None` outside `[0, cutting_length]`.
282    fn radius_at_height(&self, h: f64) -> Option<f64>;
283    /// `radius_at_height` extended by the shank up to `cutting_length + shaft_length`.
284    fn max_radius_at_height(&self, h: f64) -> Option<f64> {
285        if h < 0.0 || !h.is_finite() {
286            return None;
287        }
288        if h <= self.cutting_length() {
289            return self.radius_at_height(h);
290        }
291        if h <= self.cutting_length() + self.shaft_length() {
292            return Some(self.shaft_radius());
293        }
294        None
295    }
296    /// The piecewise description, bottom segment first.
297    fn segments(&self) -> Vec<ProfileSegment>;
298    /// User-facing dimension check.
299    fn validate(&self) -> Result<(), CutterError>;
300    /// Total modelled height: cutting length plus shank.
301    fn total_height(&self) -> f64 {
302        self.cutting_length() + self.shaft_length()
303    }
304}
305
306pub(super) fn check_positive(field: &'static str, v: f64) -> Result<(), CutterError> {
307    if !v.is_finite() {
308        return Err(CutterError::NonFinite { field });
309    }
310    if v <= 0.0 {
311        return Err(CutterError::NotPositive { field });
312    }
313    Ok(())
314}
315
316pub(super) fn check_non_negative(field: &'static str, v: f64) -> Result<(), CutterError> {
317    if !v.is_finite() {
318        return Err(CutterError::NonFinite { field });
319    }
320    if v < 0.0 {
321        return Err(CutterError::NotPositive { field });
322    }
323    Ok(())
324}
325
326pub(super) fn in_radius(r: f64, radius: f64) -> bool {
327    r.is_finite() && r >= 0.0 && r <= radius
328}
329
330pub(super) fn in_height(h: f64, cutting_length: f64) -> bool {
331    h.is_finite() && h >= 0.0 && h <= cutting_length
332}
333
334/// Shank dimensions shared by every profile.
335#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct Shank {
338    pub cutting_length: f64,
339    pub shaft_length: f64,
340    /// `None`: the cutting radius.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub shaft_radius: Option<f64>,
343}
344
345impl Shank {
346    pub fn new(cutting_length: f64, shaft_length: f64) -> Self {
347        Self { cutting_length, shaft_length, shaft_radius: None }
348    }
349
350    pub(super) fn validate(&self) -> Result<(), CutterError> {
351        check_positive("cutting length", self.cutting_length)?;
352        check_non_negative("shaft length", self.shaft_length)?;
353        if let Some(r) = self.shaft_radius {
354            check_positive("shaft radius", r)?;
355        }
356        Ok(())
357    }
358
359    pub(super) fn cylinder_segment(&self, radius: f64, from: f64) -> ProfileSegment {
360        ProfileSegment {
361            kind: SegmentKind::Cylinder,
362            radius_range: [radius, radius],
363            height_range: [from, self.cutting_length],
364            profile_radius: radius,
365            ring_radius: 0.0,
366            half_angle_rad: 0.0,
367            z_offset: from,
368        }
369    }
370}
371
372/// Flat end mill: planar bottom.
373#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase")]
375pub struct FlatCutter {
376    pub radius: f64,
377    pub shank: Shank,
378}
379
380impl CutterProfile for FlatCutter {
381    fn kind(&self) -> CutterKind {
382        CutterKind::Flat
383    }
384    fn radius(&self) -> f64 {
385        self.radius
386    }
387    fn cutting_length(&self) -> f64 {
388        self.shank.cutting_length
389    }
390    fn shaft_length(&self) -> f64 {
391        self.shank.shaft_length
392    }
393    fn shaft_radius(&self) -> f64 {
394        self.shank.shaft_radius.unwrap_or(self.radius)
395    }
396    fn profile_height(&self) -> f64 {
397        0.0
398    }
399    fn height_at_radius(&self, r: f64) -> Option<f64> {
400        in_radius(r, self.radius).then_some(0.0)
401    }
402    fn radius_at_height(&self, h: f64) -> Option<f64> {
403        in_height(h, self.shank.cutting_length).then_some(self.radius)
404    }
405    fn segments(&self) -> Vec<ProfileSegment> {
406        vec![
407            ProfileSegment {
408                kind: SegmentKind::Flat,
409                radius_range: [0.0, self.radius],
410                height_range: [0.0, 0.0],
411                profile_radius: 0.0,
412                ring_radius: 0.0,
413                half_angle_rad: 0.0,
414                z_offset: 0.0,
415            },
416            self.shank.cylinder_segment(self.radius, 0.0),
417        ]
418    }
419    fn validate(&self) -> Result<(), CutterError> {
420        check_positive("diameter", self.radius)?;
421        self.shank.validate()
422    }
423}
424
425/// Ball nose end mill: hemispherical bottom, centre at local height `radius`.
426#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
427#[serde(rename_all = "camelCase")]
428pub struct BallCutter {
429    pub radius: f64,
430    pub shank: Shank,
431}
432
433impl CutterProfile for BallCutter {
434    fn kind(&self) -> CutterKind {
435        CutterKind::Ball
436    }
437    fn radius(&self) -> f64 {
438        self.radius
439    }
440    fn cutting_length(&self) -> f64 {
441        self.shank.cutting_length
442    }
443    fn shaft_length(&self) -> f64 {
444        self.shank.shaft_length
445    }
446    fn shaft_radius(&self) -> f64 {
447        self.shank.shaft_radius.unwrap_or(self.radius)
448    }
449    fn profile_height(&self) -> f64 {
450        self.radius
451    }
452    fn height_at_radius(&self, r: f64) -> Option<f64> {
453        if !in_radius(r, self.radius) {
454            return None;
455        }
456        let rr = self.radius;
457        Some(rr - (rr * rr - r * r).max(0.0).sqrt())
458    }
459    fn radius_at_height(&self, h: f64) -> Option<f64> {
460        if !in_height(h, self.shank.cutting_length) {
461            return None;
462        }
463        let rr = self.radius;
464        if h >= rr {
465            return Some(rr);
466        }
467        Some((rr * rr - (rr - h) * (rr - h)).max(0.0).sqrt())
468    }
469    fn segments(&self) -> Vec<ProfileSegment> {
470        vec![
471            ProfileSegment {
472                kind: SegmentKind::Sphere,
473                radius_range: [0.0, self.radius],
474                height_range: [0.0, self.radius],
475                profile_radius: self.radius,
476                ring_radius: 0.0,
477                half_angle_rad: 0.0,
478                z_offset: self.radius,
479            },
480            self.shank.cylinder_segment(self.radius, self.radius),
481        ]
482    }
483    fn validate(&self) -> Result<(), CutterError> {
484        check_positive("diameter", self.radius)?;
485        self.shank.validate()?;
486        if self.shank.cutting_length < self.radius {
487            return Err(CutterError::NotPositive { field: "cutting length above the ball" });
488        }
489        Ok(())
490    }
491}
492
493/// Bull nose: flat centre of radius `radius - corner_radius`, toroidal corner.
494#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct BullCutter {
497    pub radius: f64,
498    pub corner_radius: f64,
499    pub shank: Shank,
500}
501
502impl BullCutter {
503    /// Radius of the flat centre and of the torus ring.
504    pub fn flat_radius(&self) -> f64 {
505        self.radius - self.corner_radius
506    }
507}
508
509impl CutterProfile for BullCutter {
510    fn kind(&self) -> CutterKind {
511        CutterKind::Bull
512    }
513    fn radius(&self) -> f64 {
514        self.radius
515    }
516    fn cutting_length(&self) -> f64 {
517        self.shank.cutting_length
518    }
519    fn shaft_length(&self) -> f64 {
520        self.shank.shaft_length
521    }
522    fn shaft_radius(&self) -> f64 {
523        self.shank.shaft_radius.unwrap_or(self.radius)
524    }
525    fn profile_height(&self) -> f64 {
526        self.corner_radius
527    }
528    fn height_at_radius(&self, r: f64) -> Option<f64> {
529        if !in_radius(r, self.radius) {
530            return None;
531        }
532        let rf = self.flat_radius();
533        if r <= rf {
534            return Some(0.0);
535        }
536        let rc = self.corner_radius;
537        let d = r - rf;
538        Some(rc - (rc * rc - d * d).max(0.0).sqrt())
539    }
540    fn radius_at_height(&self, h: f64) -> Option<f64> {
541        if !in_height(h, self.shank.cutting_length) {
542            return None;
543        }
544        let rc = self.corner_radius;
545        if h >= rc {
546            return Some(self.radius);
547        }
548        Some(self.flat_radius() + (rc * rc - (rc - h) * (rc - h)).max(0.0).sqrt())
549    }
550    fn segments(&self) -> Vec<ProfileSegment> {
551        let rf = self.flat_radius();
552        vec![
553            ProfileSegment {
554                kind: SegmentKind::Flat,
555                radius_range: [0.0, rf],
556                height_range: [0.0, 0.0],
557                profile_radius: 0.0,
558                ring_radius: 0.0,
559                half_angle_rad: 0.0,
560                z_offset: 0.0,
561            },
562            ProfileSegment {
563                kind: SegmentKind::Torus,
564                radius_range: [rf, self.radius],
565                height_range: [0.0, self.corner_radius],
566                profile_radius: self.corner_radius,
567                ring_radius: rf,
568                half_angle_rad: 0.0,
569                z_offset: self.corner_radius,
570            },
571            self.shank.cylinder_segment(self.radius, self.corner_radius),
572        ]
573    }
574    fn validate(&self) -> Result<(), CutterError> {
575        check_positive("diameter", self.radius)?;
576        if !self.corner_radius.is_finite() {
577            return Err(CutterError::NonFinite { field: "corner radius" });
578        }
579        if self.corner_radius <= 0.0 || self.corner_radius >= self.radius {
580            return Err(CutterError::CornerRadiusOutOfRange {
581                corner_radius: self.corner_radius,
582                radius: self.radius,
583            });
584        }
585        self.shank.validate()?;
586        if self.shank.cutting_length < self.corner_radius {
587            return Err(CutterError::NotPositive { field: "cutting length above the corner" });
588        }
589        Ok(())
590    }
591}
592
593/// Cone / V-bit: apex at the reference point, half-angle `half_angle_rad`.
594#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
595#[serde(rename_all = "camelCase")]
596pub struct ConeCutter {
597    /// Maximum cutting radius (where the cone meets the shank).
598    pub radius: f64,
599    pub included_angle_deg: f64,
600    pub shank: Shank,
601}
602
603impl ConeCutter {
604    pub fn half_angle_rad(&self) -> f64 {
605        self.included_angle_deg.to_radians() * 0.5
606    }
607
608    /// Height of the cone from apex to the maximum radius.
609    pub fn cone_height(&self) -> f64 {
610        self.radius / self.half_angle_rad().tan()
611    }
612}
613
614impl CutterProfile for ConeCutter {
615    fn kind(&self) -> CutterKind {
616        CutterKind::Cone
617    }
618    fn radius(&self) -> f64 {
619        self.radius
620    }
621    fn cutting_length(&self) -> f64 {
622        self.shank.cutting_length
623    }
624    fn shaft_length(&self) -> f64 {
625        self.shank.shaft_length
626    }
627    fn shaft_radius(&self) -> f64 {
628        self.shank.shaft_radius.unwrap_or(self.radius)
629    }
630    fn profile_height(&self) -> f64 {
631        self.cone_height()
632    }
633    fn height_at_radius(&self, r: f64) -> Option<f64> {
634        if !in_radius(r, self.radius) {
635            return None;
636        }
637        Some(r / self.half_angle_rad().tan())
638    }
639    fn radius_at_height(&self, h: f64) -> Option<f64> {
640        if !in_height(h, self.shank.cutting_length) {
641            return None;
642        }
643        Some((h * self.half_angle_rad().tan()).min(self.radius))
644    }
645    fn segments(&self) -> Vec<ProfileSegment> {
646        // A cone taller than the cutting length is truncated there: the
647        // profile ends at the flute end, below the maximum radius.
648        let hc = self.cone_height().min(self.shank.cutting_length);
649        let top_radius = (hc * self.half_angle_rad().tan()).min(self.radius);
650        vec![
651            ProfileSegment {
652                kind: SegmentKind::Cone,
653                radius_range: [0.0, top_radius],
654                height_range: [0.0, hc],
655                profile_radius: 0.0,
656                ring_radius: 0.0,
657                half_angle_rad: self.half_angle_rad(),
658                z_offset: 0.0,
659            },
660            self.shank.cylinder_segment(top_radius, hc),
661        ]
662    }
663    fn validate(&self) -> Result<(), CutterError> {
664        check_positive("maximum diameter", self.radius)?;
665        if !self.included_angle_deg.is_finite() {
666            return Err(CutterError::NonFinite { field: "included angle" });
667        }
668        if self.included_angle_deg <= 0.0 || self.included_angle_deg >= 180.0 {
669            return Err(CutterError::IncludedAngleOutOfRange { included_angle_deg: self.included_angle_deg });
670        }
671        self.shank.validate()
672    }
673}
674
675/// Ball-to-conical compound cutter: a ball nose of radius `ball_radius`
676/// joined tangentially to a cone of half-angle `included_angle_deg / 2` that
677/// widens to `radius`; shank above.
678#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
679#[serde(rename_all = "camelCase")]
680pub struct BallConeCutter {
681    pub ball_radius: f64,
682    /// Maximum cutting radius at the top of the cone.
683    pub radius: f64,
684    pub included_angle_deg: f64,
685    pub shank: Shank,
686}
687
688impl BallConeCutter {
689    pub fn half_angle_rad(&self) -> f64 {
690        self.included_angle_deg.to_radians() * 0.5
691    }
692
693    /// Radius where the cone leaves the ball tangentially: `Rb · cos(A)`.
694    pub fn tangent_radius(&self) -> f64 {
695        self.ball_radius * self.half_angle_rad().cos()
696    }
697
698    /// Ball height at the tangent radius.
699    pub fn tangent_height(&self) -> f64 {
700        let rb = self.ball_radius;
701        let rt = self.tangent_radius();
702        rb - (rb * rb - rt * rt).max(0.0).sqrt()
703    }
704
705    /// Apex height of the cone extended to the axis (negative: below the tip).
706    pub fn cone_apex_z(&self) -> f64 {
707        self.tangent_height() - self.tangent_radius() / self.half_angle_rad().tan()
708    }
709
710    /// Height where the cone reaches the maximum radius.
711    pub fn cone_top_height(&self) -> f64 {
712        self.cone_apex_z() + self.radius / self.half_angle_rad().tan()
713    }
714
715    fn is_plain_ball(&self) -> bool {
716        self.radius <= self.ball_radius
717    }
718}
719
720impl CutterProfile for BallConeCutter {
721    fn kind(&self) -> CutterKind {
722        CutterKind::BallCone
723    }
724    fn radius(&self) -> f64 {
725        self.radius
726    }
727    fn cutting_length(&self) -> f64 {
728        self.shank.cutting_length
729    }
730    fn shaft_length(&self) -> f64 {
731        self.shank.shaft_length
732    }
733    fn shaft_radius(&self) -> f64 {
734        self.shank.shaft_radius.unwrap_or(self.radius)
735    }
736    fn profile_height(&self) -> f64 {
737        if self.is_plain_ball() {
738            self.ball_radius
739        } else {
740            self.cone_top_height()
741        }
742    }
743    fn height_at_radius(&self, r: f64) -> Option<f64> {
744        if !in_radius(r, self.radius) {
745            return None;
746        }
747        let rb = self.ball_radius;
748        if self.is_plain_ball() || r <= self.tangent_radius() {
749            return Some(rb - (rb * rb - r * r).max(0.0).sqrt());
750        }
751        Some(self.cone_apex_z() + r / self.half_angle_rad().tan())
752    }
753    fn radius_at_height(&self, h: f64) -> Option<f64> {
754        if !in_height(h, self.shank.cutting_length) {
755            return None;
756        }
757        let rb = self.ball_radius;
758        if self.is_plain_ball() {
759            return Some(if h >= rb { rb } else { (rb * rb - (rb - h) * (rb - h)).max(0.0).sqrt() });
760        }
761        if h <= self.tangent_height() {
762            return Some((rb * rb - (rb - h) * (rb - h)).max(0.0).sqrt());
763        }
764        Some(((h - self.cone_apex_z()) * self.half_angle_rad().tan()).min(self.radius))
765    }
766    fn segments(&self) -> Vec<ProfileSegment> {
767        let rb = self.ball_radius;
768        if self.is_plain_ball() {
769            return vec![
770                ProfileSegment {
771                    kind: SegmentKind::Sphere,
772                    radius_range: [0.0, rb],
773                    height_range: [0.0, rb],
774                    profile_radius: rb,
775                    ring_radius: 0.0,
776                    half_angle_rad: 0.0,
777                    z_offset: rb,
778                },
779                self.shank.cylinder_segment(rb, rb),
780            ];
781        }
782        let rt = self.tangent_radius();
783        let ht = self.tangent_height();
784        // The cone is truncated at the cutting length when it is taller.
785        let top = self.cone_top_height().min(self.shank.cutting_length).max(ht);
786        let top_radius = ((top - self.cone_apex_z()) * self.half_angle_rad().tan()).clamp(rt, self.radius);
787        vec![
788            ProfileSegment {
789                kind: SegmentKind::Sphere,
790                radius_range: [0.0, rt],
791                height_range: [0.0, ht],
792                profile_radius: rb,
793                ring_radius: 0.0,
794                half_angle_rad: 0.0,
795                z_offset: rb,
796            },
797            ProfileSegment {
798                kind: SegmentKind::Cone,
799                radius_range: [rt, top_radius],
800                height_range: [ht, top],
801                profile_radius: 0.0,
802                ring_radius: 0.0,
803                half_angle_rad: self.half_angle_rad(),
804                z_offset: self.cone_apex_z(),
805            },
806            self.shank.cylinder_segment(top_radius, top),
807        ]
808    }
809    fn validate(&self) -> Result<(), CutterError> {
810        check_positive("ball diameter", self.ball_radius)?;
811        check_positive("maximum diameter", self.radius)?;
812        if self.ball_radius > self.radius {
813            return Err(CutterError::BallLargerThanMaximum {
814                ball_diameter: 2.0 * self.ball_radius,
815                maximum_diameter: 2.0 * self.radius,
816            });
817        }
818        if !self.included_angle_deg.is_finite() {
819            return Err(CutterError::NonFinite { field: "included angle" });
820        }
821        if self.included_angle_deg <= 0.0 || self.included_angle_deg >= 180.0 {
822            return Err(CutterError::IncludedAngleOutOfRange { included_angle_deg: self.included_angle_deg });
823        }
824        self.shank.validate()
825    }
826}
827
828/// Any of the five cutters, with static dispatch over the profile trait.
829#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
830#[serde(tag = "kind", rename_all = "kebab-case")]
831pub enum Cutter {
832    Flat(FlatCutter),
833    Ball(BallCutter),
834    Bull(BullCutter),
835    Cone(ConeCutter),
836    BallCone(BallConeCutter),
837    Frustum(FrustumCutter),
838    Lollipop(LollipopCutter),
839    Dovetail(DovetailCutter),
840}
841
842impl Cutter {
843    pub fn flat(diameter: f64, cutting_length: f64, shaft_length: f64) -> Self {
844        Cutter::Flat(FlatCutter { radius: 0.5 * diameter, shank: Shank::new(cutting_length, shaft_length) })
845    }
846
847    pub fn ball(diameter: f64, cutting_length: f64, shaft_length: f64) -> Self {
848        Cutter::Ball(BallCutter { radius: 0.5 * diameter, shank: Shank::new(cutting_length, shaft_length) })
849    }
850
851    pub fn bull(diameter: f64, corner_radius: f64, cutting_length: f64, shaft_length: f64) -> Self {
852        Cutter::Bull(BullCutter {
853            radius: 0.5 * diameter,
854            corner_radius,
855            shank: Shank::new(cutting_length, shaft_length),
856        })
857    }
858
859    pub fn cone(maximum_diameter: f64, included_angle_deg: f64, cutting_length: f64, shaft_length: f64) -> Self {
860        Cutter::Cone(ConeCutter {
861            radius: 0.5 * maximum_diameter,
862            included_angle_deg,
863            shank: Shank::new(cutting_length, shaft_length),
864        })
865    }
866
867    pub fn ball_cone(
868        ball_diameter: f64,
869        maximum_diameter: f64,
870        included_angle_deg: f64,
871        cutting_length: f64,
872        shaft_length: f64,
873    ) -> Self {
874        Cutter::BallCone(BallConeCutter {
875            ball_radius: 0.5 * ball_diameter,
876            radius: 0.5 * maximum_diameter,
877            included_angle_deg,
878            shank: Shank::new(cutting_length, shaft_length),
879        })
880    }
881
882    /// Default included angle of a hole-making tool kind when the geometry has none.
883    pub fn default_point_angle_deg(kind: ToolKind) -> Option<f64> {
884        match kind {
885            ToolKind::Drill => Some(118.0),
886            ToolKind::SpotDrill => Some(90.0),
887            ToolKind::CenterDrill => Some(60.0),
888            _ => None,
889        }
890    }
891
892    /// The validated profile of a tool, or why it has none.
893    ///
894    /// Flat, tap, reamer, boring bar, thread mill, slot and T-slot cutters are
895    /// cylinders (slot cutters on their arbor through `shaft_diameter`); a face
896    /// mill is a bull when it has a corner radius; drills, spot and centre drills
897    /// are cones with their point angle (118°, 90°, 60° unless given); V-bits and
898    /// engravers are cones; a chamfer or tapered flat mill with a tip diameter is
899    /// a frustum, without one a cone; a tapered ball is a ball-cone; a lollipop
900    /// and a dovetail need `shaft_diameter` as their neck. Lathe inserts have no
901    /// milling profile.
902    pub fn from_tool(tool: &Tool) -> Result<Cutter, CutterError> {
903        let g = &tool.geometry;
904        let mut shank = Shank::new(g.cutting_length, g.shaft_length);
905        if let Some(d) = g.shaft_diameter {
906            shank.shaft_radius = Some(0.5 * d);
907        }
908        let angle = |field: &'static str| -> Result<f64, CutterError> {
909            g.included_angle_deg
910                .or_else(|| Self::default_point_angle_deg(tool.kind))
911                .ok_or(CutterError::MissingField { kind: tool.kind, field })
912        };
913        let neck = || -> Result<f64, CutterError> {
914            g.shaft_diameter.map(|d| 0.5 * d).ok_or(CutterError::MissingField { kind: tool.kind, field: "shaft diameter (neck)" })
915        };
916        let cutter = match tool.kind {
917            ToolKind::Flat
918            | ToolKind::Tap
919            | ToolKind::Reamer
920            | ToolKind::BoringBar
921            | ToolKind::ThreadMill
922            | ToolKind::Slot
923            | ToolKind::TSlot => Cutter::Flat(FlatCutter { radius: 0.5 * g.diameter, shank }),
924            ToolKind::FaceMill => match g.corner_radius {
925                Some(corner_radius) if corner_radius > 0.0 => {
926                    Cutter::Bull(BullCutter { radius: 0.5 * g.diameter, corner_radius, shank })
927                }
928                _ => Cutter::Flat(FlatCutter { radius: 0.5 * g.diameter, shank }),
929            },
930            ToolKind::Ball => Cutter::Ball(BallCutter { radius: 0.5 * g.diameter, shank }),
931            ToolKind::Bull => {
932                let corner_radius = g.corner_radius.ok_or(CutterError::MissingField {
933                    kind: tool.kind,
934                    field: "corner radius",
935                })?;
936                Cutter::Bull(BullCutter { radius: 0.5 * g.diameter, corner_radius, shank })
937            }
938            ToolKind::Drill | ToolKind::SpotDrill | ToolKind::CenterDrill | ToolKind::VBit | ToolKind::Engraver => {
939                Cutter::Cone(ConeCutter { radius: 0.5 * g.diameter, included_angle_deg: angle("included angle")?, shank })
940            }
941            ToolKind::Chamfer | ToolKind::TaperedFlat => {
942                let included_angle_deg = angle("included angle")?;
943                match g.tip_diameter {
944                    Some(tip) if tip > 0.0 => Cutter::Frustum(FrustumCutter {
945                        tip_radius: 0.5 * tip,
946                        radius: 0.5 * g.diameter,
947                        included_angle_deg,
948                        shank,
949                    }),
950                    _ => Cutter::Cone(ConeCutter { radius: 0.5 * g.diameter, included_angle_deg, shank }),
951                }
952            }
953            ToolKind::Lollipop => {
954                Cutter::Lollipop(LollipopCutter { ball_radius: 0.5 * g.diameter, neck_radius: neck()?, shank })
955            }
956            ToolKind::Dovetail => Cutter::Dovetail(DovetailCutter {
957                radius: 0.5 * g.diameter,
958                neck_radius: neck()?,
959                included_angle_deg: angle("included angle")?,
960                shank,
961            }),
962            ToolKind::TaperedBall => {
963                let included_angle_deg = g.included_angle_deg.ok_or(CutterError::MissingField {
964                    kind: tool.kind,
965                    field: "included angle",
966                })?;
967                let ball_diameter = g.ball_diameter.unwrap_or(g.diameter);
968                Cutter::BallCone(BallConeCutter {
969                    ball_radius: 0.5 * ball_diameter,
970                    radius: 0.5 * g.diameter,
971                    included_angle_deg,
972                    shank,
973                })
974            }
975            other => return Err(CutterError::UnsupportedToolKind { kind: other }),
976        };
977        cutter.validate()?;
978        Ok(cutter)
979    }
980
981    fn profile(&self) -> &dyn CutterProfile {
982        match self {
983            Cutter::Flat(c) => c,
984            Cutter::Ball(c) => c,
985            Cutter::Bull(c) => c,
986            Cutter::Cone(c) => c,
987            Cutter::BallCone(c) => c,
988            Cutter::Frustum(c) => c,
989            Cutter::Lollipop(c) => c,
990            Cutter::Dovetail(c) => c,
991        }
992    }
993
994    pub fn frustum(tip_diameter: f64, diameter: f64, included_angle_deg: f64, cutting_length: f64, shaft_length: f64) -> Self {
995        Cutter::Frustum(FrustumCutter {
996            tip_radius: 0.5 * tip_diameter,
997            radius: 0.5 * diameter,
998            included_angle_deg,
999            shank: Shank::new(cutting_length, shaft_length),
1000        })
1001    }
1002
1003    pub fn lollipop(ball_diameter: f64, neck_diameter: f64, cutting_length: f64, shaft_length: f64) -> Self {
1004        Cutter::Lollipop(LollipopCutter {
1005            ball_radius: 0.5 * ball_diameter,
1006            neck_radius: 0.5 * neck_diameter,
1007            shank: Shank::new(cutting_length, shaft_length),
1008        })
1009    }
1010
1011    pub fn dovetail(diameter: f64, neck_diameter: f64, included_angle_deg: f64, cutting_length: f64, shaft_length: f64) -> Self {
1012        Cutter::Dovetail(DovetailCutter {
1013            radius: 0.5 * diameter,
1014            neck_radius: 0.5 * neck_diameter,
1015            included_angle_deg,
1016            shank: Shank::new(cutting_length, shaft_length),
1017        })
1018    }
1019}
1020
1021impl CutterProfile for Cutter {
1022    fn kind(&self) -> CutterKind {
1023        self.profile().kind()
1024    }
1025    fn radius(&self) -> f64 {
1026        self.profile().radius()
1027    }
1028    fn cutting_length(&self) -> f64 {
1029        self.profile().cutting_length()
1030    }
1031    fn shaft_length(&self) -> f64 {
1032        self.profile().shaft_length()
1033    }
1034    fn shaft_radius(&self) -> f64 {
1035        self.profile().shaft_radius()
1036    }
1037    fn profile_height(&self) -> f64 {
1038        self.profile().profile_height()
1039    }
1040    fn height_at_radius(&self, r: f64) -> Option<f64> {
1041        self.profile().height_at_radius(r)
1042    }
1043    fn radius_at_height(&self, h: f64) -> Option<f64> {
1044        self.profile().radius_at_height(h)
1045    }
1046    fn max_radius_at_height(&self, h: f64) -> Option<f64> {
1047        self.profile().max_radius_at_height(h)
1048    }
1049    fn segments(&self) -> Vec<ProfileSegment> {
1050        self.profile().segments()
1051    }
1052    fn validate(&self) -> Result<(), CutterError> {
1053        self.profile().validate()?;
1054        check_segment_joins(&self.profile().segments(), &crate::numeric::Tolerance::default())
1055    }
1056}
1057