Skip to main content

brep_cam/model/
machine.rs

1//! Machine definitions: kind, kinematics and limits.
2
3use super::{MachineId, Point3};
4use serde::{Deserialize, Serialize};
5
6/// A machine tool the setup is posted for.
7#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct Machine {
10    pub id: MachineId,
11    #[serde(default)]
12    pub name: String,
13    #[serde(default)]
14    pub kind: MachineKind,
15    /// Linear travel limits in machine coordinates (`None`: unlimited).
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub travel: Option<TravelLimits>,
18    #[serde(default)]
19    pub spindle: SpindleLimits,
20    #[serde(default)]
21    pub feed: FeedLimits,
22    /// Identifier of the post-processor script this machine uses.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub post: Option<String>,
25    /// Seconds a tool change takes, for cycle-time estimates.
26    #[serde(default = "default_tool_change_seconds")]
27    pub tool_change_seconds: f64,
28    /// Collision geometry: solids attached to the chain's links (lane C10c/C17).
29    #[serde(default, skip_serializing_if = "MachineModel::is_empty")]
30    pub collision: MachineModel,
31}
32
33fn default_tool_change_seconds() -> f64 {
34    5.0
35}
36
37impl Default for Machine {
38    fn default() -> Self {
39        Self {
40            id: MachineId::default(),
41            name: String::new(),
42            kind: MachineKind::default(),
43            travel: None,
44            spindle: SpindleLimits::default(),
45            feed: FeedLimits::default(),
46            post: None,
47            tool_change_seconds: default_tool_change_seconds(),
48            collision: MachineModel::default(),
49        }
50    }
51}
52
53/// A rigid link of the machine that solids attach to (the kinematics chain
54/// resolves its pose).
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
57pub enum Link {
58    /// The bed and column: never moves.
59    Base,
60    /// The link carried by joint `index` of the chain (table or head).
61    Joint { index: usize },
62    /// The part (and its fixture and stock): the innermost table joint.
63    Part,
64    /// The spindle (and tool): the innermost head joint plus the linear axes.
65    Tool,
66}
67
68/// Shape of a machine solid in its link's frame. Geometric queries
69/// (containment, surface sampling) live with the simulation.
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
72pub enum LinkShape {
73    Box { min: Point3, max: Point3 },
74    Cylinder { base: Point3, axis: Point3, radius: f64, height: f64 },
75}
76
77impl LinkShape {
78    pub fn validate(&self) -> Result<(), String> {
79        match self {
80            LinkShape::Box { min, max } => {
81                if !min.is_finite() || !max.is_finite() || min.x >= max.x || min.y >= max.y || min.z >= max.z {
82                    return Err("machine solid: box needs finite min < max on every axis".into());
83                }
84            }
85            LinkShape::Cylinder { base, axis, radius, height } => {
86                if !base.is_finite()
87                    || axis.normalized().is_none()
88                    || !(*radius > 0.0)
89                    || !(*height > 0.0)
90                    || !radius.is_finite()
91                    || !height.is_finite()
92                {
93                    return Err("machine solid: cylinder needs a finite base, a non-zero axis and positive radius and height".into());
94                }
95            }
96        }
97        Ok(())
98    }
99}
100
101/// One machine solid: a shape attached to a link.
102#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct LinkSolid {
105    pub name: String,
106    pub link: Link,
107    pub shape: LinkShape,
108}
109
110/// The machine's collision geometry.
111#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113#[serde(default)]
114pub struct MachineModel {
115    pub solids: Vec<LinkSolid>,
116}
117
118impl MachineModel {
119    pub fn is_empty(&self) -> bool {
120        self.solids.is_empty()
121    }
122
123    pub fn validate(&self) -> Result<(), String> {
124        for solid in &self.solids {
125            if solid.name.is_empty() {
126                return Err("machine solid needs a name".into());
127            }
128            solid.shape.validate().map_err(|m| format!("{m} ({})", solid.name))?;
129        }
130        Ok(())
131    }
132}
133
134/// Machine family and, for multi-axis mills, the rotary arrangement.
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
137#[non_exhaustive]
138pub enum MachineKind {
139    /// Three linear axes.
140    Mill3,
141    /// Three linear axes plus one rotary (wrap/indexing).
142    Mill4 { rotary: RotaryAxis },
143    /// Five-axis mill with the given kinematic arrangement.
144    Mill5 { kinematics: Kinematics },
145    /// Two-axis lathe (X/Z), optional C and live tooling later.
146    Lathe,
147    /// Lathe with a C axis (and optionally Y) for live-tool milling (lane C11b).
148    /// `c_axis` must be named `C`, point along +Z and sit on the spindle axis.
149    MillTurn { c_axis: RotaryAxis, y_axis: bool },
150}
151
152impl Default for MachineKind {
153    fn default() -> Self {
154        MachineKind::Mill3
155    }
156}
157
158/// Where the two rotary axes of a five-axis machine live.
159#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
161pub enum Kinematics {
162    /// Both rotaries carry the part (trunnion). `primary` carries `secondary`.
163    TableTable { primary: RotaryAxis, secondary: RotaryAxis },
164    /// Both rotaries carry the spindle.
165    HeadHead { primary: RotaryAxis, secondary: RotaryAxis },
166    /// One rotary on the head (tilts the tool), one on the table (turns the part).
167    HeadTable { head: RotaryAxis, table: RotaryAxis },
168}
169
170/// A rotary axis: its name, direction and pivot in machine coordinates.
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct RotaryAxis {
174    /// Axis letter the post emits (`A`, `B`, `C`).
175    pub name: String,
176    /// Unit direction of the rotation axis.
177    pub direction: Point3,
178    /// A point on the axis.
179    #[serde(default)]
180    pub origin: Point3,
181    /// Angular limits in degrees (`None`: continuous).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub limits_deg: Option<[f64; 2]>,
184}
185
186/// Linear travel limits in machine coordinates.
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct TravelLimits {
190    pub min: Point3,
191    pub max: Point3,
192}
193
194/// Spindle speed range in rpm.
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197#[serde(default)]
198pub struct SpindleLimits {
199    pub min_rpm: f64,
200    pub max_rpm: f64,
201}
202
203impl Default for SpindleLimits {
204    fn default() -> Self {
205        Self { min_rpm: 0.0, max_rpm: 24_000.0 }
206    }
207}
208
209/// Feed limits in model units per minute.
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212#[serde(default)]
213pub struct FeedLimits {
214    /// Maximum cutting feed.
215    pub max_cutting: f64,
216    /// Rapid traverse rate (used for cycle-time estimates).
217    pub rapid: f64,
218    /// Axis acceleration in model units per second squared, for cycle-time
219    /// estimates (`None`: instantaneous).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub acceleration: Option<f64>,
222}
223
224impl Default for FeedLimits {
225    fn default() -> Self {
226        Self { max_cutting: 10_000.0, rapid: 30_000.0, acceleration: None }
227    }
228}
229
230impl Machine {
231    pub fn new(id: impl Into<MachineId>, name: impl Into<String>, kind: MachineKind) -> Self {
232        Self { id: id.into(), name: name.into(), kind, ..Default::default() }
233    }
234
235    pub fn validate(&self) -> Result<(), String> {
236        if !(self.spindle.max_rpm >= self.spindle.min_rpm) || self.spindle.min_rpm < 0.0 {
237            return Err("spindle limits must satisfy 0 <= min <= max".into());
238        }
239        if !(self.feed.max_cutting > 0.0) || !(self.feed.rapid > 0.0) {
240            return Err("feed limits must be positive".into());
241        }
242        if let Some(a) = self.feed.acceleration {
243            if !a.is_finite() || a <= 0.0 {
244                return Err("acceleration must be finite and positive".into());
245            }
246        }
247        if !self.tool_change_seconds.is_finite() || self.tool_change_seconds < 0.0 {
248            return Err("tool change time must be finite and non-negative".into());
249        }
250        self.collision.validate()?;
251        if let Some(t) = &self.travel {
252            if !t.min.is_finite() || !t.max.is_finite() {
253                return Err("travel limits are not finite".into());
254            }
255            if t.min.x > t.max.x || t.min.y > t.max.y || t.min.z > t.max.z {
256                return Err("travel limits must have min <= max".into());
257            }
258        }
259        if let MachineKind::MillTurn { c_axis, .. } = &self.kind {
260            if !matches!(c_axis.name.as_str(), "C" | "c") {
261                return Err("mill-turn C axis must be named C".into());
262            }
263            if c_axis.direction.normalized().map_or(true, |d| d.distance(Point3::Z) > 1e-9) {
264                return Err("mill-turn C axis must point along +Z".into());
265            }
266            if c_axis.origin.x.abs() > 1e-9 || c_axis.origin.y.abs() > 1e-9 {
267                return Err("mill-turn C axis origin must lie on the spindle axis (x = y = 0)".into());
268            }
269        }
270        let axes: Vec<&RotaryAxis> = match &self.kind {
271            MachineKind::Mill3 | MachineKind::Lathe => Vec::new(),
272            MachineKind::MillTurn { c_axis, .. } => vec![c_axis],
273            MachineKind::Mill4 { rotary } => vec![rotary],
274            MachineKind::Mill5 { kinematics } => match kinematics {
275                Kinematics::TableTable { primary, secondary }
276                | Kinematics::HeadHead { primary, secondary } => vec![primary, secondary],
277                Kinematics::HeadTable { head, table } => vec![head, table],
278            },
279        };
280        for axis in axes {
281            axis.validate()?;
282        }
283        Ok(())
284    }
285}
286
287impl RotaryAxis {
288    pub fn validate(&self) -> Result<(), String> {
289        if self.name.is_empty() {
290            return Err("rotary axis has no name".into());
291        }
292        if self.direction.normalized().is_none() || !self.origin.is_finite() {
293            return Err(format!("rotary axis {} has an invalid direction or origin", self.name));
294        }
295        if let Some([lo, hi]) = self.limits_deg {
296            if !lo.is_finite() || !hi.is_finite() || lo > hi {
297                return Err(format!("rotary axis {} has invalid limits", self.name));
298            }
299        }
300        Ok(())
301    }
302}