Skip to main content

brep_cam/
stock.rs

1//! Stock resolved into MACHINE coordinates for one setup: the shapes the
2//! material-removal simulation (lane C7) starts from. No simulation here.
3
4use crate::mesh::MeshSnapshot;
5use crate::model::{Bounds3, Point3, RigidTransform, Setup, SetupId, Stock};
6
7/// A box given in its own (model) frame plus the transform into machine space.
8/// Kept oriented so a rotated setup does not inflate the stock to its AABB.
9#[derive(Clone, Debug, PartialEq)]
10pub struct StockBox {
11    /// The box in model coordinates (a `Stock::BoundsOffset`'s offsets follow
12    /// the setup's frame: see [`bounds_offset_box`]).
13    pub bounds: Bounds3,
14    /// Model-to-machine.
15    pub to_machine: RigidTransform,
16}
17
18/// A `Stock::BoundsOffset`'s box, kept ORIENTED in the model frame (no
19/// axis-aligned inflation on a rotated setup), with the offsets following the
20/// setup: each machine axis's offsets (`x` both sides of machine X, `y` of
21/// machine Y, `bottom` / `top` below / above machine Z) are applied along the
22/// model axis that maps closest to that machine axis, on the side its sign
23/// gives — so a setup whose Z is the model's +Y adds its top offset along the
24/// model's +Y, and an upside-down part gets its top offset above it on the
25/// machine. Exact whenever the setup's axes are the model's axes permuted or
26/// reversed (every face or edge pick); for a frame skewed off the model axes
27/// the offsets go along the nearest model axes. `None` for another stock kind
28/// or no targets.
29pub fn bounds_offset_box(setup: &Setup, target_bounds_model: &Bounds3) -> Option<StockBox> {
30    let Stock::BoundsOffset { x, y, top, bottom } = &setup.stock else { return None };
31    if target_bounds_model.is_empty() {
32        return None;
33    }
34    let r = &setup.model_to_machine.rotation;
35    let mut min = [target_bounds_model.min.x, target_bounds_model.min.y, target_bounds_model.min.z];
36    let mut max = [target_bounds_model.max.x, target_bounds_model.max.y, target_bounds_model.max.z];
37    // (low side, high side) of each machine axis.
38    let offsets = [(*x, *x), (*y, *y), (*bottom, *top)];
39    let mut used = [false; 3];
40    // Machine Z claims its model axis first: top and bottom matter most.
41    for machine_axis in [2usize, 0, 1] {
42        let (lo, hi) = offsets[machine_axis];
43        // The model axis whose image is closest to this machine axis (each used once).
44        let model_axis = (0..3)
45            .filter(|i| !used[*i])
46            .max_by(|a, b| r[machine_axis][*a].abs().partial_cmp(&r[machine_axis][*b].abs()).unwrap_or(std::cmp::Ordering::Equal))
47            .unwrap_or(machine_axis);
48        used[model_axis] = true;
49        if r[machine_axis][model_axis] >= 0.0 {
50            min[model_axis] -= lo;
51            max[model_axis] += hi;
52        } else {
53            min[model_axis] -= hi;
54            max[model_axis] += lo;
55        }
56    }
57    Some(StockBox { bounds: Bounds3::new(Point3::new(min[0], min[1], min[2]), Point3::new(max[0], max[1], max[2])), to_machine: setup.model_to_machine })
58}
59
60impl StockBox {
61    /// Axis-aligned machine-space bounds (exact for axis-aligned setups).
62    pub fn machine_bounds(&self) -> Bounds3 {
63        self.to_machine.apply_bounds(&self.bounds)
64    }
65
66    /// Whether a MACHINE point is inside the stock (closed).
67    pub fn contains(&self, machine_point: Point3) -> bool {
68        self.bounds.contains_point(self.to_machine.inverse().apply_point(machine_point))
69    }
70
71    pub fn volume(&self) -> f64 {
72        let s = self.bounds.size();
73        if self.bounds.is_empty() {
74            0.0
75        } else {
76            s.x * s.y * s.z
77        }
78    }
79}
80
81/// A cylinder in MACHINE coordinates.
82#[derive(Clone, Debug, PartialEq)]
83pub struct StockCylinder {
84    pub base: Point3,
85    /// Unit axis direction.
86    pub axis: Point3,
87    pub radius: f64,
88    pub height: f64,
89}
90
91impl StockCylinder {
92    pub fn machine_bounds(&self) -> Bounds3 {
93        let a = self.axis;
94        let top = self.base.add(a.scale(self.height));
95        let ext = Point3::new(
96            self.radius * (1.0 - a.x * a.x).max(0.0).sqrt(),
97            self.radius * (1.0 - a.y * a.y).max(0.0).sqrt(),
98            self.radius * (1.0 - a.z * a.z).max(0.0).sqrt(),
99        );
100        Bounds3::new(self.base.min(top).sub(ext), self.base.max(top).add(ext))
101    }
102
103    pub fn contains(&self, machine_point: Point3) -> bool {
104        let d = machine_point.sub(self.base);
105        let along = d.dot(self.axis);
106        if along < 0.0 || along > self.height {
107            return false;
108        }
109        let radial = d.sub(self.axis.scale(along)).length();
110        radial <= self.radius
111    }
112
113    pub fn volume(&self) -> f64 {
114        std::f64::consts::PI * self.radius * self.radius * self.height
115    }
116}
117
118/// The setup's stock in machine coordinates.
119#[derive(Clone, Debug, PartialEq)]
120pub enum ResolvedStock {
121    Box(StockBox),
122    Cylinder(StockCylinder),
123    /// A body used as stock, already tessellated and transformed into machine space.
124    Mesh(MeshSnapshot),
125    /// The rest material of another setup; the simulation chains to that setup's result.
126    Rest(SetupId),
127}
128
129impl ResolvedStock {
130    /// Machine-space bounds; `None` for `Rest` (known only after the previous setup ran).
131    pub fn machine_bounds(&self) -> Option<Bounds3> {
132        match self {
133            ResolvedStock::Box(b) => Some(b.machine_bounds()),
134            ResolvedStock::Cylinder(c) => Some(c.machine_bounds()),
135            ResolvedStock::Mesh(m) => Some(m.bounds),
136            ResolvedStock::Rest(_) => None,
137        }
138    }
139
140    /// Analytic containment; `None` where it needs a volume model (mesh, rest).
141    pub fn contains(&self, machine_point: Point3) -> Option<bool> {
142        match self {
143            ResolvedStock::Box(b) => Some(b.contains(machine_point)),
144            ResolvedStock::Cylinder(c) => Some(c.contains(machine_point)),
145            ResolvedStock::Mesh(_) | ResolvedStock::Rest(_) => None,
146        }
147    }
148
149    /// Top of stock in machine Z, when known.
150    pub fn top_z(&self) -> Option<f64> {
151        self.machine_bounds().map(|b| b.max.z)
152    }
153}
154
155/// Why a stock definition could not be resolved.
156#[derive(Clone, Debug, PartialEq, Eq)]
157pub enum StockError {
158    /// The definition failed its own validation (message from `Stock::validate`).
159    Invalid(String),
160    /// `BoundsOffset` with no target geometry to grow.
161    NoTargets,
162    /// `FromSolid` named a body the caller did not supply.
163    MissingSolid(String),
164}
165
166impl std::fmt::Display for StockError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            StockError::Invalid(m) => write!(f, "stock: {m}"),
170            StockError::NoTargets => write!(f, "stock: the setup has no target geometry to size the stock from"),
171            StockError::MissingSolid(s) => write!(f, "stock: body {s:?} was not supplied"),
172        }
173    }
174}
175
176impl std::error::Error for StockError {}
177
178/// Resolve `setup.stock` into machine coordinates. `target_bounds_model` is
179/// the model-space bounds of the setup's target bodies; `from_solid` is the
180/// model-space snapshot of the stock body for `Stock::FromSolid`. The setup
181/// transform is applied here and nowhere else for stock.
182pub fn resolve_stock(
183    setup: &Setup,
184    target_bounds_model: &Bounds3,
185    from_solid: Option<&MeshSnapshot>,
186) -> Result<ResolvedStock, StockError> {
187    setup.stock.validate().map_err(StockError::Invalid)?;
188    let t = &setup.model_to_machine;
189    match &setup.stock {
190        Stock::BoundsOffset { .. } => Ok(ResolvedStock::Box(bounds_offset_box(setup, target_bounds_model).ok_or(StockError::NoTargets)?)),
191        Stock::Box { min, max } => Ok(ResolvedStock::Box(StockBox { bounds: Bounds3::new(*min, *max), to_machine: *t })),
192        Stock::Cylinder { base, axis, radius, height } => {
193            let axis = axis.normalized().ok_or_else(|| StockError::Invalid("cylinder axis is zero".into()))?;
194            Ok(ResolvedStock::Cylinder(StockCylinder {
195                base: t.apply_point(*base),
196                axis: t.apply_vector(axis),
197                radius: *radius,
198                height: *height,
199            }))
200        }
201        Stock::FromSolid { solid } => {
202            let mesh = from_solid.ok_or_else(|| StockError::MissingSolid(solid.clone()))?;
203            Ok(ResolvedStock::Mesh(crate::mesh::to_machine(setup, mesh)))
204        }
205        Stock::FromRest { setup: previous } => Ok(ResolvedStock::Rest(previous.clone())),
206    }
207}
208