1use 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#[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 Frustum,
35 Lollipop,
37 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#[derive(Clone, Debug, PartialEq)]
64pub enum CutterError {
65 NonFinite { field: &'static str },
66 NotPositive { field: &'static str },
67 CornerRadiusOutOfRange { corner_radius: f64, radius: f64 },
69 IncludedAngleOutOfRange { included_angle_deg: f64 },
71 BallLargerThanMaximum { ball_diameter: f64, maximum_diameter: f64 },
73 UnsupportedToolKind { kind: ToolKind },
75 MissingField { kind: ToolKind, field: &'static str },
77 JoinGap { radius: f64, gap: f64 },
79 JoinNotTangent { radius: f64, sine: f64 },
81 TipNotSmallerThanDiameter { tip_diameter: f64, diameter: f64 },
83 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub enum SegmentKind {
131 Flat,
133 Sphere,
135 Torus,
137 Cone,
139 Cylinder,
141 SphereUpper,
144 ConeInverted,
148}
149
150#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct ProfileSegment {
154 pub kind: SegmentKind,
155 pub radius_range: [f64; 2],
157 pub height_range: [f64; 2],
159 pub profile_radius: f64,
161 pub ring_radius: f64,
163 pub half_angle_rad: f64,
165 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 pub const EQUATOR_SNAP: f64 = 1e-9;
185
186 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
223pub 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 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
259pub trait CutterProfile: Sync {
263 fn kind(&self) -> CutterKind;
264 fn radius(&self) -> f64;
266 fn diameter(&self) -> f64 {
267 2.0 * self.radius()
268 }
269 fn cutting_length(&self) -> f64;
271 fn shaft_length(&self) -> f64;
273 fn shaft_radius(&self) -> f64 {
275 self.radius()
276 }
277 fn profile_height(&self) -> f64;
279 fn height_at_radius(&self, r: f64) -> Option<f64>;
281 fn radius_at_height(&self, h: f64) -> Option<f64>;
283 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 fn segments(&self) -> Vec<ProfileSegment>;
298 fn validate(&self) -> Result<(), CutterError>;
300 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#[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 #[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#[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#[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#[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 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
595#[serde(rename_all = "camelCase")]
596pub struct ConeCutter {
597 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
679#[serde(rename_all = "camelCase")]
680pub struct BallConeCutter {
681 pub ball_radius: f64,
682 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 pub fn tangent_radius(&self) -> f64 {
695 self.ball_radius * self.half_angle_rad().cos()
696 }
697
698 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 pub fn cone_apex_z(&self) -> f64 {
707 self.tangent_height() - self.tangent_radius() / self.half_angle_rad().tan()
708 }
709
710 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 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#[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 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 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