Skip to main content

brep_cam/multiaxis/sim/
machine.rs

1//! Machine collision (lane C10c, row 2): simple solids (boxes, cylinders)
2//! attached to the links of the kinematic chain — head/spindle housing on
3//! the tool side, table, fixture and stock on the part side — checked
4//! along the resolved machine motion.
5//!
6//! Between consecutive resolved blocks the joints and linear axes are
7//! interpolated in machine space in steps bounded by `max_step_deg` and
8//! `max_step_linear`; at every sample each tool-side solid is tested
9//! against every part-side solid (surface samples of one inside the other,
10//! both directions, exact containment of a point in a box or cylinder) and
11//! against the stock (tri-dexel containment in the part frame). The first
12//! hit is reported with its block index, interpolation fraction, joint
13//! angles, the two bodies and the point. The controlled point of every
14//! block is derived from its tip in setup coordinates through the chain
15//! (TCP off), so the check is independent of the resolver's TCP mode and
16//! of work-plane sections. Surface sampling bounds the
17//! detection: an interpenetration shallower than `surface_spacing` between
18//! samples can be missed; the bound is reported.
19
20use crate::kinematics::{Chain, KinematicsOptions};
21use crate::model::{Point3, RigidTransform};
22use crate::multiaxis::sim::tridexel::TriDexel;
23use crate::kinematics::post::ResolvedMotion;
24use crate::{CamError, Runner};
25use serde::{Deserialize, Serialize};
26
27pub use crate::model::{Link, LinkShape, LinkSolid, MachineModel};
28
29/// Geometric queries on the model's machine shapes (the model holds only data).
30pub trait LinkShapeGeometry {
31    /// Containment of a point given in the link frame.
32    fn contains_local(&self, p: Point3) -> bool;
33    /// Surface sample points in the link frame, at most `spacing` apart,
34    /// including the edges and corners.
35    fn surface_samples(&self, spacing: f64) -> Vec<Point3>;
36}
37
38impl LinkShapeGeometry for LinkShape {
39    /// Containment of a point given in the link frame.
40    fn contains_local(&self, p: Point3) -> bool {
41        match self {
42            LinkShape::Box { min, max } => p.x >= min.x && p.x <= max.x && p.y >= min.y && p.y <= max.y && p.z >= min.z && p.z <= max.z,
43            LinkShape::Cylinder { base, axis, radius, height } => {
44                let a = axis.normalized().unwrap_or(Point3::Z);
45                let d = p.sub(*base);
46                let along = d.dot(a);
47                along >= 0.0 && along <= *height && d.sub(a.scale(along)).length() <= *radius
48            }
49        }
50    }
51
52    /// Surface sample points in the link frame, at most `spacing` apart,
53    /// including the edges and corners.
54    fn surface_samples(&self, spacing: f64) -> Vec<Point3> {
55        let steps = |len: f64| ((len / spacing).ceil() as usize).max(1);
56        let mut out = Vec::new();
57        match self {
58            LinkShape::Box { min, max } => {
59                let n = [steps(max.x - min.x), steps(max.y - min.y), steps(max.z - min.z)];
60                for i in 0..=n[0] {
61                    for j in 0..=n[1] {
62                        for k in 0..=n[2] {
63                            let on_face = i == 0 || i == n[0] || j == 0 || j == n[1] || k == 0 || k == n[2];
64                            if !on_face {
65                                continue;
66                            }
67                            out.push(Point3::new(
68                                min.x + (max.x - min.x) * i as f64 / n[0] as f64,
69                                min.y + (max.y - min.y) * j as f64 / n[1] as f64,
70                                min.z + (max.z - min.z) * k as f64 / n[2] as f64,
71                            ));
72                        }
73                    }
74                }
75            }
76            LinkShape::Cylinder { base, axis, radius, height } => {
77                let a = axis.normalized().unwrap_or(Point3::Z);
78                let e1 = crate::geom::perpendicular(a);
79                let e2 = a.cross(e1);
80                let nh = steps(*height);
81                let na = steps(std::f64::consts::TAU * radius).max(8);
82                let nr = steps(*radius);
83                for k in 0..=nh {
84                    let h = height * k as f64 / nh as f64;
85                    for i in 0..na {
86                        let phi = std::f64::consts::TAU * i as f64 / na as f64;
87                        out.push(base.add(a.scale(h)).add(e1.scale(radius * phi.cos())).add(e2.scale(radius * phi.sin())));
88                    }
89                }
90                for h in [0.0, *height] {
91                    for r in 0..nr {
92                        let rr = radius * r as f64 / nr as f64;
93                        let m = if r == 0 { 1 } else { na };
94                        for i in 0..m {
95                            let phi = std::f64::consts::TAU * i as f64 / m as f64;
96                            out.push(base.add(a.scale(h)).add(e1.scale(rr * phi.cos())).add(e2.scale(rr * phi.sin())));
97                        }
98                    }
99                }
100            }
101        }
102        out
103    }
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108#[serde(default)]
109pub struct MachineCheckOptions {
110    /// Surface sample spacing (the detection bound).
111    pub surface_spacing: f64,
112    /// Largest rotary change between samples of one block, degrees.
113    pub max_step_deg: f64,
114    /// Largest linear change between samples of one block.
115    pub max_step_linear: f64,
116    /// Work budget: point tests.
117    pub max_work: u64,
118}
119
120impl Default for MachineCheckOptions {
121    fn default() -> Self {
122        Self { surface_spacing: 0.5, max_step_deg: 2.0, max_step_linear: 5.0, max_work: 2_000_000_000 }
123    }
124}
125
126impl MachineCheckOptions {
127    pub fn validate(&self) -> Result<(), CamError> {
128        let bad = |m: &str| Err(CamError::InvalidInput(format!("machine check: {m}")));
129        if !self.surface_spacing.is_finite() || self.surface_spacing <= 0.0 {
130            return bad("surface spacing must be positive");
131        }
132        if !self.max_step_deg.is_finite() || self.max_step_deg <= 0.0 || !self.max_step_linear.is_finite() || self.max_step_linear <= 0.0 {
133            return bad("steps must be positive");
134        }
135        if self.max_work == 0 {
136            return bad("work budget must be positive");
137        }
138        Ok(())
139    }
140}
141
142/// The first collision found.
143#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct MachineCollision {
146    /// Index of the resolved block during which it happens (the sample lies
147    /// between block `index - 1` and block `index`; 0 for the first block's pose).
148    pub block: usize,
149    /// Interpolation fraction from the previous block to this one.
150    pub fraction: f64,
151    pub angles_deg: Vec<f64>,
152    pub linear: Point3,
153    /// Tool-side body.
154    pub body: String,
155    /// Part-side body, or `"stock"`.
156    pub against: String,
157    /// The offending point in machine coordinates.
158    pub point: Point3,
159}
160
161#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct MachineCheckReport {
164    pub first: Option<MachineCollision>,
165    pub samples: u64,
166    /// Point tests performed.
167    pub work: u64,
168    /// Detection bound: interpenetrations shallower than this may be missed.
169    pub bound: f64,
170}
171
172struct Placed<'a> {
173    solid: &'a LinkSolid,
174    to_world: RigidTransform,
175    to_local: RigidTransform,
176}
177
178fn is_tool_side(chain: &Chain, link: Link) -> bool {
179    match link {
180        Link::Tool => true,
181        Link::Joint { index } => chain.joints.get(index).map_or(false, |j| j.carrier == crate::kinematics::Carrier::Head),
182        Link::Base | Link::Part => false,
183    }
184}
185
186/// Check the resolved motion against the machine model and the stock.
187pub fn check_machine(
188    chain: &Chain,
189    model: &MachineModel,
190    blocks: &[ResolvedMotion],
191    stock: Option<&TriDexel>,
192    opts: &MachineCheckOptions,
193    runner: &mut Runner<'_>,
194) -> Result<MachineCheckReport, CamError> {
195    opts.validate()?;
196    for s in &model.solids {
197        s.shape.validate().map_err(CamError::InvalidInput)?;
198        if let Link::Joint { index } = s.link {
199            if index >= chain.joints.len() {
200                return Err(CamError::InvalidInput(format!("machine solid {} sits on joint {index}, which the chain does not have", s.name)));
201            }
202        }
203    }
204    let samples: Vec<Vec<Point3>> = model.solids.iter().map(|s| s.shape.surface_samples(opts.surface_spacing)).collect();
205    let tool_side: Vec<usize> = (0..model.solids.len()).filter(|&i| is_tool_side(chain, model.solids[i].link)).collect();
206    let part_side: Vec<usize> = (0..model.solids.len()).filter(|&i| !is_tool_side(chain, model.solids[i].link)).collect();
207    let mut report = MachineCheckReport { bound: opts.surface_spacing, ..Default::default() };
208    let mut work_left = opts.max_work;
209    let n_rot = chain.joints.len();
210    let mut prev: Option<(Point3, Vec<f64>)> = None;
211    let mut tip: Option<Point3> = None;
212    let off = KinematicsOptions { tcp: false, ..Default::default() };
213    runner.stage("machine-check", "Machine collision check", 0, blocks.len() as u64)?;
214    for (bi, b) in blocks.iter().enumerate() {
215        runner.work(1, "machine-check", bi as u64, blocks.len() as u64)?;
216        let angles: Vec<f64> = b.rotary.iter().map(|w| w.angle_deg).collect();
217        if angles.len() != n_rot {
218            return Err(CamError::InvalidInput(format!("block {bi} carries {} rotary words, the chain has {n_rot} joints", angles.len())));
219        }
220        // The controlled point from the block's tip (setup coordinates):
221        // independent of the resolver's TCP mode and of work planes, whose
222        // `linear` words are tip or plane-local coordinates.
223        if let Some(t) = b.motion.end() {
224            tip = Some(t);
225        }
226        let Some(t) = tip else { continue };
227        let linear = chain.linear_for(t, &angles, &off);
228        let (steps, from) = match &prev {
229            None => (1usize, (linear, angles.clone())),
230            Some((pl, pa)) => {
231                let rot = pa.iter().zip(&angles).map(|(a, c)| (c - a).abs()).fold(0.0, f64::max);
232                let lin = pl.distance(linear);
233                let n = ((rot / opts.max_step_deg).ceil().max((lin / opts.max_step_linear).ceil()) as usize).max(1);
234                (n, (*pl, pa.clone()))
235            }
236        };
237        let start_s = if prev.is_none() { 0 } else { 1 };
238        for s in start_s..=steps {
239            let f = s as f64 / steps as f64;
240            let lin = from.0.lerp(linear, f);
241            let ang: Vec<f64> = from.1.iter().zip(&angles).map(|(a, c)| a + (c - a) * f).collect();
242            report.samples += 1;
243            let placed: Vec<Placed<'_>> = model
244                .solids
245                .iter()
246                .map(|sld| {
247                    let t = chain.link_transform(sld.link, &ang, lin)?;
248                    Ok(Placed { solid: sld, to_local: t.inverse(), to_world: t })
249                })
250                .collect::<Result<_, crate::kinematics::KinematicsError>>()?;
251            let mut hit: Option<MachineCollision> = None;
252            'pairs: for &ti in &tool_side {
253                for &pi in &part_side {
254                    for (a, bb) in [(ti, pi), (pi, ti)] {
255                        let cost = samples[a].len() as u64;
256                        if work_left < cost {
257                            return Err(CamError::InvalidInput("machine check: work budget exceeded".into()));
258                        }
259                        work_left -= cost;
260                        report.work += cost;
261                        for p in &samples[a] {
262                            let w = placed[a].to_world.apply_point(*p);
263                            let q = placed[bb].to_local.apply_point(w);
264                            if placed[bb].solid.shape.contains_local(q) {
265                                hit = Some(MachineCollision {
266                                    block: bi,
267                                    fraction: f,
268                                    angles_deg: ang.clone(),
269                                    linear: lin,
270                                    body: model.solids[ti].name.clone(),
271                                    against: model.solids[pi].name.clone(),
272                                    point: w,
273                                });
274                                break 'pairs;
275                            }
276                        }
277                    }
278                }
279                if let Some(stock) = stock {
280                    let cost = samples[ti].len() as u64;
281                    if work_left < cost {
282                        return Err(CamError::InvalidInput("machine check: work budget exceeded".into()));
283                    }
284                    work_left -= cost;
285                    report.work += cost;
286                    for p in &samples[ti] {
287                        let w = placed[ti].to_world.apply_point(*p);
288                        let in_part = chain.world_to_part(&ang, w);
289                        if stock.contains(in_part) {
290                            hit = Some(MachineCollision {
291                                block: bi,
292                                fraction: f,
293                                angles_deg: ang.clone(),
294                                linear: lin,
295                                body: model.solids[ti].name.clone(),
296                                against: "stock".into(),
297                                point: w,
298                            });
299                            break 'pairs;
300                        }
301                    }
302                }
303            }
304            if hit.is_some() {
305                report.first = hit;
306                return Ok(report);
307            }
308        }
309        prev = Some((linear, angles));
310    }
311    Ok(report)
312}