Skip to main content

brep_cam/collision/
mod.rs

1//! Collision checks during simulation: shank and holder against stock and
2//! protected bodies, and any non-cutting move (rapid, link, retract) that
3//! passes through material.
4//!
5//! The check runs in time order over each move with the Z-dexel stock: at a
6//! sample parameter `t` the tool's envelope ([`ToolProfile`]) is compared
7//! against the material of every column under its footprint, minus what the
8//! move's own cutting band has removed up to `t` (the flutes reach a point
9//! before the shank does on a descent, so that material is not a collision).
10//! The first colliding sample is refined by bisection against the last clear
11//! one, so a plunge with a too-short holder reports the depth where the
12//! holder meets the stock. Protected bodies are checked the same way against
13//! their own grid, with no removal credit. After the check the move is cut
14//! exactly ([`crate::stock_sim::removal`]).
15
16use crate::cancel::{Cancelled, Runner};
17use crate::cutter::{Cutter, CutterProfile, ToolPart, ToolProfile};
18use crate::model::{Bounds3, Cycle, Motion, MotionKind, Point3};
19use crate::numeric::Tolerance;
20use crate::stock_sim::frame::RotaryFrame;
21use crate::stock_sim::removal::{arc_chords_in_plane, cut_back_bore, cut_linear, refuse, RemovalOptions, SimulationReport};
22use crate::stock_sim::DexelGrid;
23use serde::{Deserialize, Serialize};
24
25/// Which part of the tool touched.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum CollisionPart {
29    /// The cutting part met material on a move that does not cut (rapid, link, retract).
30    CuttingOnRapid,
31    Shank,
32    Holder,
33}
34
35/// What was touched.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum CollisionAgainst {
39    Stock,
40    Protected,
41}
42
43/// One reported contact: the first time a part of the tool met material
44/// during a motion.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Collision {
48    pub motion_index: usize,
49    pub part: CollisionPart,
50    pub against: CollisionAgainst,
51    /// Tool reference point at first contact (machine coordinates).
52    pub position: Point3,
53    /// Column centre and the lowest colliding material Z.
54    pub contact: Point3,
55}
56
57/// Collision check parameters.
58#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[serde(default)]
61pub struct CollisionOptions {
62    /// Stop simulating at the first collision (the stock stays as it was before that move).
63    pub stop_at_first: bool,
64    /// Sample spacing along a move (`0`: the stock grid's cell size).
65    pub sample_step: f64,
66    /// Check rapids, links and retracts for passing through material.
67    pub check_rapids: bool,
68    /// Bisection steps refining the first-contact parameter.
69    pub refine_steps: u32,
70}
71
72impl Default for CollisionOptions {
73    fn default() -> Self {
74        Self { stop_at_first: false, sample_step: 0.0, check_rapids: true, refine_steps: 30 }
75    }
76}
77
78/// Result of a checked simulation.
79#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct CheckedSimulation {
82    pub removal: SimulationReport,
83    pub collisions: Vec<Collision>,
84    /// Index of the motion the simulation stopped at (`stop_at_first`), if any.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub stopped_at: Option<usize>,
87}
88
89/// Material in `column` that the tool at reference `c` touches with the
90/// parts named by `bands` (absolute Z bands with their part), outside the
91/// credit band `credit` already removed by this move. An overlap no deeper
92/// than `tangent` is tangency, not contact. Returns the lowest colliding Z
93/// and its part.
94fn touch(column: &crate::stock_sim::Column, c_z: f64, bands: &[([f64; 2], ToolPart)], credit: Option<[f64; 2]>, tangent: f64) -> Option<(f64, ToolPart)> {
95    let mut best: Option<(f64, ToolPart)> = None;
96    for &([h0, h1], part) in bands {
97        let (z0, z1) = (c_z + h0, c_z + h1);
98        for &[a, b] in &column.intervals {
99            // Overlap of material [a, b] with the band [z0, z1].
100            let lo = a.max(z0);
101            let hi = b.min(z1);
102            if hi <= lo {
103                continue;
104            }
105            // Remove the credit band.
106            let pieces: Vec<[f64; 2]> = match credit {
107                Some([ca, cb]) if cb > lo && ca < hi => {
108                    let mut v = Vec::new();
109                    if lo < ca {
110                        v.push([lo, ca]);
111                    }
112                    if cb < hi {
113                        v.push([cb, hi]);
114                    }
115                    v
116                }
117                _ => vec![[lo, hi]],
118            };
119            for [p0, p1] in pieces {
120                if p1 - p0 > tangent && best.map_or(true, |(z, _)| p0 < z) {
121                    best = Some((p0, part));
122                }
123            }
124        }
125    }
126    best
127}
128
129/// Scan one move against `grid` in time order. `cutting` says whether the
130/// move removes material (then the cutting part is excluded and credited);
131/// `credit_cutter` is the cutter for the credit band. Returns the first
132/// collision (lowest material Z at the earliest colliding sample, refined).
133#[allow(clippy::too_many_arguments)]
134fn scan_move(
135    grid: &DexelGrid,
136    against: CollisionAgainst,
137    profile: &ToolProfile,
138    cutter: &Cutter,
139    cutting: bool,
140    start: Point3,
141    end: Point3,
142    motion_index: usize,
143    options: &CollisionOptions,
144    tol: &Tolerance,
145) -> Option<Collision> {
146    let r_max = profile.max_radius();
147    let footprint = Bounds3::from_points([start, end]).expanded(r_max);
148    let [ix0, ix1, iy0, iy1] = grid.columns_in(&footprint)?;
149    let d = end.sub(start);
150    let length = d.length();
151    let step = if options.sample_step > 0.0 { options.sample_step } else { grid.cell_x.min(grid.cell_y) };
152    let samples = if length > 0.0 { ((length / step).ceil() as usize).clamp(1, 1 << 20) } else { 1 };
153    let cut_len = cutter.cutting_length();
154    let cut_radius = cutter.radius();
155    // Per-column state: prefix cut credit [floor, top] accumulated along the move.
156    let ncols = ((ix1 - ix0 + 1) * (iy1 - iy0 + 1)) as usize;
157    let mut credit: Vec<Option<[f64; 2]>> = vec![None; ncols];
158    // At the START of a move the tool sits where the previous move left it,
159    // and that move cut every column it touched down to the tool's own
160    // surface. The tool envelope and the cut surface agree there only to
161    // round-off (C19-17/18: a tapered flank 4e-17 into the column it just
162    // finished), so an overlap no deeper than `distance_eps` at t = 0 is
163    // tangency. Every later sample, and the bisection, stay strict: a move
164    // that drags into material is flagged as soon as it is any deeper.
165    let start_tangent = tol.distance_eps.max(0.0);
166    let collides_at = |t: f64, credit: &mut [Option<[f64; 2]>], update_credit: bool| -> Option<(f64, ToolPart, Point3)> {
167        let tangent = if t == 0.0 { start_tangent } else { 0.0 };
168        let c = start.add(d.scale(t));
169        let mut hit: Option<(f64, ToolPart, Point3)> = None;
170        let mut k = 0;
171        for iy in iy0..=iy1 {
172            for ix in ix0..=ix1 {
173                let (x, y) = grid.center(ix, iy);
174                let r = ((x - c.x).powi(2) + (y - c.y).powi(2)).sqrt();
175                let column = grid.column(ix, iy);
176                let slot = k;
177                k += 1;
178                if column.is_empty() || r > r_max {
179                    continue;
180                }
181                // Removal credit from this move up to now.
182                if cutting && update_credit && r <= cut_radius {
183                    let floor = c.z + cutter.height_at_radius(r).unwrap_or(0.0);
184                    let top = c.z + cut_len;
185                    credit[slot] = Some(match credit[slot] {
186                        Some([f, tp]) => [f.min(floor), tp.max(top)],
187                        None => [floor, top],
188                    });
189                }
190                let bands = profile.height_bands_reaching(r);
191                let bands: Vec<([f64; 2], ToolPart)> = if cutting {
192                    bands.into_iter().filter_map(|([h0, h1], part)| {
193                        let lo = h0.max(cut_len);
194                        (h1 > lo).then_some(([lo, h1], part))
195                    }).collect()
196                } else {
197                    bands
198                };
199                if let Some((z, part)) = touch(column, c.z, &bands, if cutting { credit[slot] } else { None }, tangent) {
200                    if hit.map_or(true, |(hz, _, _)| z < hz) {
201                        hit = Some((z, part, Point3::new(x, y, z)));
202                    }
203                }
204            }
205        }
206        hit
207    };
208    let mut last_clear = None;
209    for k in 0..=samples {
210        let t = k as f64 / samples as f64;
211        if let Some((_, part, contact)) = collides_at(t, &mut credit, true) {
212            // Refine between the last clear sample and this one (credit frozen).
213            let (mut lo, mut hi) = (last_clear.unwrap_or(t), t);
214            let mut best = (part, contact);
215            if last_clear.is_some() {
216                let mut frozen = credit.clone();
217                for _ in 0..options.refine_steps {
218                    let mid = 0.5 * (lo + hi);
219                    match collides_at(mid, &mut frozen, false) {
220                        Some((_, p, c)) => {
221                            hi = mid;
222                            best = (p, c);
223                        }
224                        None => lo = mid,
225                    }
226                    if (hi - lo) * length <= 1e-3 * tol.distance_eps {
227                        break;
228                    }
229                }
230            }
231            let position = start.add(d.scale(hi));
232            let part = match best.0 {
233                ToolPart::Cutting => CollisionPart::CuttingOnRapid,
234                ToolPart::Shank => CollisionPart::Shank,
235                ToolPart::Holder => CollisionPart::Holder,
236            };
237            return Some(Collision { motion_index, part, against, position, contact: best.1 });
238        }
239        last_clear = Some(t);
240    }
241    None
242}
243
244/// Run a motion stream through the stock with collision checks. Every move is
245/// checked against the stock as it is BEFORE the move (with the move's own
246/// cutting credited) and against `protected`, then cut. With
247/// `stop_at_first` the stock is left as it was before the colliding move.
248/// Without a machine, a nonzero rotary position or a synchronized rotary move
249/// is refused by name; use [`simulate_checked_on_machine`] for streams that
250/// index or rotate the part.
251#[allow(clippy::too_many_arguments)]
252pub fn simulate_checked(
253    grid: &mut DexelGrid,
254    protected: Option<&DexelGrid>,
255    cutter: &Cutter,
256    profile: &ToolProfile,
257    motions: &[Motion],
258    tol: &Tolerance,
259    removal: &RemovalOptions,
260    options: &CollisionOptions,
261    runner: &mut Runner<'_>,
262) -> Result<CheckedSimulation, Cancelled> {
263    checked_impl(grid, protected, cutter, profile, motions, tol, removal, options, None, runner)
264}
265
266/// [`simulate_checked`] on a machine: rotary positioning is applied through
267/// `chain`, so checks and cuts happen in the stock's part frame (collision
268/// positions are reported in that frame too). `protected` is a grid in the
269/// same part frame (fixtures that rotate with the part, such as a chuck).
270#[allow(clippy::too_many_arguments)]
271pub fn simulate_checked_on_machine(
272    grid: &mut DexelGrid,
273    protected: Option<&DexelGrid>,
274    cutter: &Cutter,
275    profile: &ToolProfile,
276    motions: &[Motion],
277    tol: &Tolerance,
278    removal: &RemovalOptions,
279    options: &CollisionOptions,
280    chain: &crate::kinematics::Chain,
281    runner: &mut Runner<'_>,
282) -> Result<CheckedSimulation, Cancelled> {
283    checked_impl(grid, protected, cutter, profile, motions, tol, removal, options, Some(chain), runner)
284}
285
286#[allow(clippy::too_many_arguments)]
287fn checked_impl(
288    grid: &mut DexelGrid,
289    protected: Option<&DexelGrid>,
290    cutter: &Cutter,
291    profile: &ToolProfile,
292    motions: &[Motion],
293    tol: &Tolerance,
294    removal: &RemovalOptions,
295    options: &CollisionOptions,
296    chain: Option<&crate::kinematics::Chain>,
297    runner: &mut Runner<'_>,
298) -> Result<CheckedSimulation, Cancelled> {
299    let mut out = CheckedSimulation { removal: SimulationReport { motions: motions.len() as u64, ..Default::default() }, ..Default::default() };
300    let mut frame = RotaryFrame::new(chain);
301    let mut position: Option<Point3> = None;
302    'motions: for (index, motion) in motions.iter().enumerate() {
303        runner.stage("stock-simulation", "checking and cutting motion", index as u64, motions.len() as u64)?;
304        let Some(end) = motion.end() else {
305            frame.commit(&motion.kind);
306            continue;
307        };
308        let Some(start) = position else {
309            position = Some(end);
310            frame.commit(&motion.kind);
311            continue;
312        };
313        if let MotionKind::Cycle { at, cycle: Cycle::BackBore { entry_z, depth_z, .. } } = &motion.kind {
314            if !at.is_finite() || !entry_z.is_finite() || !depth_z.is_finite()
315                || entry_z >= depth_z || cutter.kind() != crate::cutter::CutterKind::Flat
316            {
317                refuse(&mut out.removal, index, "back boring requires finite upward depths and a flat cutting profile".into());
318                position = Some(end);
319                frame.commit(&motion.kind);
320                continue;
321            }
322            // Transform both cutting-edge endpoints: an offset machine frame can
323            // change Z as well as the bore's XY location.
324            let world = [(Point3::new(at.x, at.y, *entry_z), Point3::new(at.x, at.y, *depth_z))];
325            let part = match frame.to_part(motion, &world, removal.arc_chord, tol) {
326                Ok(part) => part,
327                Err(reason) => {
328                    refuse(&mut out.removal, index, reason);
329                    position = Some(end);
330                    frame.commit(&motion.kind);
331                    continue;
332                }
333            };
334            refuse(&mut out.removal, index, "asymmetric back-bore head entry/exit clearance is not modeled".into());
335            for (entry, finished) in part {
336                let cut = cut_back_bore(grid, cutter.radius(), entry, entry.z, finished.z, runner)?;
337                out.removal.removed_volume += cut.removed_volume;
338                out.removal.columns_touched += cut.columns_touched;
339            }
340            out.removal.cutting_motions += 1;
341            out.removal.warnings.push(crate::model::Diagnostic::new("back-bore-access-not-certified", "G87 upward removal is simulated; asymmetric shifted head entry/exit clearance is not modeled"));
342            position = Some(end);
343            frame.commit(&motion.kind);
344            continue;
345        }
346        let cutting = matches!(
347            motion.kind,
348            MotionKind::Plunge { .. }
349                | MotionKind::Cut { .. }
350                | MotionKind::ArcCw { .. }
351                | MotionKind::ArcCcw { .. }
352                | MotionKind::LeadIn { .. }
353                | MotionKind::LeadOut { .. }
354                | MotionKind::Cycle { .. }
355                | MotionKind::LinearRotary { .. }
356        );
357        let world: Vec<(Point3, Point3)> = match &motion.kind {
358            MotionKind::ArcCw { to, center, plane, pitch } | MotionKind::ArcCcw { to, center, plane, pitch } => {
359                let clockwise = matches!(motion.kind, MotionKind::ArcCw { .. });
360                let mut prev = start;
361                arc_chords_in_plane(start, *to, *center, *plane, clockwise, *pitch, removal.arc_chord)
362                    .into_iter()
363                    .map(|p| {
364                        let seg = (prev, p);
365                        prev = p;
366                        seg
367                    })
368                    .collect()
369            }
370            MotionKind::Cycle { at, cycle } => {
371                let top = Point3::new(at.x, at.y, cycle.retract_z().max(at.z));
372                let depth = cycle.depth_z();
373                vec![(start, top), (top, Point3::new(at.x, at.y, depth))]
374            }
375            _ => vec![(start, end)],
376        };
377        let segments = match frame.to_part(motion, &world, removal.arc_chord, tol) {
378            Ok(part) => part,
379            Err(reason) => {
380                refuse(&mut out.removal, index, reason);
381                position = Some(end);
382                frame.commit(&motion.kind);
383                continue;
384            }
385        };
386        if cutting {
387            out.removal.cutting_motions += 1;
388        }
389        for (a, b) in segments {
390            if cutting || options.check_rapids {
391                let mut found: Vec<Collision> = Vec::new();
392                if let Some(c) = scan_move(grid, CollisionAgainst::Stock, profile, cutter, cutting, a, b, index, options, tol) {
393                    found.push(c);
394                }
395                if let Some(p) = protected {
396                    if let Some(c) = scan_move(p, CollisionAgainst::Protected, profile, cutter, false, a, b, index, options, tol) {
397                        found.push(c);
398                    }
399                }
400                if !found.is_empty() {
401                    out.collisions.extend(found);
402                    if options.stop_at_first {
403                        out.stopped_at = Some(index);
404                        break 'motions;
405                    }
406                }
407            }
408            if cutting {
409                let cut = cut_linear(grid, cutter, a, b, tol, removal, runner)?;
410                out.removal.removed_volume += cut.removed_volume;
411                out.removal.columns_touched += cut.columns_touched;
412                out.removal.unconverged_columns += cut.unconverged_columns;
413            }
414        }
415        position = Some(end);
416        frame.commit(&motion.kind);
417    }
418    Ok(out)
419}
420