Skip to main content

brep_cam/model/
setup.rs

1//! Setups: the rigid model-to-machine transform, work offset, stock and heights.
2
3use super::{Bounds3, MachineId, Point3, RigidTransform, SetupId, TargetRef};
4use serde::{Deserialize, Serialize};
5
6/// One machining setup: a fixturing of the part on the machine.
7///
8/// Every algorithm runs in MACHINE coordinates (cutter axis +Z, depths descend
9/// in Z). `model_to_machine` is the only transform CAM applies to geometry;
10/// the default aligns model +Z with machine +Z.
11#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Setup {
14    pub id: SetupId,
15    #[serde(default)]
16    pub name: String,
17    /// Rigid transform from model coordinates to machine coordinates.
18    #[serde(default)]
19    pub model_to_machine: RigidTransform,
20    /// Work coordinate system the post emits (G54..., or a fixture offset).
21    #[serde(default)]
22    pub wcs: WorkOffset,
23    /// Raw material before this setup's operations.
24    #[serde(default)]
25    pub stock: Stock,
26    /// Unclamped length measured back from the cylindrical stock's front.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub turning_stick_out: Option<f64>,
29    /// Clearance/retract/feed heights in MACHINE Z.
30    #[serde(default)]
31    pub heights: Heights,
32    /// Machine the setup is posted for; `None` falls back to a generic 3-axis mill.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub machine: Option<MachineId>,
35    /// Bodies being machined in this setup (whole bodies).
36    #[serde(default)]
37    pub targets: Vec<TargetRef>,
38    /// Fixtures, vise jaws and other protected bodies (never cut, always checked).
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub protected: Vec<TargetRef>,
41    /// Program identity for the post (number and/or comment).
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub program: Option<ProgramInfo>,
44}
45
46impl Setup {
47    pub fn new(id: impl Into<SetupId>, name: impl Into<String>) -> Self {
48        Self { id: id.into(), name: name.into(), ..Default::default() }
49    }
50
51    /// Machine-to-model transform for display conversion.
52    pub fn machine_to_model(&self) -> RigidTransform {
53        self.model_to_machine.inverse()
54    }
55}
56
57/// The work offset the post program uses.
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[serde(default)]
61pub struct WorkOffset {
62    /// Work offset number: 1 = G54, 2 = G55, ... (controller-specific beyond 6).
63    pub number: u32,
64    /// Position of the WCS origin in MACHINE coordinates (usually the origin).
65    pub origin: Point3,
66}
67
68impl Default for WorkOffset {
69    fn default() -> Self {
70        Self { number: 1, origin: Point3::ZERO }
71    }
72}
73
74/// Program number and comment for the NC output.
75#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ProgramInfo {
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub number: Option<u32>,
80    #[serde(default)]
81    pub comment: String,
82}
83
84/// Raw material definition. Geometric stock is given in MODEL coordinates and
85/// transformed with the setup; `FromRest` chains setups.
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(tag = "kind", rename_all = "camelCase")]
88#[non_exhaustive]
89pub enum Stock {
90    /// The targets' bounding box grown by per-side offsets (Fusion's "relative size box").
91    BoundsOffset {
92        #[serde(default)]
93        x: f64,
94        #[serde(default)]
95        y: f64,
96        #[serde(default)]
97        top: f64,
98        #[serde(default)]
99        bottom: f64,
100    },
101    /// An explicit box.
102    Box { min: Point3, max: Point3 },
103    /// A cylinder along `axis` starting at `base`.
104    Cylinder { base: Point3, axis: Point3, radius: f64, height: f64 },
105    /// Another body in the document is the stock.
106    FromSolid { solid: String },
107    /// The rest material left by a previous setup.
108    FromRest { setup: SetupId },
109}
110
111impl Default for Stock {
112    fn default() -> Self {
113        Stock::BoundsOffset { x: 0.0, y: 0.0, top: 0.0, bottom: 0.0 }
114    }
115}
116
117impl Stock {
118    /// Model-space bounding box of the stock when it is geometric and the
119    /// targets' model bounds are `target_bounds`. `FromSolid`/`FromRest` need
120    /// their referenced geometry and return `None`.
121    pub fn model_bounds(&self, target_bounds: &Bounds3) -> Option<Bounds3> {
122        match self {
123            Stock::BoundsOffset { x, y, top, bottom } => {
124                if target_bounds.is_empty() {
125                    return None;
126                }
127                Some(Bounds3::new(
128                    Point3::new(target_bounds.min.x - x, target_bounds.min.y - y, target_bounds.min.z - bottom),
129                    Point3::new(target_bounds.max.x + x, target_bounds.max.y + y, target_bounds.max.z + top),
130                ))
131            }
132            Stock::Box { min, max } => Some(Bounds3::new(*min, *max)),
133            Stock::Cylinder { base, axis, radius, height } => {
134                let a = axis.normalized()?;
135                let top = base.add(a.scale(*height));
136                // Extent of a circle of radius r perpendicular to a along each axis.
137                let ext = Point3::new(
138                    radius * (1.0 - a.x * a.x).max(0.0).sqrt(),
139                    radius * (1.0 - a.y * a.y).max(0.0).sqrt(),
140                    radius * (1.0 - a.z * a.z).max(0.0).sqrt(),
141                );
142                Some(Bounds3::new(base.min(top).sub(ext), base.max(top).add(ext)))
143            }
144            Stock::FromSolid { .. } | Stock::FromRest { .. } => None,
145        }
146    }
147
148    /// Every finite dimension finite and positive where it must be.
149    pub fn validate(&self) -> Result<(), String> {
150        match self {
151            Stock::BoundsOffset { x, y, top, bottom } => {
152                for (name, v) in [("x", x), ("y", y), ("top", top), ("bottom", bottom)] {
153                    if !v.is_finite() {
154                        return Err(format!("stock offset {name} is not finite"));
155                    }
156                }
157                Ok(())
158            }
159            Stock::Box { min, max } => {
160                if !min.is_finite() || !max.is_finite() {
161                    return Err("stock box has a non-finite corner".into());
162                }
163                if min.x >= max.x || min.y >= max.y || min.z >= max.z {
164                    return Err("stock box must have positive size on every axis".into());
165                }
166                Ok(())
167            }
168            Stock::Cylinder { base, axis, radius, height } => {
169                if !base.is_finite() || !axis.is_finite() || !radius.is_finite() || !height.is_finite() {
170                    return Err("stock cylinder has a non-finite parameter".into());
171                }
172                if axis.normalized().is_none() {
173                    return Err("stock cylinder axis must be non-zero".into());
174                }
175                if *radius <= 0.0 || *height <= 0.0 {
176                    return Err("stock cylinder radius and height must be positive".into());
177                }
178                Ok(())
179            }
180            Stock::FromSolid { solid } => {
181                if solid.is_empty() {
182                    Err("stock body name is empty".into())
183                } else {
184                    Ok(())
185                }
186            }
187            Stock::FromRest { setup } => {
188                if setup.as_str().is_empty() {
189                    Err("rest stock setup id is empty".into())
190                } else {
191                    Ok(())
192                }
193            }
194        }
195    }
196}
197
198/// Safe heights in MACHINE Z. Absolute values; the operation may override.
199#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201#[serde(default)]
202pub struct Heights {
203    /// Rapid traverse height between operations and over fixtures.
204    pub clearance: f64,
205    /// Retract height between passes within an operation.
206    pub retract: f64,
207    /// Height at which rapids turn into feed moves on the way down.
208    pub feed: f64,
209    /// Top of the material to cut (`None`: from stock).
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub top: Option<f64>,
212    /// Lowest Z the operation may reach (`None`: from the model).
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub bottom: Option<f64>,
215}
216
217impl Default for Heights {
218    fn default() -> Self {
219        Self { clearance: 15.0, retract: 5.0, feed: 2.0, top: None, bottom: None }
220    }
221}
222
223impl Heights {
224    /// `clearance >= retract >= feed`, all finite; top above bottom when both given.
225    pub fn validate(&self) -> Result<(), String> {
226        for (name, v) in [("clearance", self.clearance), ("retract", self.retract), ("feed", self.feed)] {
227            if !v.is_finite() {
228                return Err(format!("{name} height is not finite"));
229            }
230        }
231        if self.clearance < self.retract {
232            return Err("clearance height must not be below retract height".into());
233        }
234        if self.retract < self.feed {
235            return Err("retract height must not be below feed height".into());
236        }
237        if let (Some(top), Some(bottom)) = (self.top, self.bottom) {
238            if !top.is_finite() || !bottom.is_finite() {
239                return Err("top/bottom height is not finite".into());
240            }
241            if top < bottom {
242                return Err("top height must not be below bottom height".into());
243            }
244        }
245        Ok(())
246    }
247}