Skip to main content

brep_cam/cycle_time/
mod.rs

1//! Cycle-time estimate from the motion stream.
2//!
3//! Every motion is timed from its geometry and feed: rapids at the machine's
4//! rapid rate, feed moves at their feed (capped by the machine's maximum
5//! cutting feed), arcs by their true arc length, hole cycles as the plunge to
6//! depth and the retract (pecks add their retracts), dwells by their seconds,
7//! tool changes by the machine's tool-change time. With an acceleration limit
8//! each move is a trapezoidal (or triangular) velocity profile from rest to
9//! rest, which is conservative: real controllers blend corners. Times are in
10//! seconds; feeds are per minute.
11
12use crate::model::{ArcPlane, Cycle, Feed, Machine, Motion, MotionKind, Point3};
13use serde::{Deserialize, Serialize};
14
15/// Time of one motion.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct MotionTime {
19    pub seconds: f64,
20    /// Path length travelled by the tool reference point.
21    pub length: f64,
22    pub cutting: bool,
23}
24
25/// The estimate for a motion stream.
26#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct CycleTime {
29    pub total_seconds: f64,
30    pub cutting_seconds: f64,
31    pub rapid_seconds: f64,
32    pub dwell_seconds: f64,
33    pub tool_change_seconds: f64,
34    pub cutting_length: f64,
35    pub rapid_length: f64,
36    pub tool_changes: u32,
37    /// One entry per motion, in order.
38    pub per_motion: Vec<MotionTime>,
39}
40
41/// Seconds to travel `length` at `feed_per_minute`, from rest to rest under an
42/// optional acceleration limit (units/s²): trapezoidal when the cruise speed is
43/// reached, triangular otherwise.
44pub fn move_seconds(length: f64, feed_per_minute: f64, acceleration: Option<f64>) -> f64 {
45    if !(length > 0.0) || !(feed_per_minute > 0.0) {
46        return 0.0;
47    }
48    let v = feed_per_minute / 60.0;
49    match acceleration {
50        Some(a) if a > 0.0 && a.is_finite() => {
51            let ramp = v * v / a; // distance to accelerate and decelerate
52            if length >= ramp {
53                length / v + v / a
54            } else {
55                2.0 * (length / a).sqrt()
56            }
57        }
58        _ => length / v,
59    }
60}
61
62/// Arc length of an XY arc from `start` to `end` about `center` in the given
63/// sense, including the helical rise (a full circle when start and end
64/// coincide in XY; `pitch` fixes the turns of a helix).
65pub fn arc_length(start: Point3, end: Point3, center: Point3, clockwise: bool, pitch: f64) -> f64 {
66    use std::f64::consts::TAU;
67    let r0 = ((start.x - center.x).powi(2) + (start.y - center.y).powi(2)).sqrt();
68    let r1 = ((end.x - center.x).powi(2) + (end.y - center.y).powi(2)).sqrt();
69    let radius = 0.5 * (r0 + r1);
70    let a0 = (start.y - center.y).atan2(start.x - center.x);
71    let a1 = (end.y - center.y).atan2(end.x - center.x);
72    let mut sweep = (if clockwise { a0 - a1 } else { a1 - a0 }).rem_euclid(TAU);
73    if sweep < 1e-12 {
74        sweep = TAU;
75    }
76    let dz = end.z - start.z;
77    if pitch.is_finite() && pitch.abs() > 0.0 && dz.abs() > 0.0 {
78        sweep = TAU * dz.abs() / pitch.abs();
79    }
80    let planar = radius * sweep;
81    (planar * planar + dz * dz).sqrt()
82}
83
84/// Estimate the time of `motions` on `machine`. The first positional motion
85/// places the tool (no time); unknown feeds fall back to the machine limits.
86pub fn estimate_cycle_time(motions: &[Motion], machine: &Machine) -> CycleTime {
87    let mut out = CycleTime { per_motion: Vec::with_capacity(motions.len()), ..Default::default() };
88    let rapid_rate = machine.feed.rapid;
89    let max_cut = machine.feed.max_cutting;
90    let accel = machine.feed.acceleration;
91    let mut position: Option<Point3> = None;
92    for motion in motions {
93        let mut mt = MotionTime::default();
94        match &motion.kind {
95            MotionKind::Dwell { seconds } => {
96                mt.seconds = seconds.max(0.0);
97                out.dwell_seconds += mt.seconds;
98            }
99            MotionKind::ToolChange { .. } => {
100                mt.seconds = machine.tool_change_seconds.max(0.0);
101                out.tool_change_seconds += mt.seconds;
102                out.tool_changes += 1;
103            }
104            MotionKind::Cycle { at, cycle } => {
105                // Travel to the hole (rapid), plunge to depth at feed, retract at rapid, pecks added.
106                let feed = feed_rate(motion.feed, max_cut).unwrap_or(max_cut);
107                if let Some(p) = position {
108                    let travel = p.distance(*at);
109                    let t = move_seconds(travel, rapid_rate, accel);
110                    mt.seconds += t;
111                    mt.length += travel;
112                    out.rapid_seconds += t;
113                    out.rapid_length += travel;
114                }
115                let retract_z = cycle.retract_z().max(at.z);
116                let depth_z = match cycle {
117                    Cycle::BackBore { entry_z, .. } => *entry_z,
118                    other => other.depth_z(),
119                };
120                let depth = (retract_z - depth_z).abs();
121                let (mut feed_len, mut rapid_len) = (depth, depth);
122                match cycle {
123                    Cycle::Peck { peck, .. } if *peck > 0.0 => {
124                        let pecks = (depth / peck).ceil().max(1.0);
125                        // Each peck but the last retracts fully and rapids back down to the previous depth.
126                        for k in 1..pecks as usize {
127                            let reached = (k as f64 * peck).min(depth);
128                            rapid_len += 2.0 * reached;
129                        }
130                    }
131                    Cycle::ChipBreak { peck, back_off, .. } if *peck > 0.0 => {
132                        let pecks = (depth / peck).ceil().max(1.0);
133                        let back = if *back_off > 0.0 { *back_off } else { peck * 0.5 };
134                        rapid_len += 2.0 * back * (pecks - 1.0);
135                        feed_len += back * (pecks - 1.0);
136                    }
137                    Cycle::Ream { .. } | Cycle::Tap { .. } => {
138                        // Feed out as well.
139                        feed_len += depth;
140                        rapid_len -= depth;
141                    }
142                    Cycle::Bore { feed_out: true, .. } => {
143                        feed_len += depth;
144                        rapid_len -= depth;
145                    }
146                    _ => {}
147                }
148                let dwell = match cycle {
149                    Cycle::Drill { dwell_seconds, .. } | Cycle::Bore { dwell_seconds, .. } => dwell_seconds.max(0.0),
150                    _ => 0.0,
151                };
152                let tf = move_seconds(feed_len, feed, accel);
153                let tr = move_seconds(rapid_len.max(0.0), rapid_rate, accel);
154                mt.seconds += tf + tr + dwell;
155                mt.length += feed_len + rapid_len.max(0.0);
156                mt.cutting = true;
157                out.cutting_seconds += tf;
158                out.cutting_length += feed_len;
159                out.rapid_seconds += tr;
160                out.rapid_length += rapid_len.max(0.0);
161                out.dwell_seconds += dwell;
162                position = Some(Point3::new(at.x, at.y, retract_z));
163            }
164            kind => {
165                if let Some(end) = kind.end() {
166                    if let Some(start) = position {
167                        let length = match kind {
168                            MotionKind::ArcCw { to, center, plane, pitch } | MotionKind::ArcCcw { to, center, plane, pitch } => {
169                                if *plane == ArcPlane::Xy {
170                                    arc_length(start, *to, *center, matches!(kind, MotionKind::ArcCw { .. }), *pitch)
171                                } else {
172                                    start.distance(*to)
173                                }
174                            }
175                            _ => start.distance(end),
176                        };
177                        let is_rapid = matches!(motion.feed, Feed::Rapid(_));
178                        let rate = if is_rapid { rapid_rate } else { feed_rate(motion.feed, max_cut).unwrap_or(max_cut) };
179                        mt.seconds = move_seconds(length, rate, accel);
180                        mt.length = length;
181                        mt.cutting = kind.is_cutting();
182                        if mt.cutting {
183                            out.cutting_seconds += mt.seconds;
184                            out.cutting_length += length;
185                        } else {
186                            out.rapid_seconds += mt.seconds;
187                            out.rapid_length += length;
188                        }
189                    }
190                    position = Some(end);
191                }
192            }
193        }
194        out.total_seconds += mt.seconds;
195        out.per_motion.push(mt);
196    }
197    out
198}
199
200/// A feed move's rate per minute, capped by the machine; `None` for rapids.
201fn feed_rate(feed: Feed, max_cutting: f64) -> Option<f64> {
202    match feed {
203        Feed::Rapid(_) => None,
204        Feed::Rate(r) if r.is_finite() && r > 0.0 => Some(r.min(max_cutting)),
205        Feed::Rate(_) => Some(max_cutting),
206    }
207}
208