1use super::{MachineId, Point3};
4use serde::{Deserialize, Serialize};
5
6#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub post: Option<String>,
26 #[serde(default = "default_tool_change_seconds")]
28 pub tool_change_seconds: f64,
29 #[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#[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 Base,
62 Joint { index: usize },
64 Part,
66 Tool,
68}
69
70#[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#[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#[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#[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 Mill3,
147 Mill4 { rotary: RotaryAxis },
149 Mill5 { kinematics: Kinematics },
151 Lathe,
153 MillTurn { c_axis: RotaryAxis, y_axis: bool },
156}
157
158impl Default for MachineKind {
159 fn default() -> Self {
160 MachineKind::Mill3
161 }
162}
163
164#[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 TableTable { primary: RotaryAxis, secondary: RotaryAxis },
171 HeadHead { primary: RotaryAxis, secondary: RotaryAxis },
173 HeadTable { head: RotaryAxis, table: RotaryAxis },
175}
176
177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct RotaryAxis {
182 pub name: String,
184 pub direction: Point3,
186 #[serde(default)]
188 pub origin: Point3,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub limits_deg: Option<[f64; 2]>,
192}
193
194#[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#[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#[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 pub max_cutting: f64,
227 pub rapid: f64,
229 #[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}