1use super::{MachineId, Point3};
4use serde::{Deserialize, Serialize};
5
6#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub post: Option<String>,
25 #[serde(default = "default_tool_change_seconds")]
27 pub tool_change_seconds: f64,
28 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
57pub enum Link {
58 Base,
60 Joint { index: usize },
62 Part,
64 Tool,
66}
67
68#[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#[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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
137#[non_exhaustive]
138pub enum MachineKind {
139 Mill3,
141 Mill4 { rotary: RotaryAxis },
143 Mill5 { kinematics: Kinematics },
145 Lathe,
147 MillTurn { c_axis: RotaryAxis, y_axis: bool },
150}
151
152impl Default for MachineKind {
153 fn default() -> Self {
154 MachineKind::Mill3
155 }
156}
157
158#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
161pub enum Kinematics {
162 TableTable { primary: RotaryAxis, secondary: RotaryAxis },
164 HeadHead { primary: RotaryAxis, secondary: RotaryAxis },
166 HeadTable { head: RotaryAxis, table: RotaryAxis },
168}
169
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct RotaryAxis {
174 pub name: String,
176 pub direction: Point3,
178 #[serde(default)]
180 pub origin: Point3,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub limits_deg: Option<[f64; 2]>,
184}
185
186#[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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212#[serde(default)]
213pub struct FeedLimits {
214 pub max_cutting: f64,
216 pub rapid: f64,
218 #[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}