Skip to main content

brep_cam/model/
tool.rs

1//! Tools, holders, feeds and speeds.
2
3use super::{HolderId, ToolId};
4use serde::{Deserialize, Serialize};
5
6/// A cutting tool as the user defines it. Geometry is a flat parameter set
7/// (`None` where the kind does not use a field); [`crate::cutter::Cutter`]
8/// turns it into a validated profile for the algorithms.
9#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct Tool {
12    pub id: ToolId,
13    /// Tool number in the machine's carousel (`T` word).
14    #[serde(default)]
15    pub number: u32,
16    #[serde(default)]
17    pub name: String,
18    #[serde(default)]
19    pub description: String,
20    #[serde(default)]
21    pub kind: ToolKind,
22    #[serde(default)]
23    pub geometry: ToolGeometry,
24    /// Explicit insert/blade geometry for turning; never inferred from a mill diameter.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub turning_geometry: Option<crate::turning::TurningGeometry>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub holder: Option<Holder>,
29    #[serde(default)]
30    pub coolant: Coolant,
31    #[serde(default)]
32    pub feeds: FeedsSpeeds,
33    /// Length offset register (`H` word), when it differs from `number`.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub length_offset: Option<u32>,
36    /// Diameter offset register (`D` word), when it differs from `number`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub diameter_offset: Option<u32>,
39    /// Material this tool is made of, free text (carbide, HSS, ...).
40    #[serde(default, skip_serializing_if = "String::is_empty")]
41    pub material: String,
42}
43
44impl Tool {
45    pub fn new(id: impl Into<ToolId>, number: u32, kind: ToolKind, geometry: ToolGeometry) -> Self {
46        Self { id: id.into(), number, kind, geometry, ..Default::default() }
47    }
48
49    pub fn radius(&self) -> f64 {
50        self.geometry.diameter * 0.5
51    }
52}
53
54/// Tool families from the capability matrix.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "kebab-case")]
57#[non_exhaustive]
58pub enum ToolKind {
59    /// Flat end mill.
60    #[default]
61    Flat,
62    /// Ball nose end mill.
63    Ball,
64    /// Bull nose / corner-radius end mill.
65    Bull,
66    /// Chamfer mill (conical, uses `included_angle_deg`, optional tip diameter).
67    Chamfer,
68    /// V-bit / engraving cone (conical to a point).
69    VBit,
70    /// Tapered mill with a ball end (ball-cone compound).
71    TaperedBall,
72    /// Tapered mill with a flat end.
73    TaperedFlat,
74    /// Lollipop (undercut) cutter.
75    Lollipop,
76    /// Face mill (large flat, indexable).
77    FaceMill,
78    /// Slot cutter (side-and-face).
79    Slot,
80    /// T-slot cutter.
81    TSlot,
82    /// Dovetail cutter.
83    Dovetail,
84    /// Engraver (fine V point).
85    Engraver,
86    /// Thread mill.
87    ThreadMill,
88    /// Twist drill.
89    Drill,
90    /// Spot drill.
91    SpotDrill,
92    /// Centre drill.
93    CenterDrill,
94    /// Tap (right-hand unless `left_hand`).
95    Tap,
96    /// Reamer.
97    Reamer,
98    /// Boring bar (milling).
99    BoringBar,
100    /// Lathe insert tool (turning).
101    LatheInsert,
102}
103
104impl ToolKind {
105    /// Stable lowercase name, the same string serde writes.
106    pub fn as_str(&self) -> &'static str {
107        match self {
108            ToolKind::Flat => "flat",
109            ToolKind::Ball => "ball",
110            ToolKind::Bull => "bull",
111            ToolKind::Chamfer => "chamfer",
112            ToolKind::VBit => "v-bit",
113            ToolKind::TaperedBall => "tapered-ball",
114            ToolKind::TaperedFlat => "tapered-flat",
115            ToolKind::Lollipop => "lollipop",
116            ToolKind::FaceMill => "face-mill",
117            ToolKind::Slot => "slot",
118            ToolKind::TSlot => "t-slot",
119            ToolKind::Dovetail => "dovetail",
120            ToolKind::Engraver => "engraver",
121            ToolKind::ThreadMill => "thread-mill",
122            ToolKind::Drill => "drill",
123            ToolKind::SpotDrill => "spot-drill",
124            ToolKind::CenterDrill => "center-drill",
125            ToolKind::Tap => "tap",
126            ToolKind::Reamer => "reamer",
127            ToolKind::BoringBar => "boring-bar",
128            ToolKind::LatheInsert => "lathe-insert",
129        }
130    }
131
132    /// Hole-making tools that run canned cycles rather than milling paths.
133    pub fn is_hole_tool(&self) -> bool {
134        matches!(
135            self,
136            ToolKind::Drill
137                | ToolKind::SpotDrill
138                | ToolKind::CenterDrill
139                | ToolKind::Tap
140                | ToolKind::Reamer
141                | ToolKind::BoringBar
142        )
143    }
144}
145
146/// Tool dimensions in model units. Angles in degrees.
147#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ToolGeometry {
150    /// Maximum cutting diameter.
151    pub diameter: f64,
152    /// Usable cutting (flute) length.
153    pub cutting_length: f64,
154    /// Shank diameter above the flutes (`None`: equals `diameter`).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub shaft_diameter: Option<f64>,
157    /// Shank length above the cutting length used for collision checks.
158    #[serde(default = "default_shaft_length")]
159    pub shaft_length: f64,
160    /// Overall length from tip to holder gauge face (`None`: cutting + shaft).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub overall_length: Option<f64>,
163    /// Corner radius (bull nose). `None` or `0` for sharp corners.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub corner_radius: Option<f64>,
166    /// Included tip angle for cones, chamfers, V-bits, drills and spot drills.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub included_angle_deg: Option<f64>,
169    /// Ball diameter of a tapered ball (ball-cone) tool; `None` means the full diameter.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub ball_diameter: Option<f64>,
172    /// Tip (small-end) diameter of a chamfer or tapered flat tool.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub tip_diameter: Option<f64>,
175    /// Thread pitch (taps and thread mills).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub thread_pitch: Option<f64>,
178    /// Number of flutes/teeth; feeds per tooth multiply by it.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub flute_count: Option<u32>,
181    /// Left-hand cutting direction/thread.
182    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183    pub left_hand: bool,
184}
185
186fn default_shaft_length() -> f64 {
187    30.0
188}
189
190impl Default for ToolGeometry {
191    fn default() -> Self {
192        Self {
193            diameter: 6.0,
194            cutting_length: 20.0,
195            shaft_diameter: None,
196            shaft_length: default_shaft_length(),
197            overall_length: None,
198            corner_radius: None,
199            included_angle_deg: None,
200            ball_diameter: None,
201            tip_diameter: None,
202            thread_pitch: None,
203            flute_count: None,
204            left_hand: false,
205        }
206    }
207}
208
209impl ToolGeometry {
210    pub fn flat(diameter: f64, cutting_length: f64) -> Self {
211        Self { diameter, cutting_length, ..Default::default() }
212    }
213
214    pub fn shaft_diameter(&self) -> f64 {
215        self.shaft_diameter.unwrap_or(self.diameter)
216    }
217
218    pub fn overall_length(&self) -> f64 {
219        self.overall_length.unwrap_or(self.cutting_length + self.shaft_length)
220    }
221}
222
223/// A tool holder as stacked frusta from the tool side (bottom) upward.
224#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct Holder {
227    #[serde(default)]
228    pub id: HolderId,
229    #[serde(default)]
230    pub name: String,
231    /// Bottom segment first.
232    #[serde(default)]
233    pub segments: Vec<HolderSegment>,
234}
235
236/// One frustum of a holder profile.
237#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct HolderSegment {
240    pub height: f64,
241    /// Diameter at the bottom (tool side) of this segment.
242    pub lower_diameter: f64,
243    /// Diameter at the top of this segment.
244    pub upper_diameter: f64,
245}
246
247impl Holder {
248    /// Total holder height (sum of segments).
249    pub fn height(&self) -> f64 {
250        self.segments.iter().map(|s| s.height).sum()
251    }
252
253    /// Holder radius at `h` above the holder's bottom face, `None` above the holder.
254    pub fn radius_at_height(&self, h: f64) -> Option<f64> {
255        if h < 0.0 {
256            return None;
257        }
258        let mut base = 0.0;
259        for s in &self.segments {
260            if h <= base + s.height {
261                let t = if s.height > 0.0 { (h - base) / s.height } else { 0.0 };
262                return Some(0.5 * (s.lower_diameter + t * (s.upper_diameter - s.lower_diameter)));
263            }
264            base += s.height;
265        }
266        None
267    }
268
269    pub fn validate(&self) -> Result<(), String> {
270        for (i, s) in self.segments.iter().enumerate() {
271            let finite = s.height.is_finite() && s.lower_diameter.is_finite() && s.upper_diameter.is_finite();
272            if !finite {
273                return Err(format!("holder segment {i} has a non-finite dimension"));
274            }
275            if s.height <= 0.0 || s.lower_diameter <= 0.0 || s.upper_diameter <= 0.0 {
276                return Err(format!("holder segment {i} must have positive dimensions"));
277            }
278        }
279        Ok(())
280    }
281}
282
283/// Coolant request posted with the tool.
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
285#[serde(rename_all = "kebab-case")]
286pub enum Coolant {
287    #[default]
288    Off,
289    Flood,
290    Mist,
291    ThroughTool,
292    Air,
293    FloodAndThroughTool,
294    FloodAndMist,
295}
296
297/// Spindle rotation sense.
298#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
299#[serde(rename_all = "kebab-case")]
300pub enum SpindleDirection {
301    #[default]
302    Cw,
303    Ccw,
304}
305
306impl SpindleDirection {
307    /// Serde names of every variant, in declaration order.
308    pub const VARIANTS: &'static [&'static str] = &["cw", "ccw"];
309}
310
311/// Default feeds and speeds for a tool. Feeds in model units per minute.
312#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct FeedsSpeeds {
315    pub spindle_rpm: f64,
316    #[serde(default)]
317    pub spindle_direction: SpindleDirection,
318    pub cutting_feed: f64,
319    pub plunge_feed: f64,
320    /// Lead-in/lead-out feed (`None`: cutting feed).
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub lead_feed: Option<f64>,
323    /// Ramp feed (`None`: cutting feed).
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub ramp_feed: Option<f64>,
326    /// Retract feed when retracting at feed (`None`: rapid).
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub retract_feed: Option<f64>,
329    /// Surface speed the rpm was derived from, informational.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub surface_speed: Option<f64>,
332    /// Feed per tooth the cutting feed was derived from, informational.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub feed_per_tooth: Option<f64>,
335    /// Maximum rpm under constant surface speed (turning, G50); required when
336    /// `css` is on.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub surface_speed_limit_rpm: Option<f64>,
339    /// Constant surface speed (G96) for turning: an explicit choice. Off, the
340    /// spindle runs at `spindle_rpm` and `surface_speed` is the informational
341    /// cutting speed (milling Vc).
342    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
343    pub css: bool,
344}
345
346impl Default for FeedsSpeeds {
347    fn default() -> Self {
348        Self {
349            spindle_rpm: 10_000.0,
350            spindle_direction: SpindleDirection::Cw,
351            cutting_feed: 1_000.0,
352            plunge_feed: 300.0,
353            lead_feed: None,
354            ramp_feed: None,
355            retract_feed: None,
356            surface_speed: None,
357            feed_per_tooth: None,
358            surface_speed_limit_rpm: None,
359            css: false,
360        }
361    }
362}
363
364impl FeedsSpeeds {
365    pub fn lead_feed(&self) -> f64 {
366        self.lead_feed.unwrap_or(self.cutting_feed)
367    }
368
369    pub fn ramp_feed(&self) -> f64 {
370        self.ramp_feed.unwrap_or(self.cutting_feed)
371    }
372
373    pub fn validate(&self) -> Result<(), String> {
374        let fields = [
375            ("spindle rpm", self.spindle_rpm, false),
376            ("cutting feed", self.cutting_feed, true),
377            ("plunge feed", self.plunge_feed, true),
378        ];
379        for (name, v, positive) in fields {
380            if !v.is_finite() || v < 0.0 || (positive && v == 0.0) {
381                return Err(format!("{name} must be finite and positive"));
382            }
383        }
384        for (name, v) in [
385            ("lead feed", self.lead_feed),
386            ("ramp feed", self.ramp_feed),
387            ("retract feed", self.retract_feed),
388        ] {
389            if let Some(v) = v {
390                if !v.is_finite() || v <= 0.0 {
391                    return Err(format!("{name} must be finite and positive"));
392                }
393            }
394        }
395        if let Some(v) = self.surface_speed {
396            if !v.is_finite() || v <= 0.0 {
397                return Err("surface speed must be finite and positive".into());
398            }
399        }
400        if let Some(max) = self.surface_speed_limit_rpm {
401            if !max.is_finite() || max <= 0.0 {
402                return Err("surface speed limit rpm must be finite and positive".into());
403            }
404        }
405        if self.css {
406            if self.surface_speed.is_none() {
407                return Err("constant surface speed needs a surface speed".into());
408            }
409            if self.surface_speed_limit_rpm.is_none() {
410                return Err("constant surface speed needs a finite positive maximum rpm".into());
411            }
412        }
413        Ok(())
414    }
415}