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