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