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