Skip to main content

brep_cam/pattern/
transform.rs

1use crate::{CamError,Point3,Runner};
2use crate::model::{RigidTransform,Toolpath,MotionKind,ArcPlane};
3use crate::link::Pass;
4use crate::multiaxis::indexed::linearize_arc;
5fn invalid(s:&str)->CamError {CamError::InvalidInput(format!("pattern: {s}"))}
6/// Derived placement. Reflections are deliberately not mislabeled as a proper
7/// RigidTransform (which rejects a negative determinant).
8#[derive(Clone,Debug)]
9pub enum Placement { Rigid(RigidTransform), Reflection{origin:Point3,normal:Point3} }
10impl Placement {
11    pub fn rigid(t:RigidTransform)->Result<Self,CamError> {
12        t.validate()?;
13        if t.apply_vector(Point3::Z).distance(Point3::Z)>1e-10 {return Err(invalid("only +Z-preserving placements are supported; use indexed generation for tilted instances"));}
14        Ok(Self::Rigid(t))
15    }
16    pub fn mirror(origin:Point3,normal:Point3)->Result<Self,CamError> {
17        let n=normal.normalized().ok_or_else(||invalid("mirror normal is zero or invalid"))?;
18        if !origin.is_finite() || n.z.abs()>1e-10 {return Err(invalid("mirror plane must be vertical and finite"));}
19        Ok(Self::Reflection{origin,normal:n})
20    }
21    pub fn point(&self,p:Point3)->Point3 {match self {
22        Self::Rigid(t)=>t.apply_point(p),Self::Reflection{origin,normal}=>p.sub(normal.scale(2.*p.sub(*origin).dot(*normal))),
23    }}
24    pub fn reverses(&self)->bool {matches!(self,Self::Reflection{..})}
25}
26/// Extract CUT geometry only, discard old leads/links and re-link later. Every
27/// reflected pass reverses traversal, retaining the source material side. Arcs
28/// become bounded chords before reflection; therefore reflected arc sense and
29/// traversal reversal are both represented in the resulting ordered points.
30/// Cycles, multi-axis/lathe/manual/control motions refuse rather than disappear.
31pub fn transform_passes(path:&Toolpath,placement:&Placement,chord:f64,runner:&mut Runner<'_>)->Result<Vec<Pass>,CamError> {
32    runner.token().check()?;
33    if !path.report.is_usable() || !chord.is_finite() || chord<=0. || path.motions.len()>200_000 {return Err(invalid("unusable path or invalid transform work budget"));}
34    let placement=match placement {Placement::Rigid(t)=>Placement::rigid(t.clone())?,Placement::Reflection{origin,normal}=>Placement::mirror(*origin,*normal)?};
35    let mut out=Vec::new();let mut current:Option<Pass>=None;let mut at:Option<Point3>=None;let mut count=0usize;
36    for m in &path.motions {
37        runner.work(1,"pattern-transform",count as u64,0)?;
38        if !m.kind.is_finite() || m.tool_axis.is_some_and(|a|a.distance(Point3::Z)>1e-10) {return Err(invalid("requires finite +Z source motions"));}
39        let points=match &m.kind {
40            MotionKind::Cut{to}=>Some(vec![*to]),
41            MotionKind::ArcCw{to,center,plane,pitch}|MotionKind::ArcCcw{to,center,plane,pitch}=>{
42                if *plane!=ArcPlane::Xy || *pitch!=0. {return Err(invalid("pattern requires planar XY arcs; helical/indexed arcs must be explicitly expanded"));}
43                let start=at.ok_or_else(||invalid("arc has no start"))?;
44                let radius=start.distance(*center);
45                if radius<=0. || (to.distance(*center)-radius).abs()>1e-9*radius.max(1.) || (start.z-to.z).abs()>1e-9 || (start.z-center.z).abs()>1e-9 {return Err(invalid("source arc has inconsistent radius or plane"));}
46                let pts=linearize_arc(start,*to,*center,*plane,*pitch,matches!(m.kind,MotionKind::ArcCw{..}),chord);
47                // Independently pin the subdivision cap's sag bound.
48                let r=start.distance(*center);
49                if pts.is_empty() || pts.windows(2).any(|w|w[0].lerp(w[1],0.5).distance(*center)<r-chord-1e-9) || start.lerp(pts[0],0.5).distance(*center)<r-chord-1e-9 {return Err(invalid("arc chord budget cannot meet tolerance"));}
50                Some(pts)
51            }
52            MotionKind::Rapid{..}|MotionKind::Plunge{..}|MotionKind::Retract{..}|MotionKind::Link{..}|MotionKind::LeadIn{..}|MotionKind::LeadOut{..}=>None,
53            _=>return Err(invalid("source contains unsupported control/cycle/rotary/manual motions")),
54        };
55        if let Some(points)=points {
56            let start=at.ok_or_else(||invalid("cut has no start"))?;
57            if current.as_ref().is_some_and(|p|p.path_id!=m.path_id) {out.push(current.take().unwrap());}
58            let p=current.get_or_insert_with(||Pass::new(vec![placement.point(start)],m.path_id));
59            count+=points.len();if count>200_000 {return Err(invalid("transformed point budget exceeded"));}
60            p.points.extend(points.into_iter().map(|p|placement.point(p)));
61        } else if let Some(p)=current.take() {out.push(p);}
62        if let Some(p)=m.end(){at=Some(p);}
63    }
64    if let Some(p)=current {out.push(p);}
65    if out.is_empty(){return Err(invalid("source has no cut passes"));}
66    if placement.reverses(){for p in &mut out {p.points.reverse();}}
67    runner.token().check()?;Ok(out)
68}