Skip to main content

brep_cam/turning/
tooling.rs

1use crate::{CamError,Point3,Tool,ToolKind};
2use serde::{Serialize,Deserialize};
3use std::f64::consts::TAU;
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
6#[derive(Clone,Copy,Debug,PartialEq,Eq,Serialize,Deserialize)]
7pub enum InsertShape { C,D,V,T,W,R,S }
8
9/// ISO shape letter determines the included corner angle. For a round insert,
10/// edge_length means diameter and nose_radius must be half that diameter.
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12#[derive(Clone,Debug,PartialEq,Serialize,Deserialize)]
13pub struct Insert {
14    pub shape:InsertShape,
15    pub edge_length:f64,
16    pub nose_radius:f64,
17    pub clearance_deg:f64,
18    /// Rotation of the insert in XZ, in addition to holder orientation.
19    pub lead_angle_deg:f64,
20    pub holder_angle_deg:f64,
21}
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[derive(Clone,Debug,PartialEq,Serialize,Deserialize)]
24pub struct Blade {pub width:f64,pub reach:f64,pub corner_radius:f64,pub holder_angle_deg:f64}
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[derive(Clone,Debug,PartialEq,Serialize,Deserialize)]
27pub struct ThreadInsert {pub pitch_min:f64,pub pitch_max:f64,pub angle_deg:f64,pub reach:f64,pub nose_radius:f64,pub holder_angle_deg:f64}
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[derive(Clone,Debug,PartialEq,Serialize,Deserialize)]
30pub struct BoringBar {pub diameter:f64,pub reach:f64,pub insert:Insert,pub insert_offset:Point3}
31
32/// Geometry supplement for the shared Tool; not a replacement Tool model.
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[derive(Clone,Debug,PartialEq,Serialize,Deserialize)]
35#[serde(tag="kind",rename_all="camelCase")]
36pub enum TurningGeometry {Insert(Insert),Blade(Blade),Thread(ThreadInsert),BoringBar(BoringBar)}
37
38/// Conservative convex XZ envelope about its geometric reference. Insert
39/// reference is its centre; blade reference is the middle of its cutting edge.
40/// `reference_to_tip` maps the reference to the selected cutting tip centre.
41/// Callers must translate by this offset when their toolpath uses that tip.
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[derive(Clone,Debug,PartialEq,Serialize,Deserialize)]
44pub struct ToolEnvelope {
45    pub vertices:Vec<Point3>,
46    pub reference_to_tip:Point3,
47    pub chord_bound:f64,
48    /// Rake/clearance is a 3D accessibility condition, not a planar shrink.
49    pub clearance_deg:f64,
50}
51impl ToolEnvelope {
52    pub fn validate(&self)->Result<(),CamError> {
53        if self.vertices.len()<3 || self.vertices.iter().any(|p|!p.is_finite() || p.y!=0.) || !self.reference_to_tip.is_finite() || self.reference_to_tip.y!=0. || !positive(self.chord_bound) {return Err(invalid("invalid turning envelope"));}
54        Ok(())
55    }
56    pub fn support(&self,direction:Point3)->Result<f64,CamError> {
57        self.validate()?;
58        if !direction.is_finite() || direction.y!=0. {return Err(invalid("turning support direction must lie in XZ"));}
59        Ok(self.vertices.iter().map(|p|p.dot(direction)).fold(f64::NEG_INFINITY,f64::max))
60    }
61}
62fn invalid(s:&str)->CamError {CamError::InvalidInput(s.into())}
63fn positive(v:f64)->bool {v.is_finite() && v>0.}
64fn rotate(p:Point3,angle:f64)->Point3 {let (s,c)=angle.sin_cos();Point3::new(c*p.x-s*p.z,0.,s*p.x+c*p.z)}
65fn p(x:f64,z:f64)->Point3 {Point3::new(x,0.,z)}
66
67/// Conservative polygonization uses intersections of adjacent arc tangents,
68/// never inscribed chords. Maximum arc excess is bounded by `chord`.
69pub fn envelope(tool:&Tool,geometry:&TurningGeometry,chord:f64)->Result<ToolEnvelope,CamError> {
70    if !matches!(tool.kind,ToolKind::LatheInsert|ToolKind::BoringBar) || !positive(chord) {return Err(invalid("turning envelope needs a lathe tool and positive chord tolerance"));}
71    let result=match geometry {
72        TurningGeometry::Insert(g)=>insert_envelope(g,chord),
73        TurningGeometry::Blade(g)=>{
74            if !positive(g.width) || !positive(g.reach) || !g.corner_radius.is_finite() || g.corner_radius<0. || 2.*g.corner_radius>g.width.min(g.reach) || !g.holder_angle_deg.is_finite() {return Err(invalid("invalid grooving blade dimensions"));}
75            let raw=vec![p(0.,-g.width/2.),p(g.reach,-g.width/2.),p(g.reach,g.width/2.),p(0.,g.width/2.)];
76            let mut outline=rounded_polygon(&raw,g.corner_radius,chord)?;
77            // Only the two cutting-edge corners are rounded. Preserve square
78            // rear corners so the blade body is never under-enveloped.
79            outline.extend([p(g.reach,-g.width/2.),p(g.reach,g.width/2.)]);
80            let vertices=hull(outline).into_iter().map(|v|rotate(v,g.holder_angle_deg.to_radians())).collect();
81            Ok(ToolEnvelope{vertices,reference_to_tip:Point3::ZERO,chord_bound:chord,clearance_deg:0.})
82        }
83        TurningGeometry::Thread(g)=>{
84            if !positive(g.pitch_min) || g.pitch_max<g.pitch_min || !g.pitch_max.is_finite() || !positive(g.reach) || !g.angle_deg.is_finite() || g.angle_deg<=0. || g.angle_deg>=180. || !g.nose_radius.is_finite() || g.nose_radius<0. || !g.holder_angle_deg.is_finite() {return Err(invalid("invalid threading insert dimensions"));}
85            let half=g.reach*(0.5*g.angle_deg.to_radians()).tan();
86            let raw=vec![p(0.,0.),p(g.reach,-half),p(g.reach,half)];
87            let vertices=rounded_polygon(&raw,g.nose_radius,chord)?.into_iter().map(|v|rotate(v,g.holder_angle_deg.to_radians())).collect();
88            Ok(ToolEnvelope{vertices,reference_to_tip:rotate(p(g.nose_radius/(0.5*g.angle_deg.to_radians()).sin(),0.),g.holder_angle_deg.to_radians()),chord_bound:chord,clearance_deg:0.})
89        }
90        TurningGeometry::BoringBar(g)=>{
91            if !positive(g.diameter) || !positive(g.reach) || !g.insert_offset.is_finite() || g.insert_offset.y!=0. {return Err(invalid("invalid boring bar dimensions"));}
92            let mut e=insert_envelope(&g.insert,chord)?;
93            for v in &mut e.vertices {*v=v.add(g.insert_offset);}
94            e.reference_to_tip=e.reference_to_tip.add(g.insert_offset);
95            e.vertices.extend([p(-g.diameter/2.,0.),p(g.diameter/2.,0.),p(g.diameter/2.,g.reach),p(-g.diameter/2.,g.reach)]);
96            e.vertices=hull(e.vertices);Ok(e)
97        }
98    }?;
99    result.validate()?;Ok(result)
100}
101fn insert_envelope(g:&Insert,chord:f64)->Result<ToolEnvelope,CamError> {
102    if !positive(g.edge_length) || !g.nose_radius.is_finite() || g.nose_radius<0. || !g.clearance_deg.is_finite() || !(0. ..90.).contains(&g.clearance_deg) || !g.lead_angle_deg.is_finite() || !g.holder_angle_deg.is_finite() {return Err(invalid("invalid turning insert dimensions"));}
103    let l=g.edge_length;let angle=(g.lead_angle_deg+g.holder_angle_deg).to_radians();
104    if g.shape==InsertShape::R {
105        if (g.nose_radius-l/2.).abs()>chord {return Err(invalid("round insert nose radius must equal half its diameter"));}
106        let vertices=circle(p(0.,0.),l/2.,0.,TAU,chord)?.into_iter().map(|v|rotate(v,angle)).collect();
107        return Ok(ToolEnvelope{vertices,reference_to_tip:Point3::ZERO,chord_bound:chord,clearance_deg:g.clearance_deg});
108    }
109    let raw=match g.shape {
110        InsertShape::S=>vec![p(l/2.,l/2.),p(-l/2.,l/2.),p(-l/2.,-l/2.),p(l/2.,-l/2.)],
111        InsertShape::T=>(0..3).map(|i|p(l/3_f64.sqrt()*(i as f64*TAU/3.).cos(),l/3_f64.sqrt()*(i as f64*TAU/3.).sin())).collect(),
112        InsertShape::W=>{let t=40_f64.to_radians().tan();let k=2.*t/(3_f64.sqrt()+t);let r=l/(1.+k*k-k).sqrt();(0..6).map(|i|{let radius=if i%2==0{r}else{k*r};let a=i as f64*TAU/6.;p(radius*a.cos(),radius*a.sin())}).collect()},
113        shape=>{let deg:f64=match shape {InsertShape::C=>80.,InsertShape::D=>55.,InsertShape::V=>35.,_=>unreachable!()};let a=l*(deg.to_radians()/2.).cos();let b=l*(deg.to_radians()/2.).sin();vec![p(a,0.),p(0.,b),p(-a,0.),p(0.,-b)]},
114    };
115    let centre=corner_centre(&raw,0,g.nose_radius)?;
116    let vertices=rounded_polygon(&raw,g.nose_radius,chord)?.into_iter().map(|v|rotate(v,angle)).collect();
117    Ok(ToolEnvelope{vertices,reference_to_tip:rotate(centre,angle),chord_bound:chord,clearance_deg:g.clearance_deg})
118}
119fn corner_centre(poly:&[Point3],i:usize,r:f64)->Result<Point3,CamError> {
120    let at=poly[i];let a=poly[(i+poly.len()-1)%poly.len()].sub(at).normalized().ok_or_else(||invalid("degenerate insert edge"))?;
121    let b=poly[(i+1)%poly.len()].sub(at).normalized().ok_or_else(||invalid("degenerate insert edge"))?;
122    let bisector=a.add(b).normalized().ok_or_else(||invalid("degenerate insert corner"))?;
123    let sin=(0.5*(1.-a.dot(b))).max(0.).sqrt();
124    if sin<=0. {return Err(invalid("degenerate insert angle"));}
125    Ok(at.add(bisector.scale(r/sin)))
126}
127fn rounded_polygon(poly:&[Point3],r:f64,chord:f64)->Result<Vec<Point3>,CamError> {
128    if r==0. {return Ok(poly.to_vec());}
129    let mut out=Vec::new();
130    for i in 0..poly.len() {
131        let at=poly[i];let prev=poly[(i+poly.len()-1)%poly.len()];let next=poly[(i+1)%poly.len()];
132        let a=prev.sub(at).normalized().unwrap();let b=next.sub(at).normalized().unwrap();
133        let centre=corner_centre(poly,i,r)?;
134        let along=centre.sub(at).dot(a);
135        if along>0.5*at.distance(prev) || along>0.5*at.distance(next) {return Err(invalid("nose radius cannot fit insert edges"));}
136        let from=at.add(a.scale(along)).sub(centre);let to=at.add(b.scale(along)).sub(centre);
137        let start=from.z.atan2(from.x);let sweep=(to.z.atan2(to.x)-start).rem_euclid(TAU);
138        out.extend(circle(centre,r,start,sweep,chord)?);
139    }
140    Ok(out)
141}
142fn circle(centre:Point3,r:f64,start:f64,sweep:f64,chord:f64)->Result<Vec<Point3>,CamError> {
143    let max_angle=2.*(r/(r+chord)).acos();
144    let steps=(sweep/max_angle).ceil().max(1.);
145    if !steps.is_finite() || steps>100_000. {return Err(invalid("turning envelope exceeds arc work budget"));}
146    let n=steps as usize;let step=sweep/n as f64;
147    let mut out=vec![centre.add(p(r*start.cos(),r*start.sin()))];
148    for i in 0..n {let a=start+(i as f64+0.5)*step;let radius=r/(step*0.5).cos();out.push(centre.add(p(radius*a.cos(),radius*a.sin())));}
149    let end=start+sweep;out.push(centre.add(p(r*end.cos(),r*end.sin())));Ok(out)
150}
151fn cross(a:Point3,b:Point3,c:Point3)->f64 {(b.x-a.x)*(c.z-a.z)-(b.z-a.z)*(c.x-a.x)}
152fn hull(mut points:Vec<Point3>)->Vec<Point3> {
153    points.sort_by(|a,b|a.x.total_cmp(&b.x).then(a.z.total_cmp(&b.z)));points.dedup();
154    let chain=|iter:Vec<Point3>| {let mut c=Vec::new();for p in iter {while c.len()>=2 && cross(c[c.len()-2],c[c.len()-1],p)<=0. {c.pop();}c.push(p);}c};
155    let mut lower=chain(points.clone());let mut upper=chain(points.into_iter().rev().collect());lower.pop();upper.pop();lower.extend(upper);lower
156}
157
158impl ToolEnvelope {
159    /// Convex envelope swept from one TIP-reference position to another. This
160    /// is collision construction only, not a profile offset/compensation engine.
161    pub fn swept_vertices(&self,start:Point3,end:Point3)->Result<Vec<Point3>,CamError> {
162        self.validate()?;
163        if !start.is_finite() || !end.is_finite() || start.y!=0. || end.y!=0. {return Err(invalid("turning sweep must lie in XZ"));}
164        Ok(hull(self.vertices.iter().flat_map(|v|[v.sub(self.reference_to_tip).add(start),v.sub(self.reference_to_tip).add(end)]).collect()))
165    }
166    /// Conservative collision against a filled, simple material polygon.
167    /// Touching counts as collision; the caller explicitly supplies any allowed
168    /// finishing tolerance through the profile representation.
169    pub fn collides(&self,start:Point3,end:Point3,material:&[Point3])->Result<bool,CamError> {
170        if material.len()<3 || material.iter().any(|p|!p.is_finite() || p.y!=0.) {return Err(invalid("material section needs finite XZ polygon vertices"));}
171        let swept=self.swept_vertices(start,end)?;
172        for i in 0..swept.len() {for j in 0..material.len() {
173            if segments_touch(swept[i],swept[(i+1)%swept.len()],material[j],material[(j+1)%material.len()]) {return Ok(true);}
174        }}
175        Ok(inside(swept[0],material)||inside(material[0],&swept))
176    }
177}
178fn segments_touch(a:Point3,b:Point3,c:Point3,d:Point3)->bool {
179    if a.x.max(b.x)<c.x.min(d.x) || c.x.max(d.x)<a.x.min(b.x) || a.z.max(b.z)<c.z.min(d.z) || c.z.max(d.z)<a.z.min(b.z) {return false;}
180    let [u,v,w,t]=[cross(a,b,c),cross(a,b,d),cross(c,d,a),cross(c,d,b)];
181    ((u<=0. && v>=0.)||(v<=0. && u>=0.)) && ((w<=0. && t>=0.)||(t<=0. && w>=0.))
182}
183fn inside(p:Point3,poly:&[Point3])->bool {
184    let mut yes=false;
185    for i in 0..poly.len() {let a=poly[i];let b=poly[(i+1)%poly.len()];if (a.z>p.z)!=(b.z>p.z) && p.x<(b.x-a.x)*(p.z-a.z)/(b.z-a.z)+a.x {yes=!yes;}}
186    yes
187}