Skip to main content

brep_cam/verify/
mod.rs

1//! Stock-versus-part comparison: the deviation map.
2//!
3//! For every column of the simulated stock the stock's top point is compared
4//! with the part mesh (machine coordinates): the deviation is the 3D distance
5//! to the nearest part triangle, signed by where the point lies — outside the
6//! part (above or beside it) is EXCESS stock (positive), inside the part is a
7//! GOUGE (negative), so a finishing pass with stock-to-leave `s` reads `+s`
8//! everywhere it reached, on sloped faces too (normal distance, not vertical
9//! gap). An empty column where the part has material is a gouge measured from
10//! the stock floor. Samples are aggregated per part face (by the nearest
11//! triangle's face) and returned as a per-column grid for the viewport's
12//! colour map.
13
14use crate::index::{ProjectionMode, TriangleIndex};
15use crate::mesh::MeshSnapshot;
16use crate::model::{Bounds3, Point3};
17use crate::stock_sim::DexelGrid;
18use serde::{Deserialize, Serialize};
19
20/// Deviation parameters.
21#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23#[serde(default)]
24pub struct DeviationOptions {
25    /// |deviation| at or below this counts as within tolerance.
26    pub tolerance: f64,
27    /// Largest distance searched for the nearest part triangle; columns farther
28    /// from the part than this report `None` (stock far from the part).
29    pub max_distance: f64,
30}
31
32impl Default for DeviationOptions {
33    fn default() -> Self {
34        Self { tolerance: 0.05, max_distance: 1_000.0 }
35    }
36}
37
38/// Classification of one deviation value.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum DeviationClass {
42    Within,
43    Excess,
44    Gouge,
45}
46
47/// Where a column's stock stands against the part, vertically.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum ColumnPlacement {
51    /// The part is in this column and the stock reaches its lowest surface
52    /// there or higher: excess or gouge against the part as usual.
53    AbovePart,
54    /// No part in this column (beside it, or in a through hole) and the stock
55    /// top within the part's height range: measured to the nearest face.
56    WithinExtent,
57    /// The stock top is under the part's lowest surface (the part's own in
58    /// this column, else the whole part's): stock below the part, e.g. the
59    /// block under a flange exit. Counted with its volume, never attributed to
60    /// a face and never read as excess on the nearest wall.
61    BelowPart,
62}
63
64/// Aggregate of the samples whose nearest triangle belongs to one part face.
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct FaceDeviation {
68    /// Index into the part snapshot's `faces`.
69    pub face: u32,
70    pub solid: String,
71    pub face_name: String,
72    pub samples: u64,
73    pub min: f64,
74    pub max: f64,
75    pub mean: f64,
76    pub gouged: u64,
77    pub excess: u64,
78}
79
80/// The deviation of the stock from the part, column by column.
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct DeviationMap {
84    pub origin: Point3,
85    pub cell_x: f64,
86    pub cell_y: f64,
87    pub nx: u32,
88    pub ny: u32,
89    /// Row-major per column: signed deviation at the stock top, `None` where
90    /// the column has no stock and no part, or lies farther than `max_distance`.
91    pub values: Vec<Option<f64>>,
92    /// Stock-top sample height per column (the stock floor for empty columns).
93    pub heights: Vec<Option<f64>>,
94    /// Nearest part face per column.
95    pub faces_of: Vec<Option<u32>>,
96    pub faces: Vec<FaceDeviation>,
97    pub tolerance: f64,
98    pub min: f64,
99    pub max: f64,
100    pub within: u64,
101    pub excess: u64,
102    pub gouged: u64,
103    /// Row-major per column: where its stock stands against the part
104    /// (`None`: no stock and no part).
105    #[serde(default)]
106    pub placement: Vec<Option<ColumnPlacement>>,
107    /// Columns of stock below the part, and their material volume. They carry
108    /// no `values` entry and no face.
109    #[serde(default)]
110    pub below_part: u64,
111    #[serde(default)]
112    pub below_part_volume: f64,
113}
114
115impl DeviationMap {
116    pub fn classify(&self, deviation: f64) -> DeviationClass {
117        if deviation.abs() <= self.tolerance {
118            DeviationClass::Within
119        } else if deviation > 0.0 {
120            DeviationClass::Excess
121        } else {
122            DeviationClass::Gouge
123        }
124    }
125
126    pub fn value(&self, ix: u32, iy: u32) -> Option<f64> {
127        self.values[(iy * self.nx + ix) as usize]
128    }
129
130    /// Column containing `(x, y)`.
131    pub fn value_at(&self, x: f64, y: f64) -> Option<f64> {
132        let ix = ((x - self.origin.x) / self.cell_x).floor();
133        let iy = ((y - self.origin.y) / self.cell_y).floor();
134        if ix < 0.0 || iy < 0.0 || ix >= self.nx as f64 || iy >= self.ny as f64 {
135            return None;
136        }
137        self.value(ix as u32, iy as u32)
138    }
139
140    pub fn face(&self, name: &str) -> Option<&FaceDeviation> {
141        self.faces.iter().find(|f| f.face_name == name)
142    }
143}
144
145/// The point of triangle `abc` closest to `p` (Ericson, Real-Time Collision Detection).
146pub use crate::geom::closest_point_on_triangle;
147
148/// Squared distance from `p` to triangle `abc`.
149pub use crate::geom::point_triangle_distance_squared;
150
151/// The part's lowest crossing just past its point `q` nearest to the stock
152/// point `p` (stepped a hair from `p` towards and beyond `q`, into the part):
153/// the bottom of the part beside a column that holds no part itself.
154pub(crate) fn part_bottom_beside(part: &MeshSnapshot, index: &dyn TriangleIndex, p: Point3, q: Point3, step: f64, scratch: &mut Vec<u32>) -> Option<f64> {
155    let (dx, dy) = (q.x - p.x, q.y - p.y);
156    let len = (dx * dx + dy * dy).sqrt();
157    let (x, y) = if len > 0.0 { (q.x + dx / len * step, q.y + dy / len * step) } else { (q.x, q.y) };
158    part_bottom_under(part, index, x, y, scratch)
159}
160
161/// Nearest part triangle to `p` within `max_distance`: `(triangle id, distance)`.
162/// Searches the index in boxes that double until a hit is found and confirmed
163/// (every triangle within the found distance has been examined).
164pub fn nearest_triangle(part: &MeshSnapshot, index: &dyn TriangleIndex, p: Point3, start_radius: f64, max_distance: f64) -> Option<(u32, f64)> {
165    let mut radius = start_radius.max(f64::MIN_POSITIVE);
166    let mut candidates = Vec::new();
167    loop {
168        let q = Bounds3::from_point(p).expanded(radius);
169        candidates.clear();
170        index.query_aabb(&q, ProjectionMode::Xyz, &mut candidates);
171        let mut best: Option<(u32, f64)> = None;
172        for &id in &candidates {
173            let t = part.triangle(id);
174            let d2 = point_triangle_distance_squared(p, t.a, t.b, t.c);
175            if best.map_or(true, |(_, d)| d2 < d * d) {
176                best = Some((id, d2.sqrt()));
177            }
178        }
179        // A hit inside the box radius is final: anything closer would be in the box too.
180        if let Some((id, d)) = best {
181            if d <= radius {
182                return Some((id, d));
183            }
184        }
185        if radius >= max_distance {
186            return best.filter(|(_, d)| *d <= max_distance);
187        }
188        radius = (radius * 2.0).min(max_distance);
189    }
190}
191
192/// Whether `p` is inside the closed part mesh: parity of the part's vertical
193/// crossings above `p` under its column (brute force over the column's candidates).
194pub(crate) fn inside_part(part: &MeshSnapshot, index: &dyn TriangleIndex, p: Point3, scratch: &mut Vec<u32>) -> bool {
195    scratch.clear();
196    let q = Bounds3::new(Point3::new(p.x, p.y, f64::NEG_INFINITY), Point3::new(p.x, p.y, f64::INFINITY));
197    index.query_aabb(&q, ProjectionMode::Xy, scratch);
198    let mut hits: Vec<f64> = scratch
199        .iter()
200        .filter_map(|&id| {
201            let t = part.triangle(id);
202            crate::stock_sim::vertical_ray_hit(t.a, t.b, t.c, p.x, p.y)
203        })
204        .collect();
205    hits.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
206    hits.dedup();
207    hits.iter().filter(|&&z| z > p.z).count() % 2 == 1
208}
209
210/// Lowest part crossing of the vertical line through `(x, y)`, if the part is there.
211pub(crate) fn part_bottom_under(part: &MeshSnapshot, index: &dyn TriangleIndex, x: f64, y: f64, scratch: &mut Vec<u32>) -> Option<f64> {
212    scratch.clear();
213    let q = Bounds3::new(Point3::new(x, y, f64::NEG_INFINITY), Point3::new(x, y, f64::INFINITY));
214    index.query_aabb(&q, ProjectionMode::Xy, scratch);
215    scratch
216        .iter()
217        .filter_map(|&id| {
218            let t = part.triangle(id);
219            crate::stock_sim::vertical_ray_hit(t.a, t.b, t.c, x, y)
220        })
221        .fold(None, |m: Option<f64>, z| Some(m.map_or(z, |m| m.min(z))))
222}
223
224/// Highest part surface under column `(x, y)` and its face, if the part is there.
225fn part_top_under(part: &MeshSnapshot, index: &dyn TriangleIndex, x: f64, y: f64, scratch: &mut Vec<u32>) -> Option<(f64, u32)> {
226    scratch.clear();
227    let q = Bounds3::new(Point3::new(x, y, f64::NEG_INFINITY), Point3::new(x, y, f64::INFINITY));
228    index.query_aabb(&q, ProjectionMode::Xy, scratch);
229    let mut best: Option<(f64, u32)> = None;
230    for &id in scratch.iter() {
231        let t = part.triangle(id);
232        if let Some(z) = crate::stock_sim::vertical_ray_hit(t.a, t.b, t.c, x, y) {
233            if best.map_or(true, |(bz, _)| z > bz) {
234                best = Some((z, t.face));
235            }
236        }
237    }
238    best
239}
240
241/// Compare the stock with the part. `part` is the target bodies' snapshot in
242/// machine coordinates (protected bodies excluded); `index` is built on it.
243/// One sampled column whose stock stands more than the tolerance off the part.
244#[derive(Clone, Debug, PartialEq)]
245pub struct ExcessSample {
246    /// The stock surface sampled (the column's top), in the map's frame.
247    pub point: Point3,
248    /// Its distance to the part, > the map's tolerance.
249    pub excess: f64,
250    /// Index into the part snapshot's `faces`: the nearest face.
251    pub face: u32,
252    /// The part point nearest to `point`.
253    pub nearest: Point3,
254}
255
256/// The columns of `map` that read excess, with the stock point, the excess and
257/// the nearest part face and point, in column order. `part`, `index` and
258/// `options` must be the ones `map` was computed with. Lets a caller place the
259/// material left (against trimmed or unreachable toolpath regions, say) where a
260/// per-face summary only counts it.
261pub fn excess_samples(map: &DeviationMap, part: &MeshSnapshot, index: &dyn TriangleIndex, options: &DeviationOptions) -> Vec<ExcessSample> {
262    let start_radius = map.cell_x.max(map.cell_y);
263    let mut out = Vec::new();
264    for iy in 0..map.ny {
265        for ix in 0..map.nx {
266            let k = (iy * map.nx + ix) as usize;
267            let (Some(excess), Some(z), Some(face)) = (map.values[k], map.heights[k], map.faces_of[k]) else { continue };
268            if excess <= map.tolerance {
269                continue;
270            }
271            let point = Point3::new(map.origin.x + (ix as f64 + 0.5) * map.cell_x, map.origin.y + (iy as f64 + 0.5) * map.cell_y, z);
272            // An excess reading is the distance to this nearest triangle.
273            let Some((tri, _)) = nearest_triangle(part, index, point, start_radius, options.max_distance) else { continue };
274            let t = part.triangle(tri);
275            out.push(ExcessSample { point, excess, face, nearest: closest_point_on_triangle(point, t.a, t.b, t.c) });
276        }
277    }
278    out
279}
280
281pub fn deviation_map(grid: &DexelGrid, part: &MeshSnapshot, index: &dyn TriangleIndex, options: &DeviationOptions) -> DeviationMap {
282    let n = grid.columns.len();
283    let mut values = vec![None; n];
284    let mut heights = vec![None; n];
285    let mut faces_of = vec![None; n];
286    let mut per_face: Vec<Option<FaceDeviation>> = vec![None; part.faces.len()];
287    let mut placement = vec![None; n];
288    let (mut below_part, mut below_part_volume) = (0u64, 0.0f64);
289    // The part's lowest point: stock entirely under it is below the part.
290    let part_bottom = part.bounds.min.z;
291    let (mut min, mut max) = (f64::INFINITY, f64::NEG_INFINITY);
292    let (mut within, mut excess, mut gouged) = (0u64, 0u64, 0u64);
293    let start_radius = grid.cell_x.max(grid.cell_y);
294    let mut scratch = Vec::new();
295    for iy in 0..grid.ny {
296        for ix in 0..grid.nx {
297            let k = (iy * grid.nx + ix) as usize;
298            let (x, y) = grid.center(ix, iy);
299            let column = grid.column(ix, iy);
300            // Sample point: the stock top, or the stock floor where everything is gone.
301            let (z, empty) = match column.top() {
302                Some(top) => (top, false),
303                None => (grid.z_range[0], true),
304            };
305            let p = Point3::new(x, y, z);
306            let (deviation, face) = if empty {
307                // Everything is gone: a gouge of the part's full height here, if the part is here.
308                match part_top_under(part, index, x, y, &mut scratch) {
309                    Some((top, face)) if top > z => {
310                        placement[k] = Some(ColumnPlacement::AbovePart);
311                        (-(top - z), face)
312                    }
313                    _ => continue,
314                }
315            } else {
316                // Stock under the part's lowest surface is below the part: counted
317                // with its volume, never measured to the nearest face.
318                // Where the part is in this column, only stock strictly under its
319                // lowest surface there is below it (stock ending AT that surface
320                // means the part above was cut away: a gouge). Where it is not
321                // (beside the part, a through hole), stock reaching no higher than
322                // the part's lowest point is below it: the block under a flange
323                // exit, flush with it.
324                let local_bottom = part_bottom_under(part, index, x, y, &mut scratch);
325                let below = match local_bottom {
326                    Some(b) => z < b - options.tolerance,
327                    None => {
328                        // The part beside this column: its bottom next to the nearest
329                        // part point (a bolt hole's flange, not the whole part's
330                        // lowest point, which may lie far lower elsewhere).
331                        let beside = nearest_triangle(part, index, p, start_radius, options.max_distance).and_then(|(tri, _)| {
332                            let t = part.triangle(tri);
333                            let q = closest_point_on_triangle(p, t.a, t.b, t.c);
334                            part_bottom_beside(part, index, p, q, 1e-3 * grid.cell_x.min(grid.cell_y), &mut scratch)
335                        });
336                        z <= beside.unwrap_or(part_bottom) + options.tolerance
337                    }
338                };
339                if below {
340                    placement[k] = Some(ColumnPlacement::BelowPart);
341                    below_part += 1;
342                    below_part_volume += column.height() * grid.cell_x * grid.cell_y;
343                    continue;
344                }
345                placement[k] = Some(if local_bottom.is_some() { ColumnPlacement::AbovePart } else { ColumnPlacement::WithinExtent });
346                // The part is in this column but the stock ends at (or under) its
347                // lowest surface: the part's whole height here is gone, a gouge of
348                // that height, as for an emptied column. (The top sample sits on the
349                // part's bottom face and would read 0.)
350                if let Some(b) = local_bottom {
351                    if z <= b + options.tolerance {
352                        if let Some((top, face)) = part_top_under(part, index, x, y, &mut scratch) {
353                            if top > z + options.tolerance {
354                                let deviation = -(top - z);
355                                values[k] = Some(deviation);
356                                heights[k] = Some(z);
357                                faces_of[k] = Some(face);
358                                min = min.min(deviation);
359                                max = max.max(deviation);
360                                gouged += 1;
361                                let entry = per_face[face as usize].get_or_insert_with(|| FaceDeviation {
362                                    face,
363                                    solid: part.faces[face as usize].solid.clone(),
364                                    face_name: part.faces[face as usize].face.clone(),
365                                    samples: 0,
366                                    min: f64::INFINITY,
367                                    max: f64::NEG_INFINITY,
368                                    mean: 0.0,
369                                    gouged: 0,
370                                    excess: 0,
371                                });
372                                entry.samples += 1;
373                                entry.min = entry.min.min(deviation);
374                                entry.max = entry.max.max(deviation);
375                                entry.mean += deviation;
376                                entry.gouged += 1;
377                                continue;
378                            }
379                        }
380                    }
381                }
382                let inside = inside_part(part, index, p, &mut scratch);
383                let Some((tri, distance)) = nearest_triangle(part, index, p, start_radius, options.max_distance) else {
384                    continue;
385                };
386                (if inside { -distance } else { distance }, part.triangle(tri).face)
387            };
388            values[k] = Some(deviation);
389            heights[k] = Some(z);
390            faces_of[k] = Some(face);
391            min = min.min(deviation);
392            max = max.max(deviation);
393            let class = if deviation.abs() <= options.tolerance {
394                within += 1;
395                DeviationClass::Within
396            } else if deviation > 0.0 {
397                excess += 1;
398                DeviationClass::Excess
399            } else {
400                gouged += 1;
401                DeviationClass::Gouge
402            };
403            let entry = per_face[face as usize].get_or_insert_with(|| FaceDeviation {
404                face,
405                solid: part.faces[face as usize].solid.clone(),
406                face_name: part.faces[face as usize].face.clone(),
407                samples: 0,
408                min: f64::INFINITY,
409                max: f64::NEG_INFINITY,
410                mean: 0.0,
411                gouged: 0,
412                excess: 0,
413            });
414            entry.samples += 1;
415            entry.min = entry.min.min(deviation);
416            entry.max = entry.max.max(deviation);
417            entry.mean += deviation;
418            match class {
419                DeviationClass::Gouge => entry.gouged += 1,
420                DeviationClass::Excess => entry.excess += 1,
421                DeviationClass::Within => {}
422            }
423        }
424    }
425    let faces: Vec<FaceDeviation> = per_face
426        .into_iter()
427        .flatten()
428        .map(|mut f| {
429            f.mean /= f.samples as f64;
430            f
431        })
432        .collect();
433    if min > max {
434        min = 0.0;
435        max = 0.0;
436    }
437    DeviationMap {
438        origin: grid.origin,
439        cell_x: grid.cell_x,
440        cell_y: grid.cell_y,
441        nx: grid.nx,
442        ny: grid.ny,
443        values,
444        heights,
445        faces_of,
446        faces,
447        tolerance: options.tolerance,
448        min,
449        max,
450        within,
451        excess,
452        gouged,
453        placement,
454        below_part,
455        below_part_volume,
456    }
457}
458