Skip to main content

brep_cam/stock_sim/
mod.rs

1//! Stock model for material-removal simulation: a Z-dexel grid.
2//!
3//! The stock is a grid of vertical columns in MACHINE XY; every column holds
4//! its material as a sorted list of disjoint `[low, high]` Z intervals. This is
5//! the data structure the exact per-column floor of a 3-axis move writes into
6//! directly ([`removal`]), so removal never approximates the floor: only the XY
7//! sampling is discrete, and the cell size is a reported output, never a
8//! silent coarsening. Limits, stated: undercuts (T-slot undersides, lollipop
9//! upper surfaces) are not simulated by a Z-dexel; moves that would need them
10//! are reported as unsupported by the removal pass, never simulated wrongly.
11
12pub mod carry;
13pub mod envelope;
14pub mod frame;
15pub mod job;
16pub mod mesh;
17pub mod preop;
18pub mod remaining;
19pub mod removal;
20
21use crate::index::{ProjectionMode, TriangleIndex};
22use crate::mesh::MeshSnapshot;
23use crate::model::{Bounds3, Point3};
24use crate::stock::ResolvedStock;
25use serde::{Deserialize, Serialize};
26
27/// How the grid is laid out and how much it may cost.
28#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30#[serde(default)]
31pub struct DexelOptions {
32    /// Requested cell size in model units (both axes are at most this; a box
33    /// stock gets cells that divide its sides exactly so its volume is exact).
34    pub cell: f64,
35    /// Work budget: the grid may not exceed this many columns.
36    pub max_columns: u64,
37}
38
39impl Default for DexelOptions {
40    fn default() -> Self {
41        Self { cell: 0.5, max_columns: 4_000_000 }
42    }
43}
44
45/// Why a stock could not become a grid.
46#[derive(Clone, Debug, PartialEq)]
47pub enum StockSimError {
48    /// The requested cell would need more columns than the budget allows.
49    /// `cell_that_fits` is the smallest cell within budget.
50    BudgetExceeded { requested_cell: f64, columns: u64, max_columns: u64, cell_that_fits: f64 },
51    /// `Stock::FromRest` needs the previous setup's simulated stock.
52    RestStockMissing,
53    /// A `FromSolid` stock mesh is open at some column (odd crossing count on
54    /// the centre ray and on every jittered ray).
55    OpenStockMesh { column: [u32; 2] },
56    /// Non-finite or non-positive cell / empty stock bounds.
57    InvalidInput(String),
58}
59
60impl std::fmt::Display for StockSimError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            StockSimError::BudgetExceeded { requested_cell, columns, max_columns, cell_that_fits } => write!(
64                f,
65                "stock grid at cell {requested_cell} needs {columns} columns, over the budget of {max_columns}; a cell of {cell_that_fits} fits"
66            ),
67            StockSimError::RestStockMissing => write!(f, "rest stock needs the previous setup's simulated stock"),
68            StockSimError::OpenStockMesh { column } => {
69                write!(f, "stock mesh is not closed at column ({}, {})", column[0], column[1])
70            }
71            StockSimError::InvalidInput(m) => write!(f, "stock grid: {m}"),
72        }
73    }
74}
75
76impl std::error::Error for StockSimError {}
77
78/// Material of one column: sorted, disjoint, non-empty `[low, high]` intervals.
79#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
80#[serde(transparent)]
81pub struct Column {
82    pub intervals: Vec<[f64; 2]>,
83}
84
85impl Column {
86    pub fn full(low: f64, high: f64) -> Self {
87        Self { intervals: vec![[low, high]] }
88    }
89
90    pub fn is_empty(&self) -> bool {
91        self.intervals.is_empty()
92    }
93
94    /// Total material height.
95    pub fn height(&self) -> f64 {
96        self.intervals.iter().map(|[a, b]| b - a).sum()
97    }
98
99    /// Highest material Z, if any.
100    pub fn top(&self) -> Option<f64> {
101        self.intervals.last().map(|[_, b]| *b)
102    }
103
104    /// Remove material in `[low, high]`; returns the height removed. Raw float
105    /// compares, never snapped, so commuting cuts give identical columns.
106    pub fn cut(&mut self, low: f64, high: f64) -> f64 {
107        if !(high > low) {
108            return 0.0;
109        }
110        let mut removed = 0.0;
111        let mut out: Vec<[f64; 2]> = Vec::with_capacity(self.intervals.len() + 1);
112        for &[a, b] in &self.intervals {
113            if b <= low || a >= high {
114                out.push([a, b]);
115                continue;
116            }
117            // Overlap [max(a, low), min(b, high)].
118            let cut_lo = a.max(low);
119            let cut_hi = b.min(high);
120            removed += cut_hi - cut_lo;
121            if a < cut_lo {
122                out.push([a, cut_lo]);
123            }
124            if cut_hi < b {
125                out.push([cut_hi, b]);
126            }
127        }
128        self.intervals = out;
129        removed
130    }
131
132    /// Material present anywhere in `[low, high]`.
133    pub fn has_material(&self, low: f64, high: f64) -> bool {
134        self.intervals.iter().any(|[a, b]| *b > low && *a < high)
135    }
136}
137
138/// The Z-dexel stock. Column `(ix, iy)` is centred at
139/// `(origin.x + (ix + 0.5)·cell_x, origin.y + (iy + 0.5)·cell_y)`.
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct DexelGrid {
143    pub origin: Point3,
144    pub cell_x: f64,
145    pub cell_y: f64,
146    pub nx: u32,
147    pub ny: u32,
148    /// Row-major: `columns[iy * nx + ix]`.
149    pub columns: Vec<Column>,
150    /// Z range of the initial stock (for display and for the top-of-stock).
151    pub z_range: [f64; 2],
152}
153
154impl DexelGrid {
155    /// Lay a grid over `bounds` with cells at most `cell` on each side,
156    /// dividing the sides exactly.
157    fn layout(bounds: &Bounds3, options: &DexelOptions) -> Result<(Point3, f64, f64, u32, u32), StockSimError> {
158        if !(options.cell > 0.0) || !options.cell.is_finite() {
159            return Err(StockSimError::InvalidInput(format!("cell {} must be finite and positive", options.cell)));
160        }
161        if bounds.is_empty() || !bounds.is_finite() {
162            return Err(StockSimError::InvalidInput("stock bounds are empty or not finite".into()));
163        }
164        let size = bounds.size();
165        let nx = (size.x / options.cell).ceil().max(1.0);
166        let ny = (size.y / options.cell).ceil().max(1.0);
167        let columns = nx * ny;
168        if columns > options.max_columns as f64 {
169            let area = size.x * size.y;
170            let cell_that_fits = (area / options.max_columns as f64).sqrt() * 1.001;
171            return Err(StockSimError::BudgetExceeded {
172                requested_cell: options.cell,
173                columns: columns as u64,
174                max_columns: options.max_columns,
175                cell_that_fits,
176            });
177        }
178        Ok((bounds.min, size.x / nx, size.y / ny, nx as u32, ny as u32))
179    }
180
181    /// Seed the grid from a resolved stock. `rest` supplies the previous
182    /// setup's simulated stock for `ResolvedStock::Rest`; `index` speeds up
183    /// mesh stock seeding (brute force without it).
184    pub fn from_stock(
185        stock: &ResolvedStock,
186        options: &DexelOptions,
187        rest: Option<&DexelGrid>,
188        index: Option<&dyn TriangleIndex>,
189    ) -> Result<DexelGrid, StockSimError> {
190        match stock {
191            ResolvedStock::Box(b) => {
192                let bounds = b.machine_bounds();
193                let (origin, cell_x, cell_y, nx, ny) = Self::layout(&bounds, options)?;
194                let is_aligned = b.to_machine.rotation.iter().flatten().all(|v| v.abs() < 1e-12 || (v.abs() - 1.0).abs() < 1e-12);
195                let mut grid = Self::empty(origin, cell_x, cell_y, nx, ny, [bounds.min.z, bounds.max.z]);
196                for iy in 0..ny {
197                    for ix in 0..nx {
198                        let (x, y) = grid.center(ix, iy);
199                        let inside = is_aligned || b.contains(Point3::new(x, y, 0.5 * (bounds.min.z + bounds.max.z)));
200                        if inside {
201                            grid.columns[(iy * nx + ix) as usize] = Column::full(bounds.min.z, bounds.max.z);
202                        }
203                    }
204                }
205                Ok(grid)
206            }
207            ResolvedStock::Cylinder(c) => {
208                let bounds = c.machine_bounds();
209                let (origin, cell_x, cell_y, nx, ny) = Self::layout(&bounds, options)?;
210                let mut grid = Self::empty(origin, cell_x, cell_y, nx, ny, [bounds.min.z, bounds.max.z]);
211                let vertical = (c.axis.z.abs() - 1.0).abs() < 1e-12;
212                for iy in 0..ny {
213                    for ix in 0..nx {
214                        let (x, y) = grid.center(ix, iy);
215                        if vertical {
216                            let d = ((x - c.base.x).powi(2) + (y - c.base.y).powi(2)).sqrt();
217                            if d <= c.radius {
218                                grid.columns[(iy * nx + ix) as usize] = Column::full(bounds.min.z, bounds.max.z);
219                            }
220                        } else {
221                            // Tilted cylinder: intersect the vertical line with the
222                            // infinite cylinder, clip by the end planes.
223                            if let Some(iv) = cylinder_column(c, x, y) {
224                                grid.columns[(iy * nx + ix) as usize] = Column::full(iv[0], iv[1]);
225                            }
226                        }
227                    }
228                }
229                Ok(grid)
230            }
231            ResolvedStock::Mesh(mesh) => Self::from_mesh(mesh, options, index),
232            ResolvedStock::Rest(_) => rest.cloned().ok_or(StockSimError::RestStockMissing),
233        }
234    }
235
236    /// Seed from a closed mesh in machine coordinates by casting one vertical
237    /// ray per column and pairing crossings. A ray through a shared edge or
238    /// vertex meets every triangle there, and their plane evaluations differ
239    /// by round-off, so an odd count is first re-cast through points jittered
240    /// inside the cell; a column whose every ray stays odd is an open mesh.
241    pub fn from_mesh(mesh: &MeshSnapshot, options: &DexelOptions, index: Option<&dyn TriangleIndex>) -> Result<DexelGrid, StockSimError> {
242        Self::seed_from_mesh(mesh, options, index, false).map(|(grid, _)| grid)
243    }
244
245    /// A PROTECTION grid from a target mesh: [`from_mesh`](Self::from_mesh),
246    /// except that a column where the mesh is open (odd crossings on every
247    /// jittered ray) is filled conservatively over the mesh's whole height
248    /// (a missing face may be the top or the bottom, so no crossing bounds
249    /// it), and no part material can be missing from it. The fill
250    /// is named (`open-mesh-protection`, with the column count) rather than
251    /// refused: a checked replay must never run with no protection because
252    /// the target mesh has a hole.
253    pub fn protected_from_mesh(
254        mesh: &MeshSnapshot,
255        options: &DexelOptions,
256        index: Option<&dyn TriangleIndex>,
257    ) -> Result<(DexelGrid, Option<crate::model::ApproximationLimit>), StockSimError> {
258        let (grid, open) = Self::seed_from_mesh(mesh, options, index, true)?;
259        let limit = (open > 0).then(|| crate::model::ApproximationLimit {
260            kind: "open-mesh-protection".into(),
261            bound: None,
262            detail: Some(format!(
263                "the protected mesh is open at {open} column(s); each is protected over the mesh's whole height"
264            )),
265        });
266        Ok((grid, limit))
267    }
268
269    fn seed_from_mesh(
270        mesh: &MeshSnapshot,
271        options: &DexelOptions,
272        index: Option<&dyn TriangleIndex>,
273        fill_open: bool,
274    ) -> Result<(DexelGrid, usize), StockSimError> {
275        let bounds = mesh.bounds;
276        let (origin, cell_x, cell_y, nx, ny) = Self::layout(&bounds, options)?;
277        let mut grid = Self::empty(origin, cell_x, cell_y, nx, ny, [bounds.min.z, bounds.max.z]);
278        let brute = crate::index::BruteForceIndex::from_snapshot(mesh);
279        let idx: &dyn TriangleIndex = index.unwrap_or(&brute);
280        let mut candidates = Vec::new();
281        let mut hits: Vec<f64> = Vec::new();
282        // Fixed, deterministic offsets inside the cell (fractions of the
283        // smaller cell side), none on a symmetry line of the cell.
284        const JITTER: [(f64, f64); 4] = [(1.0e-3, 0.37e-3), (-0.61e-3, 1.0e-3), (0.29e-3, -1.0e-3), (-1.0e-3, -0.53e-3)];
285        let side = cell_x.min(cell_y);
286        let mut open = 0usize;
287        for iy in 0..ny {
288            for ix in 0..nx {
289                let (cx, cy) = grid.center(ix, iy);
290                let mut cast = |x: f64, y: f64, hits: &mut Vec<f64>| {
291                    candidates.clear();
292                    let q = Bounds3::new(Point3::new(x, y, f64::NEG_INFINITY), Point3::new(x, y, f64::INFINITY));
293                    idx.query_aabb(&q, ProjectionMode::Xy, &mut candidates);
294                    hits.clear();
295                    for &id in &candidates {
296                        let t = mesh.triangle(id);
297                        if let Some(z) = vertical_ray_hit(t.a, t.b, t.c, x, y) {
298                            hits.push(z);
299                        }
300                    }
301                    hits.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
302                    // Exact duplicates come from a ray through a shared edge: keep one.
303                    hits.dedup();
304                };
305                cast(cx, cy, &mut hits);
306                if hits.is_empty() {
307                    continue;
308                }
309                let mut k = 0;
310                while hits.len() % 2 == 1 && k < JITTER.len() {
311                    let (jx, jy) = JITTER[k];
312                    cast(cx + jx * side, cy + jy * side, &mut hits);
313                    k += 1;
314                }
315                let column = &mut grid.columns[(iy * nx + ix) as usize];
316                if hits.len() % 2 == 1 {
317                    if !fill_open {
318                        return Err(StockSimError::OpenStockMesh { column: [ix, iy] });
319                    }
320                    open += 1;
321                    column.intervals.push([bounds.min.z, bounds.max.z]);
322                    continue;
323                }
324                for pair in hits.chunks_exact(2) {
325                    if pair[1] > pair[0] {
326                        column.intervals.push([pair[0], pair[1]]);
327                    }
328                }
329            }
330        }
331        Ok((grid, open))
332    }
333
334    fn empty(origin: Point3, cell_x: f64, cell_y: f64, nx: u32, ny: u32, z_range: [f64; 2]) -> Self {
335        Self {
336            origin,
337            cell_x,
338            cell_y,
339            nx,
340            ny,
341            columns: vec![Column::default(); (nx as usize) * (ny as usize)],
342            z_range,
343        }
344    }
345
346    /// Centre of column `(ix, iy)`.
347    pub fn center(&self, ix: u32, iy: u32) -> (f64, f64) {
348        (self.origin.x + (ix as f64 + 0.5) * self.cell_x, self.origin.y + (iy as f64 + 0.5) * self.cell_y)
349    }
350
351    pub fn column(&self, ix: u32, iy: u32) -> &Column {
352        &self.columns[(iy * self.nx + ix) as usize]
353    }
354
355    pub fn column_mut(&mut self, ix: u32, iy: u32) -> &mut Column {
356        &mut self.columns[(iy * self.nx + ix) as usize]
357    }
358
359    /// Column index range `[ix0, ix1] × [iy0, iy1]` whose centres fall inside
360    /// `bounds` (XY), clipped to the grid; `None` when nothing overlaps.
361    pub fn columns_in(&self, bounds: &Bounds3) -> Option<[u32; 4]> {
362        let fx = |x: f64| (x - self.origin.x) / self.cell_x - 0.5;
363        let fy = |y: f64| (y - self.origin.y) / self.cell_y - 0.5;
364        let ix0 = fx(bounds.min.x).ceil().max(0.0);
365        let ix1 = fx(bounds.max.x).floor().min(self.nx as f64 - 1.0);
366        let iy0 = fy(bounds.min.y).ceil().max(0.0);
367        let iy1 = fy(bounds.max.y).floor().min(self.ny as f64 - 1.0);
368        if ix0 > ix1 || iy0 > iy1 {
369            return None;
370        }
371        Some([ix0 as u32, ix1 as u32, iy0 as u32, iy1 as u32])
372    }
373
374    pub fn cell_area(&self) -> f64 {
375        self.cell_x * self.cell_y
376    }
377
378    /// Material volume.
379    pub fn volume(&self) -> f64 {
380        self.columns.iter().map(|c| c.height()).sum::<f64>() * self.cell_area()
381    }
382
383    /// Columns plus intervals: the memory this grid costs, in work units.
384    pub fn work_units(&self) -> u64 {
385        self.columns.len() as u64 + self.columns.iter().map(|c| c.intervals.len() as u64).sum::<u64>()
386    }
387
388    /// Top of material at the column containing `(x, y)`, if any.
389    pub fn top_at(&self, x: f64, y: f64) -> Option<f64> {
390        let ix = ((x - self.origin.x) / self.cell_x).floor();
391        let iy = ((y - self.origin.y) / self.cell_y).floor();
392        if ix < 0.0 || iy < 0.0 || ix >= self.nx as f64 || iy >= self.ny as f64 {
393            return None;
394        }
395        self.column(ix as u32, iy as u32).top()
396    }
397
398    /// Machine-space bounds of the grid footprint and the initial Z range.
399    pub fn bounds(&self) -> Bounds3 {
400        Bounds3::new(
401            Point3::new(self.origin.x, self.origin.y, self.z_range[0]),
402            Point3::new(self.origin.x + self.nx as f64 * self.cell_x, self.origin.y + self.ny as f64 * self.cell_y, self.z_range[1]),
403        )
404    }
405}
406
407/// Z where the vertical line through `(x, y)` crosses triangle `abc`
408/// (closed test; a hit on a shared edge is reported by both triangles and
409/// de-duplicated by the caller). `None` for vertical triangles.
410pub(crate) fn vertical_ray_hit(a: Point3, b: Point3, c: Point3, x: f64, y: f64) -> Option<f64> {
411    let side = |p: Point3, q: Point3| (q.x - p.x) * (y - p.y) - (q.y - p.y) * (x - p.x);
412    let (d1, d2, d3) = (side(a, b), side(b, c), side(c, a));
413    let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
414    let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
415    if has_neg && has_pos {
416        return None;
417    }
418    let n = b.sub(a).cross(c.sub(a));
419    if n.z.abs() <= f64::EPSILON * (n.length() + 1.0) {
420        return None;
421    }
422    Some(a.z - (n.x * (x - a.x) + n.y * (y - a.y)) / n.z)
423}
424
425/// Z interval of a tilted cylinder along the vertical line through `(x, y)`.
426fn cylinder_column(c: &crate::stock::StockCylinder, x: f64, y: f64) -> Option<[f64; 2]> {
427    // Points p(z) = (x, y, z); radial distance to the axis line (base, axis):
428    // |(p - base) - ((p - base)·a) a|² = r² is a quadratic in z.
429    let a = c.axis;
430    let d0 = Point3::new(x - c.base.x, y - c.base.y, -c.base.z);
431    let dz = Point3::Z;
432    // v(z) = d0 + z dz; radial = v - (v·a) a.
433    let r0 = d0.sub(a.scale(d0.dot(a)));
434    let r1 = dz.sub(a.scale(dz.dot(a)));
435    let qa = r1.dot(r1);
436    let qb = 2.0 * r0.dot(r1);
437    let qc = r0.dot(r0) - c.radius * c.radius;
438    let (mut lo, mut hi) = if qa.abs() < 1e-15 {
439        if qc > 0.0 {
440            return None;
441        }
442        (f64::NEG_INFINITY, f64::INFINITY)
443    } else {
444        let disc = qb * qb - 4.0 * qa * qc;
445        if disc < 0.0 {
446            return None;
447        }
448        let s = disc.sqrt();
449        ((-qb - s) / (2.0 * qa), (-qb + s) / (2.0 * qa))
450    };
451    // End planes: 0 <= (p - base)·a <= height, i.e. d0·a + z (a·z) in [0, h].
452    let s0 = d0.dot(a);
453    let s1 = dz.dot(a);
454    if s1.abs() < 1e-15 {
455        if s0 < 0.0 || s0 > c.height {
456            return None;
457        }
458    } else {
459        let z_a = -s0 / s1;
460        let z_b = (c.height - s0) / s1;
461        lo = lo.max(z_a.min(z_b));
462        hi = hi.min(z_a.max(z_b));
463    }
464    (hi > lo).then_some([lo, hi])
465}
466
467