Skip to main content

brep_cam/cutter/
solid.rs

1//! The tool solid: ONE representation of the physical tool for preview,
2//! material-removal simulation and holder collision.
3//!
4//! A [`ToolProfile`] is the tool's radius-versus-height polyline from the tip
5//! (height 0) up through the cutting profile, the shank and the holder, each
6//! vertex tagged with the part it belongs to. Everything else derives from it:
7//! [`ToolProfile::radius_at_height`] is the collision envelope,
8//! [`ToolProfile::mesh`] revolves it into a closed triangle mesh, and
9//! [`ToolProfile::swept_mesh`] builds the volume swept along a linear move.
10//! Meshes are plain arrays (no BREP topology) and build identically on every
11//! target; the same inputs give the same bytes.
12
13use super::{Cutter, CutterProfile, SegmentKind};
14use crate::model::{Bounds3, Holder, Point3};
15use serde::{Deserialize, Serialize};
16
17/// Which part of the tool a profile point or triangle belongs to.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum ToolPart {
21    Cutting,
22    Shank,
23    Holder,
24}
25
26/// One vertex of the radius-versus-height polyline.
27#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ProfilePoint {
30    pub radius: f64,
31    pub height: f64,
32    pub part: ToolPart,
33}
34
35/// The tool as a revolved polyline, tip at the origin, axis +Z.
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ToolProfile {
39    /// Heights never decrease from the first point (the tip, radius 0 for
40    /// pointed tools or the flat bottom's edge) to the last (the top).
41    pub points: Vec<ProfilePoint>,
42}
43
44/// Default chordal deviation of a revolved arc in model units.
45pub const DEFAULT_CHORD: f64 = 0.01;
46/// Default number of segments around the axis.
47pub const DEFAULT_RADIAL_SEGMENTS: usize = 32;
48
49impl ToolProfile {
50    /// Profile of `cutter` (plus `holder`, stacked on top of the shank), arcs
51    /// sampled so no chord deviates more than `chord` from the true profile.
52    pub fn new(cutter: &Cutter, holder: Option<&Holder>, chord: f64) -> Self {
53        let chord = if chord.is_finite() && chord > 0.0 { chord } else { DEFAULT_CHORD };
54        let mut points: Vec<ProfilePoint> = Vec::new();
55        // A repeated position re-tags the point with the later part, so the
56        // band above it (attributed to its lower point) belongs to that part.
57        let mut push = |radius: f64, height: f64, part: ToolPart| {
58            let p = ProfilePoint { radius: radius.max(0.0), height, part };
59            match points.last_mut() {
60                Some(last) if last.radius == p.radius && last.height == p.height => last.part = part,
61                _ => points.push(p),
62            }
63        };
64        for seg in cutter.segments() {
65            let [r0, r1] = seg.radius_range;
66            let [h0, h1] = seg.height_range;
67            match seg.kind {
68                SegmentKind::Flat | SegmentKind::Cone | SegmentKind::Cylinder | SegmentKind::ConeInverted => {
69                    push(r0, h0, ToolPart::Cutting);
70                    push(r1, h1, ToolPart::Cutting);
71                }
72                SegmentKind::Sphere | SegmentKind::Torus | SegmentKind::SphereUpper => {
73                    // Arc of radius rho about (ring_radius, z_offset) from the
74                    // angle at r0 to the angle at r1, measured from straight down;
75                    // an upper arc continues past the equator (angle > 90°).
76                    let rho = seg.profile_radius;
77                    let center_r = if seg.kind == SegmentKind::Torus { seg.ring_radius } else { 0.0 };
78                    let upper = seg.kind == SegmentKind::SphereUpper;
79                    let angle_at = |r: f64| {
80                        let a = ((r - center_r) / rho).clamp(-1.0, 1.0).asin();
81                        if upper { std::f64::consts::PI - a } else { a }
82                    };
83                    let (a0, a1) = (angle_at(r0), angle_at(r1));
84                    let steps = arc_steps(rho, (a1 - a0).abs(), chord);
85                    push(r0, h0, ToolPart::Cutting);
86                    for i in 1..steps {
87                        let a = a0 + (a1 - a0) * i as f64 / steps as f64;
88                        push(center_r + rho * a.sin(), seg.z_offset - rho * a.cos(), ToolPart::Cutting);
89                    }
90                    push(r1, h1, ToolPart::Cutting);
91                }
92            }
93        }
94        let cutting_top = cutter.cutting_length();
95        let shank_r = cutter.shaft_radius();
96        let shank_top = cutting_top + cutter.shaft_length();
97        if cutter.shaft_length() > 0.0 {
98            push(shank_r, cutting_top, ToolPart::Shank);
99            push(shank_r, shank_top, ToolPart::Shank);
100        }
101        if let Some(holder) = holder {
102            let mut base = shank_top;
103            for seg in &holder.segments {
104                push(0.5 * seg.lower_diameter, base, ToolPart::Holder);
105                base += seg.height;
106                push(0.5 * seg.upper_diameter, base, ToolPart::Holder);
107            }
108        }
109        Self { points }
110    }
111
112    pub fn top_height(&self) -> f64 {
113        self.points.last().map_or(0.0, |p| p.height)
114    }
115
116    pub fn max_radius(&self) -> f64 {
117        self.points.iter().map(|p| p.radius).fold(0.0, f64::max)
118    }
119
120    /// The largest radius the tool (cutting part, shank and holder) occupies at
121    /// height `h`: the collision envelope. `None` outside `[0, top_height]`.
122    pub fn radius_at_height(&self, h: f64) -> Option<f64> {
123        if !h.is_finite() || h < 0.0 || h > self.top_height() || self.points.is_empty() {
124            return None;
125        }
126        let mut best: Option<f64> = None;
127        for w in self.points.windows(2) {
128            let (a, b) = (w[0], w[1]);
129            if h < a.height.min(b.height) || h > a.height.max(b.height) {
130                continue;
131            }
132            let r = if (b.height - a.height).abs() <= f64::EPSILON * (1.0 + h.abs()) {
133                a.radius.max(b.radius)
134            } else {
135                a.radius + (b.radius - a.radius) * (h - a.height) / (b.height - a.height)
136            };
137            best = Some(best.map_or(r, |x: f64| x.max(r)));
138        }
139        best
140    }
141
142    /// Height bands `[h0, h1]` (local, tip = 0) where the tool's radius is at
143    /// least `r`, each with the part owning it, in ascending order and merged
144    /// where adjacent bands share a part. A column at distance `r` from the
145    /// axis is touched exactly on these bands.
146    pub fn height_bands_reaching(&self, r: f64) -> Vec<([f64; 2], ToolPart)> {
147        let mut out: Vec<([f64; 2], ToolPart)> = Vec::new();
148        let mut push = |band: [f64; 2], part: ToolPart| {
149            if band[1] < band[0] {
150                return;
151            }
152            match out.last_mut() {
153                Some((last, lp)) if *lp == part && last[1] >= band[0] => last[1] = last[1].max(band[1]),
154                _ => out.push((band, part)),
155            }
156        };
157        for w in self.points.windows(2) {
158            let (a, b) = (w[0], w[1]);
159            if b.height < a.height {
160                continue;
161            }
162            let (ra, rb) = (a.radius, b.radius);
163            if ra < r && rb < r {
164                continue;
165            }
166            if b.height == a.height {
167                // A horizontal step: the step's disc touches at exactly this height.
168                if ra.max(rb) >= r {
169                    push([a.height, a.height], a.part);
170                }
171                continue;
172            }
173            // Radius is linear in height along the segment: solve radius >= r.
174            let (mut h0, mut h1) = (a.height, b.height);
175            if (rb - ra).abs() > 0.0 {
176                let h_cross = a.height + (r - ra) * (b.height - a.height) / (rb - ra);
177                if ra < r {
178                    h0 = h_cross.max(a.height);
179                } else if rb < r {
180                    h1 = h_cross.min(b.height);
181                }
182            }
183            push([h0, h1], a.part);
184        }
185        out
186    }
187
188    /// Local bounds of the tool solid.
189    pub fn bounds(&self) -> Bounds3 {
190        let r = self.max_radius();
191        Bounds3::new(Point3::new(-r, -r, 0.0), Point3::new(r, r, self.top_height()))
192    }
193
194    /// Revolve the profile into a closed triangle mesh with `radial_segments`
195    /// around the axis (at least 3), tip at `origin`.
196    pub fn mesh(&self, radial_segments: usize) -> ToolMesh {
197        self.swept_mesh(Point3::ZERO, Point3::ZERO, radial_segments)
198    }
199
200    /// One-sided radii of the polyline at height `h`: the largest radius just
201    /// below `h` and just above it (they differ at a step such as a holder
202    /// collar). `None` where the polyline has no point at or around `h`.
203    fn sided_radii(&self, h: f64) -> Option<(f64, f64)> {
204        let mut below: Option<f64> = None;
205        let mut above: Option<f64> = None;
206        let bump = |slot: &mut Option<f64>, r: f64| *slot = Some(slot.map_or(r, |x: f64| x.max(r)));
207        for w in self.points.windows(2) {
208            let (a, b) = (w[0], w[1]);
209            let (lo, hi) = (a.height.min(b.height), a.height.max(b.height));
210            if h < lo || h > hi {
211                continue;
212            }
213            if hi > lo {
214                let r = a.radius + (b.radius - a.radius) * (h - a.height) / (b.height - a.height);
215                if h > lo {
216                    bump(&mut below, r);
217                }
218                if h < hi {
219                    bump(&mut above, r);
220                }
221                if h == lo {
222                    bump(&mut above, r);
223                }
224                if h == hi {
225                    bump(&mut below, r);
226                }
227            } else {
228                // A horizontal step at exactly h joins the part below (ending at
229                // a) to the part above (starting at b).
230                bump(&mut below, a.radius);
231                bump(&mut above, b.radius);
232            }
233        }
234        if let Some(first) = self.points.first() {
235            if h == first.height {
236                bump(&mut above, first.radius);
237                bump(&mut below, first.radius);
238            }
239        }
240        if let Some(last) = self.points.last() {
241            if h == last.height {
242                bump(&mut below, last.radius);
243                bump(&mut above, last.radius);
244            }
245        }
246        match (below, above) {
247            (None, None) => None,
248            (b, a) => Some((b.or(a).unwrap(), a.or(b).unwrap())),
249        }
250    }
251
252    /// Part owning the polyline at height `h` (the highest-indexed point at or
253    /// below `h`), so an envelope keeps its preview colours.
254    pub fn part_at(&self, h: f64) -> ToolPart {
255        self.points.iter().rev().find(|p| p.height <= h).or(self.points.first()).map_or(ToolPart::Cutting, |p| p.part)
256    }
257
258    /// The vertical sweep of this profile over `[0, dz]`: the upper envelope
259    /// of the profile and its copy lifted by `dz`, as a polyline with the
260    /// steps of both. Exactly the solid swept by an axial move of length `dz`.
261    pub fn vertical_envelope(&self, dz: f64) -> ToolProfile {
262        if !(dz > 0.0) || !dz.is_finite() || self.points.is_empty() {
263            return self.clone();
264        }
265        let mut heights: Vec<f64> = self.points.iter().flat_map(|p| [p.height, p.height + dz]).collect();
266        heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
267        heights.dedup();
268        let mut out: Vec<ProfilePoint> = Vec::new();
269        let mut push = |radius: f64, height: f64, part: ToolPart| {
270            let p = ProfilePoint { radius, height, part };
271            match out.last_mut() {
272                Some(last) if last.radius == p.radius && last.height == p.height => last.part = part,
273                _ => out.push(p),
274            }
275        };
276        for h in heights {
277            let orig = self.sided_radii(h);
278            let lifted = self.sided_radii(h - dz);
279            let pick = |o: Option<f64>, l: Option<f64>| match (o, l) {
280                (Some(a), Some(b)) => Some(a.max(b)),
281                (a, b) => a.or(b),
282            };
283            let below = pick(orig.map(|r| r.0), lifted.map(|r| r.0));
284            let above = pick(orig.map(|r| r.1), lifted.map(|r| r.1));
285            let part = if orig.is_some() { self.part_at(h) } else { self.part_at(h - dz) };
286            if let Some(b) = below {
287                push(b, h, part);
288            }
289            if let Some(a) = above {
290                push(a, h, part);
291            }
292        }
293        ToolProfile { points: out }
294    }
295
296    /// The volume swept by the tool moving linearly from `start` to `end`
297    /// (tool reference points), as a closed mesh.
298    ///
299    /// Construction: the profile's [`vertical_envelope`](Self::vertical_envelope)
300    /// over the move's vertical extent, placed at the lower of the two heights,
301    /// swept horizontally between the two XY positions; each ring becomes a
302    /// stadium (back half circle at the start side, front half circle at the
303    /// end side, two straight edges). This is EXACT for horizontal moves, for
304    /// axial moves (plunges, retracts) and for a zero-length move (the tool
305    /// itself), and CONSERVATIVE (a superset) for moves with both a horizontal
306    /// and a vertical component. Exact sloped removal needs a per-column floor
307    /// evaluation, not a mesh.
308    pub fn swept_mesh(&self, start: Point3, end: Point3, radial_segments: usize) -> ToolMesh {
309        let n = radial_segments.max(3);
310        let d = end.sub(start);
311        let dz = d.z.abs();
312        let envelope = self.vertical_envelope(dz);
313        let z0 = start.z.min(end.z);
314        let a = Point3::new(start.x, start.y, z0);
315        let b = Point3::new(end.x, end.y, z0);
316        let dxy = Point3::new(d.x, d.y, 0.0);
317        // Unit direction of travel in XY; an axial or zero move uses +X.
318        let dir = dxy.normalized().unwrap_or(Point3::X);
319        let moving = dxy.length() > 0.0;
320        let half = (n / 2).max(1);
321        let ring_len = if moving { 2 * (half + 1) } else { n };
322        let mut mesh = ToolMesh::default();
323        let mut rings: Vec<(u32, &ProfilePoint)> = Vec::with_capacity(envelope.points.len());
324        for p in &envelope.points {
325            let base = mesh.positions.len() as u32;
326            mesh.push_ring(a, b, dir, moving, p.radius, p.height, n, half);
327            rings.push((base, p));
328        }
329        let Some((first_base, first)) = rings.first().copied() else {
330            return mesh;
331        };
332        // Bottom cap: the lowest ring closed inward (a point ring is skipped by
333        // the degenerate filter, so a pointed tool is closed by the next band).
334        if first.radius > 0.0 {
335            mesh.cap(first_base, ring_len, first.part, true);
336        }
337        // Bands between consecutive rings, attributed to the lower point's part.
338        for w in rings.windows(2) {
339            let ((ra, pa), (rb, _)) = (w[0], w[1]);
340            mesh.band(ra, rb, ring_len, pa.part);
341        }
342        if let Some((last_base, last)) = rings.last().copied() {
343            if last.radius > 0.0 {
344                mesh.cap(last_base, ring_len, last.part, false);
345            }
346        }
347        mesh.finish_groups();
348        mesh
349    }
350}
351
352/// Number of arc steps so the chord deviation stays within `chord`.
353fn arc_steps(rho: f64, sweep: f64, chord: f64) -> usize {
354    if !(rho > 0.0) || !(sweep > 0.0) {
355        return 1;
356    }
357    let max_step = 2.0 * (1.0 - (chord / rho).min(1.0)).acos();
358    if !(max_step > 0.0) {
359        return 1;
360    }
361    ((sweep / max_step).ceil() as usize).clamp(1, 4096)
362}
363
364/// A triangle group of one tool part.
365#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct ToolMeshGroup {
368    pub part: ToolPart,
369    /// Triangle index range `[start, end)` into `indices / 3`.
370    pub start: u32,
371    pub end: u32,
372}
373
374/// A plain closed triangle mesh of the tool, outward-wound.
375#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct ToolMesh {
378    pub positions: Vec<Point3>,
379    /// Three vertex indices per triangle, counter-clockwise seen from outside.
380    pub indices: Vec<u32>,
381    pub groups: Vec<ToolMeshGroup>,
382    #[serde(skip)]
383    pending: Vec<(ToolPart, u32)>,
384}
385
386impl ToolMesh {
387    pub fn triangle_count(&self) -> usize {
388        crate::mesh::core::MeshCore { positions: &self.positions, indices: &self.indices }.triangle_count()
389    }
390
391    pub fn triangle(&self, i: usize) -> [Point3; 3] {
392        crate::mesh::core::MeshCore { positions: &self.positions, indices: &self.indices }.triangle(i)
393    }
394
395    /// Signed volume by the divergence theorem; positive for outward winding.
396    pub fn signed_volume(&self) -> f64 {
397        crate::mesh::core::MeshCore { positions: &self.positions, indices: &self.indices }.signed_volume()
398    }
399
400    pub fn bounds(&self) -> Bounds3 {
401        crate::mesh::core::MeshCore { positions: &self.positions, indices: &self.indices }.bounds()
402    }
403
404    /// Every geometric edge is used exactly twice, once in each direction.
405    /// Vertices are welded by exact position first, because rings of a pointed
406    /// tip repeat the apex and a still ring's seam repeats no vertex.
407    pub fn is_closed(&self) -> bool {
408        crate::mesh::core::MeshCore { positions: &self.positions, indices: &self.indices }.is_closed()
409    }
410
411    /// A ring of the profile point `(radius, height)`. A still tool gets `n`
412    /// points around the axis at `start`; a moving tool gets the stadium: the
413    /// back half circle at `start` (n/2 + 1 points), then the front half circle at
414    /// `end` (n/2 + 1 points), all 2·(n/2 + 1) points counter-clockwise seen from above.
415    fn push_ring(&mut self, start: Point3, end: Point3, dir: Point3, moving: bool, radius: f64, height: f64, n: usize, half: usize) {
416        use std::f64::consts::PI;
417        let side = Point3::new(-dir.y, dir.x, 0.0);
418        let at = |c: Point3, ang: f64| {
419            c.add(dir.scale(radius * ang.cos())).add(side.scale(radius * ang.sin())).add(Point3::new(0.0, 0.0, height))
420        };
421        if !moving {
422            for i in 0..n {
423                self.positions.push(at(start, 2.0 * PI * i as f64 / n as f64));
424            }
425            return;
426        }
427        // Back half at start: angles from +90° (left side) through 180° to 270° (right side).
428        for i in 0..=half {
429            self.positions.push(at(start, 0.5 * PI + PI * i as f64 / half as f64));
430        }
431        // Front half at end: from 270° (right) through 0° to 90° (left).
432        for i in 0..=half {
433            self.positions.push(at(end, 1.5 * PI + PI * i as f64 / half as f64));
434        }
435    }
436
437    fn push_triangle(&mut self, part: ToolPart, a: u32, b: u32, c: u32) {
438        if a == b || b == c || a == c {
439            return;
440        }
441        let [pa, pb, pc] = [self.positions[a as usize], self.positions[b as usize], self.positions[c as usize]];
442        if pb.sub(pa).cross(pc.sub(pa)).length_squared() <= 0.0 {
443            return;
444        }
445        self.indices.extend([a, b, c]);
446        self.pending.push((part, (self.indices.len() / 3 - 1) as u32));
447    }
448
449    /// Quads between ring `a` (lower) and ring `b` (upper), wound outward.
450    fn band(&mut self, a: u32, b: u32, len: usize, part: ToolPart) {
451        let len = len as u32;
452        for i in 0..len {
453            let j = (i + 1) % len;
454            self.push_triangle(part, a + i, a + j, b + j);
455            self.push_triangle(part, a + i, b + j, b + i);
456        }
457    }
458
459    /// Fan over ring `base`; `down` caps the bottom (normal −Z), else the top.
460    fn cap(&mut self, base: u32, len: usize, part: ToolPart, down: bool) {
461        let center = self.positions.len() as u32;
462        let mut c = Point3::ZERO;
463        for i in 0..len {
464            c = c.add(self.positions[(base + i as u32) as usize]);
465        }
466        self.positions.push(c.scale(1.0 / len as f64));
467        let len = len as u32;
468        for i in 0..len {
469            let j = (i + 1) % len;
470            if down {
471                self.push_triangle(part, center, base + j, base + i);
472            } else {
473                self.push_triangle(part, center, base + i, base + j);
474            }
475        }
476    }
477
478    /// Collapse the per-triangle part tags into contiguous groups.
479    fn finish_groups(&mut self) {
480        let mut groups: Vec<ToolMeshGroup> = Vec::new();
481        for &(part, index) in &self.pending {
482            match groups.last_mut() {
483                Some(g) if g.part == part && g.end == index => g.end = index + 1,
484                _ => groups.push(ToolMeshGroup { part, start: index, end: index + 1 }),
485            }
486        }
487        self.groups = groups;
488        self.pending.clear();
489    }
490
491    /// Group for triangle `i`, if tagged.
492    pub fn part_of(&self, i: u32) -> Option<ToolPart> {
493        self.groups.iter().find(|g| g.start <= i && i < g.end).map(|g| g.part)
494    }
495}