Skip to main content

brep_cam/
dispatch.rs

1//! ONE entry point over every strategy: the generator the application's
2//! worker calls per operation, without knowing which lane wrote the strategy
3//! or what inputs it takes.
4//!
5//! Each lane's `generate` has its own signature — the drop-cutter finishes
6//! take a cutter, heights and feeds; the waterline family takes the setup,
7//! the tool and its limits; the 2.5D family a [`PlanarInput`] of regions;
8//! drilling the recognised holes — so this module builds those inputs from
9//! the operation, the setup, the document's tools and the mesh snapshot, and
10//! dispatches exhaustively on the strategy. Advanced model inputs arrive in
11//! GenerateContext; missing geometry is a named refusal, never a guessed path.
12//! Pattern expansion preserves program sections through generate_expanded.
13//!
14//! Hole recognition needs the kernel (the adapter's revolved faces), which
15//! lives on the worker side; the caller recognises holes there and passes
16//! them in, so this module stays kernel-free.
17
18use crate::cutter::{Cutter, CutterError};
19use crate::error::CamError;
20use crate::holes::Hole;
21use crate::index::TriangleIndex;
22use crate::mesh::MeshSnapshot;
23use crate::model::{FeedsSpeeds, Heights, Operation, Setup, Strategy, TargetRef, Tool, Toolpath};
24use crate::numeric::Tolerance;
25use crate::planar::extract::{face_outline, silhouette};
26use crate::planar::machining::{PlanarInput, PlanarRegion};
27use crate::Runner;
28
29pub mod chains;
30
31/// What one operation's generation reads.
32pub struct GenerateInput<'a> {
33    pub operation: &'a Operation,
34    pub setup: &'a Setup,
35    /// The document's tools: the operation's, and a rest finish's reference tool.
36    pub tools: &'a [Tool],
37    pub mesh: &'a MeshSnapshot,
38    pub index: Option<&'a dyn TriangleIndex>,
39    pub tolerance: Tolerance,
40    /// The holes recognised on the target bodies (drilling); empty otherwise.
41    pub holes: &'a [Hole],
42}
43
44fn invalid(message: impl Into<String>) -> CamError {
45    CamError::InvalidInput(message.into())
46}
47
48/// Resolve every picked face before generating any drilling motion. A face
49/// must identify a recognized hole on the same body; duplicate picks do not
50/// duplicate a hole. With no picks the established all-holes behavior remains.
51fn selected_holes<'a>(operation: &Operation, holes: &'a [Hole]) -> Result<std::borrow::Cow<'a, [Hole]>, CamError> {
52    let picks = &operation.targets.drive_faces;
53    if picks.is_empty() { return Ok(std::borrow::Cow::Borrowed(holes)); }
54    let matches = |pick: &TargetRef, hole: &Hole| pick.solid == hole.solid
55        && pick.face.as_ref().is_some_and(|face| hole.faces.contains(face));
56    for pick in picks {
57        if !holes.iter().any(|hole| matches(pick, hole)) {
58            return Err(invalid(format!("{}: picked face {}/{} is not a recognised hole", operation.id, pick.solid, pick.face.as_deref().unwrap_or("<body>"))));
59        }
60    }
61    Ok(std::borrow::Cow::Owned(holes.iter().filter(|hole| picks.iter().any(|pick| matches(pick, hole))).cloned().collect()))
62}
63
64/// The operation's tool, from the document's tools.
65pub fn tool_of<'a>(operation: &Operation, tools: &'a [Tool]) -> Result<&'a Tool, CamError> {
66    tools
67        .iter()
68        .find(|t| t.id == operation.tool)
69        .ok_or_else(|| invalid(format!("operation {} names tool `{}`, which the document does not have", operation.id, operation.tool)))
70}
71
72/// The heights an operation cuts at: its own override, else the setup's.
73pub fn heights_of(operation: &Operation, setup: &Setup) -> Heights {
74    operation.heights.clone().unwrap_or_else(|| setup.heights.clone())
75}
76
77/// The feeds an operation cuts at: its own override, else the tool's.
78pub fn feeds_of(operation: &Operation, tool: &Tool) -> FeedsSpeeds {
79    operation.feeds.clone().unwrap_or_else(|| tool.feeds.clone())
80}
81
82fn cutter_of(tool: &Tool) -> Result<Cutter, CamError> {
83    Cutter::from_tool(tool).map_err(|e: CutterError| CamError::Cutter(e))
84}
85
86/// The 2.5D input for an operation: one planar region per drive face (its
87/// projected outline, at the face's top) and, for an operation with no
88/// drive faces, the targets' silhouette (the projected union of every
89/// triangle, at the body's top); the stock top at the mesh's top. Nothing to
90/// outline is the model's empty-target error.
91pub fn planar_input(input: &GenerateInput<'_>) -> Result<PlanarInput, CamError> {
92    let op = input.operation;
93    let mesh = input.mesh;
94    let all: Vec<u32> = (0..mesh.triangle_count() as u32).collect();
95    let bounds = mesh.bounds_of(&all);
96    let mut regions = Vec::new();
97    let faces: Vec<TargetRef> = if op.targets.drive_faces.is_empty() {
98        op.targets.target_solids.iter().map(|s| TargetRef::solid(s.clone())).collect()
99    } else {
100        op.targets.drive_faces.clone()
101    };
102    for target in &faces {
103        let face_ids = mesh.resolve_faces(target);
104        if face_ids.is_empty() {
105            return Err(CamError::UnresolvedTarget { solid: target.solid.clone(), face: target.face.clone() });
106        }
107        let what = target.face.as_deref().unwrap_or(&target.solid).to_string();
108        let (region, z) = if target.is_whole_body() {
109            let region = silhouette(mesh, |f| face_ids.contains(&f), &input.tolerance)
110                .map_err(|e| invalid(format!("{}: the silhouette of {what} could not be built: {e:?}", op.id)))?;
111            let triangles = mesh.triangles_of_faces(&face_ids);
112            (region, mesh.bounds_of(&triangles).max.z)
113        } else {
114            let (region, z_range) = face_outline(mesh, |f| face_ids.contains(&f), &input.tolerance)
115                .map_err(|e| invalid(format!("{}: the outline of {what} could not be built: {e:?}", op.id)))?;
116            if matches!(op.strategy,Strategy::Slot(_)|Strategy::Chamfer2D(_)) && z_range[1]-z_range[0]>input.tolerance.distance_eps {
117                return Err(invalid(format!("{}: selected face must be horizontal",op.strategy.kind_name())));
118            }
119            (region, z_range[1])
120        };
121        if region.loops.is_empty() {
122            continue;
123        }
124        regions.push(PlanarRegion { region, z, upward: true });
125    }
126    if regions.is_empty() {
127        return Err(CamError::EmptyTarget { operation_id: op.id.as_str().to_string() });
128    }
129    // A contour follows the selected face boundary, or the whole-body
130    // silhouette. Keep loop winding: internal and external boundaries differ.
131    let chains = if let Strategy::Slot(params)=&op.strategy {
132        if op.targets.drive_faces.is_empty() {return Err(invalid("slot: select a horizontal rectangular slot floor"));}
133        regions.iter().map(|r|slot_floor_chain(r,params.width,&input.tolerance)).collect::<Result<Vec<_>,_>>()?
134    } else if matches!(op.strategy, Strategy::Contour2D(_)|Strategy::Chamfer2D(_)) {
135        regions.iter().flat_map(|r| r.region.loops.iter().map(move |l|
136            crate::planar::machining::PlanarChain { chain: crate::planar::Chain::from_loop(l), z: r.z }
137        )).collect()
138    } else { Vec::new() };
139    Ok(PlanarInput { regions, chains, circles: Vec::new(), stock_top: Some(bounds.max.z) })
140}
141
142/// Prefer explicitly planned geometry, never silently replace it with a silhouette.
143fn planar_for<'a>(input:&GenerateInput<'_>,context:&GenerateContext<'a>)->Result<std::borrow::Cow<'a,PlanarInput>,CamError> {
144    if let Some((_,geometry))=context.resolved_planar {
145        return Ok(std::borrow::Cow::Borrowed(geometry));
146    }
147    let geometry=if matches!(input.operation.strategy,Strategy::Contour2D(_)|Strategy::Slot(_)|Strategy::Trace(_)|Strategy::Chamfer2D(_)|Strategy::Engrave(_)) {chains::selected(input,context)?}else{planar_input(input)?};
148    Ok(std::borrow::Cow::Owned(geometry))
149}
150
151/// Resolve a straight rectangular floor only. Width is checked against the
152/// selected geometry; a bounding box alone is not evidence of a slot.
153fn slot_floor_chain(region: &PlanarRegion, width: f64, tol: &Tolerance) -> Result<crate::planar::machining::PlanarChain, CamError> {
154    use crate::planar::{Chain, Segment, Vec2};
155    use crate::Point2;
156    let fail = || invalid("slot: selected floor must be a horizontal rectangle matching slot width");
157    if region.region.loops.len()!=1 || !width.is_finite() || width<=0.0 { return Err(fail()); }
158    let l=&region.region.loops[0];
159    if l.segments.is_empty() || l.segments.iter().any(|s|s.arc.is_some()) { return Err(fail()); }
160    let edge=l.segments.iter().max_by(|a,b|a.start.distance(a.end).total_cmp(&b.start.distance(b.end))).unwrap();
161    let length=edge.start.distance(edge.end);
162    if length<=tol.point_eps { return Err(fail()); }
163    let u=Point2::new((edge.end.x-edge.start.x)/length,(edge.end.y-edge.start.y)/length);
164    let v=Point2::new(-u.y,u.x);
165    let project=|p:Point2| { let d=p.sub(edge.start); (d.dot(u),d.dot(v)) };
166    let mut bounds=[f64::INFINITY,f64::NEG_INFINITY,f64::INFINITY,f64::NEG_INFINITY];
167    for s in &l.segments { let (x,y)=project(s.start); bounds[0]=bounds[0].min(x);bounds[1]=bounds[1].max(x);bounds[2]=bounds[2].min(y);bounds[3]=bounds[3].max(y); }
168    let [x0,x1,y0,y1]=bounds;
169    let dx=x1-x0;let dy=y1-y0;let eps=tol.distance_eps;
170    if dx<=width+eps || (dy-width).abs()>eps { return Err(fail()); }
171    for s in &l.segments {
172        let (ax,ay)=project(s.start);let (bx,by)=project(s.end);
173        let vertical=(ax-bx).abs()<=eps && ((ax-x0).abs()<=eps || (ax-x1).abs()<=eps);
174        let horizontal=(ay-by).abs()<=eps && ((ay-y0).abs()<=eps || (ay-y1).abs()<=eps);
175        if !vertical && !horizontal {return Err(fail());}
176    }
177    if (l.signed_area().abs()-dx*dy).abs()>eps*(dx+dy) {return Err(fail());}
178    let point=|x:f64|Point2::new(edge.start.x+u.x*x+v.x*(y0+y1)*0.5,edge.start.y+u.y*x+v.y*(y0+y1)*0.5);
179    Ok(crate::planar::machining::PlanarChain {chain:Chain::open(vec![Segment::line(point(x0+width*0.5),point(x1-width*0.5))]),z:region.z})
180}
181
182/// Additional model data that cannot be reconstructed safely from triangles.
183/// The worker resolves these by identity; no arbitrary previous path is chosen.
184#[derive(Default)]
185pub struct GenerateContext<'a> {
186    /// Exact machine-space geometry from recognition/planning, bound to its operation.
187    pub resolved_planar: Option<(&'a crate::OperationId, &'a PlanarInput)>,
188    pub chain_geometry: Option<&'a dyn chains::ChainGeometry>,
189    pub machine: Option<&'a crate::Machine>,
190    pub flow_faces: &'a [&'a dyn crate::flow::ParametricFace],
191    /// Explicit inner operation, in machine coordinates (unwrapped for RotaryWrap).
192    pub source: Option<&'a crate::program::OperationInput>,
193    /// MultiAxisFinish source definition to generate on this mesh when no cached path
194    /// is supplied. Only self-contained three-axis finishing strategies qualify.
195    pub source_operation: Option<&'a Operation>,
196    /// Resolved pattern sources, with their original tool/setup identities.
197    pub available: &'a [crate::program::OperationInput],
198    pub rest_stock: Option<&'a crate::DexelGrid>,
199    /// Adapter-resolved stock in machine coordinates (required for FromSolid).
200    pub rough_stock: Option<&'a crate::stock::ResolvedStock>,
201    /// Explicit larger reference tool for Pocket3D rest roughing. The roughing
202    /// model currently has no reference-tool id; never choose one implicitly.
203    pub rough_reference_tool: Option<&'a crate::ToolId>,
204    pub turning: Option<&'a TurningInput<'a>>,
205    pub mill_turn: Option<&'a MillTurnInput<'a>>,
206    /// The stock as it stands before this operation (lane C7, `stock_sim::job`),
207    /// in the setup's machine frame: the linkers' in-process stock seed.
208    /// `None`: the linkers keep above the conservative material top.
209    pub pre_op_stock: Option<&'a crate::StockEnvelope>,
210}
211
212/// Geometry supplement supplied by the model adapter/feature selection.
213/// A supplied profile must include all protected axisymmetric material. Otherwise
214/// the public extractor builds it from adapter-certified carriers and the mesh.
215pub struct TurningInput<'a> {
216    pub tool_id: &'a crate::ToolId,
217    pub tool_geometry: &'a crate::turning::TurningGeometry,
218    pub carriers: &'a [crate::turning::profile::AxialFace],
219    pub profile: Option<&'a crate::turning::profile::TurningProfile>,
220    pub contour: Option<&'a crate::turning::profile_path::ProfileInput>,
221    pub stick_out: f64,
222    pub groove_center_z: Option<f64>,
223    pub thread_radius: Option<f64>,
224}
225
226/// Live-tool selection is distinct from Strategy: the shared model has no
227/// polar/cylindrical strategy variants. Inputs are explicit compensated CL
228/// contours and separated rotating/fixed meshes, as required by C11's API.
229pub enum MillTurnInput<'a> {
230    IndexedDrill { hole: &'a Hole, indexed_mesh: &'a MeshSnapshot },
231    Polar { contour: &'a crate::planar::Chain, z:f64, part:&'a MeshSnapshot, fixed:&'a MeshSnapshot },
232    Cylindrical { contour: &'a crate::planar::Chain, radius:f64, part:&'a MeshSnapshot, fixed:&'a MeshSnapshot },
233}
234
235fn refused(input:&GenerateInput<'_>,why:&str)->CamError {
236    invalid(format!("{}: {}: {why}",input.operation.id,input.operation.strategy.kind_name()))
237}
238fn resolved_machine<'a>(input:&GenerateInput<'_>,context:&GenerateContext<'a>)->Result<Option<&'a crate::Machine>,CamError> {
239    match (&input.setup.machine,context.machine) {
240        (Some(id),Some(machine)) if *id==machine.id=>Ok(Some(machine)),
241        (None,None)=>Ok(None),
242        (Some(_),None)=>Err(refused(input,"setup machine must be resolved in GenerateContext")),
243        _=>Err(refused(input,"resolved machine identity does not match the setup")),
244    }
245}
246// Mesh and CL inputs here are MACHINE coordinates, not WCS-local S.
247// Passing setup.wcs.origin would subtract it twice from table pivots.
248fn machine_chain(input:&GenerateInput<'_>,context:&GenerateContext<'_>)->Result<Option<crate::kinematics::Chain>,CamError> {
249    Ok(resolved_machine(input,context)?.map(|m|crate::kinematics::Chain::from_machine(m,crate::Point3::ZERO)).transpose()?)
250}
251fn validated_inner_path<'a>(input:&GenerateInput<'_>,context:&'a GenerateContext<'_>)->Result<&'a Toolpath,CamError> {
252    let s=context.source.ok_or_else(||refused(input,"requires an explicit resolved source operation/path in GenerateContext"))?;
253    let op=input.operation;
254    if !s.operation.enabled || s.operation.id==op.id || s.operation.setup!=op.setup || s.operation.tool!=op.tool || s.tool.id!=op.tool || s.toolpath.operation!=s.operation.id || s.toolpath.tool!=s.tool.id || s.operation.targets!=op.targets || !s.toolpath.report.is_usable() || s.toolpath.motions.is_empty() || s.tool!=*tool_of(op,input.tools)? || !s.toolpath.motions.iter().any(|m|m.kind.is_cutting()) || s.toolpath.motions.iter().any(|m|!m.kind.is_finite()) {
255        return Err(refused(input,"source must be a distinct enabled operation with matching setup, tool, targets and usable path"));
256    }
257    if s.toolpath.motions.iter().any(|m|!matches!(m.kind,crate::MotionKind::Rapid{..}|crate::MotionKind::Cut{..}|crate::MotionKind::Plunge{..}|crate::MotionKind::Retract{..}|crate::MotionKind::LeadIn{..}|crate::MotionKind::LeadOut{..}|crate::MotionKind::Link{..}|crate::MotionKind::Comment{..})) {
258        return Err(refused(input,"source must contain linear machining moves; arcs, cycles, probes and machine-control motions cannot be reinterpreted"));
259    }
260    Ok(&s.toolpath)
261}
262
263/// Generate an explicitly selected finishing operation, never a guessed default
264/// or a recursive dependency. Cached and generated paths share the same checks.
265fn inner_path<'a>(input:&GenerateInput<'_>,context:&'a GenerateContext<'_>,runner:&mut Runner<'_>)->Result<std::borrow::Cow<'a,Toolpath>,CamError> {
266    use std::borrow::Cow;
267    let Some(source)=context.source_operation else {return validated_inner_path(input,context).map(Cow::Borrowed);};
268    if context.source.is_some() {return Err(refused(input,"supply either a source operation or a resolved source path, not both"));}
269    let op=input.operation;
270    if !source.enabled || source.id==op.id || source.setup!=op.setup || source.tool!=op.tool || source.targets!=op.targets {
271        return Err(refused(input,"source must be a distinct enabled operation with matching setup, tool, targets and usable path"));
272    }
273    if !matches!(source.strategy,Strategy::Parallel(_)|Strategy::Scallop(_)|Strategy::Pencil(_)|Strategy::Radial(_)|Strategy::Spiral(_)|Strategy::MorphedSpiral(_)|Strategy::Project(_)|Strategy::Morph(_)|Strategy::Contour3D(_)|Strategy::SteepShallow(_)|Strategy::Horizontal(_)|Strategy::Ramp(_)) {
274        return Err(refused(input,"source operation must be a self-contained three-axis finishing strategy"));
275    }
276    let source_input=GenerateInput {operation:source,..*input};
277    // A source that fails says so: the finish's report names its source.
278    let path=generate(&source_input,runner).map_err(|e| match e {CamError::Cancelled=>CamError::Cancelled,e=>refused(input,&format!("source operation {} ({}) failed: {e}",source.id,source.strategy.kind_name()))})?;
279    let generated=crate::program::OperationInput {operation:source.clone(),tool:tool_of(source,input.tools)?.clone(),toolpath:path};
280    let resolved=GenerateContext {source:Some(&generated),..Default::default()};
281    validated_inner_path(input,&resolved)?;
282    Ok(Cow::Owned(generated.toolpath))
283}
284
285/// Stock bounds are MODEL-space inputs to resolve_stock. Recover them from
286/// target vertices, not by inverse-transforming the inflated machine AABB.
287fn rough_stock(input:&GenerateInput<'_>,context:&GenerateContext<'_>)->Result<crate::stock::ResolvedStock,CamError> {
288    input.setup.model_to_machine.validate().map_err(|e|refused(input,&format!("invalid stock transform: {e}")))?;
289    if let Some(stock)=context.rough_stock {return Ok(stock.clone());}
290    let mut bounds=crate::Bounds3::empty();
291    let inverse=input.setup.model_to_machine.inverse();
292    let targets=if input.setup.targets.is_empty() {input.operation.targets.target_solids.iter().map(TargetRef::solid).collect()} else {input.setup.targets.clone()};
293    for target in targets.into_iter().filter(|_|matches!(input.setup.stock,crate::Stock::BoundsOffset{..})) {
294        let faces=input.mesh.resolve_faces(&target);
295        if faces.is_empty() {return Err(CamError::UnresolvedTarget {solid:target.solid,face:target.face});}
296        for id in input.mesh.triangles_of_faces(&faces) {
297            let triangle=&input.mesh.triangles[id as usize];
298            if input.mesh.faces[triangle.face as usize].protected_only {continue;}
299            for p in triangle.vertices() {bounds.include(inverse.apply_point(p));}
300        }
301    }
302    crate::stock::resolve_stock(input.setup,&bounds,None).map_err(|e|refused(input,&format!("stock resolution: {e}; FromSolid needs rough_stock in GenerateContext")))
303}
304
305fn roughing_path(input:&GenerateInput<'_>,context:&GenerateContext<'_>,tool:&Tool,runner:&mut Runner<'_>)->Result<Toolpath,CamError> {
306    use crate::{RestSource,Stock};
307    let op=input.operation;let setup=input.setup;let mesh=input.mesh;let index=input.index;let tol=input.tolerance;
308    let (rest,source)=match &op.strategy {
309        Strategy::Adaptive2D(p)=>(p.rest_machining,p.rest_source),
310        Strategy::Adaptive3D(p)=>(p.rest_machining,p.rest_source),
311        Strategy::Pocket3D(p)=>(p.rest_machining,p.rest_source),
312        _=>return Err(refused(input,"not a roughing strategy")),
313    };
314    let grid=if rest && source==RestSource::Stock {Some(context.rest_stock.ok_or_else(||refused(input,"rest machining requires simulated stock in GenerateContext.rest_stock"))?)} else {None};
315    if rest && source==RestSource::ReferenceTool && !matches!(op.strategy,Strategy::Pocket3D(_)) {
316        return Err(refused(input,"reference-tool rest is supported by Pocket3D; adaptive rest requires RestSource::Stock"));
317    }
318    if matches!(setup.stock,Stock::FromRest{..}) && grid.is_none() {
319        return Err(refused(input,"FromRest setup stock requires rest_machining with RestSource::Stock and a simulated grid"));
320    }
321    if matches!(op.strategy,Strategy::Adaptive2D(_)) {
322        let mut planar=planar_input(input)?;
323        let stock=rough_stock(input,context)?;
324        planar.stock_top=grid.map(|g|g.z_range[1]).or_else(||stock.top_z());
325        return crate::adaptive2d::generate(op,setup,tool,mesh,index,&planar,grid,tol,runner);
326    }
327    let stock=rough_stock(input,context)?;
328    match &op.strategy {
329        Strategy::Adaptive3D(_)=>crate::adaptive3d::generate(op,setup,tool,mesh,index,&stock,grid,tol,runner),
330        Strategy::Pocket3D(_) if rest && source==RestSource::ReferenceTool=>{
331            let id=context.rough_reference_tool.ok_or_else(||refused(input,"reference-tool rest requires explicit rough_reference_tool in GenerateContext"))?;
332            let reference=input.tools.iter().find(|t|t.id==*id).ok_or_else(||refused(input,"roughing reference tool is not in the document"))?;
333            let cutter=cutter_of(reference)?;
334            crate::rest_rough::generate(op,setup,tool,mesh,index,&stock,crate::rest_rough::RestInput::ReferenceTool(&cutter),tol,runner)
335        },
336        Strategy::Pocket3D(_)=>crate::pocket3d::generate(op,setup,tool,mesh,index,&stock,grid,tol,runner),
337        _=>Err(refused(input,"not a 3D roughing strategy")),
338    }
339}
340
341/// Pattern output retains separate operations/tools/WCS sections. The single
342/// Toolpath API refuses this loss of information; workers use this entry point
343/// for program expansion and pass its setups to program::assemble.
344pub fn generate_expanded(input:&GenerateInput<'_>,context:&GenerateContext<'_>,runner:&mut Runner<'_>)->Result<Vec<crate::program::SetupInput>,CamError> {
345    if let Strategy::Pattern(p)=&input.operation.strategy {
346        let output=if matches!(p.kind,crate::PatternKind::WcsOffsets{..}) {
347            crate::pattern::generate::wcs_setups(input.operation,input.setup,context.available,input.mesh,input.tolerance,runner)?
348        }else{
349            vec![crate::program::SetupInput {setup:input.setup.clone(),operations:crate::pattern::generate::generate(input.operation,input.setup,context.available,input.mesh,input.tolerance,runner)?}]
350        };
351        if output.is_empty()||output.iter().any(|s|s.operations.is_empty()||s.operations.iter().any(|o|o.toolpath.motions.is_empty()||!o.toolpath.report.is_usable())) {return Err(refused(input,"pattern expansion produced no usable operations"));}return Ok(output);
352    }
353    let tool=tool_of(input.operation,input.tools)?.clone();let path=generate_with_context(input,context,runner)?;
354    Ok(vec![crate::program::SetupInput {setup:input.setup.clone(),operations:vec![crate::program::OperationInput {operation:input.operation.clone(),tool,toolpath:path}]}])
355}
356
357/// Default outside selection follows a single connected certified meridian.
358/// Disconnected sections, bores and axial folds require an explicit selection.
359fn turning_outer_contour(profile:&crate::turning::profile::TurningProfile,tol:&Tolerance)->Result<crate::turning::profile_path::ProfileInput,CamError> {
360    let mut edges:Vec<_>=profile.edges.iter().filter(|e| e.start().x>tol.point_eps && e.end().x>tol.point_eps).cloned().collect();
361    let start=edges.iter().flat_map(|e|[e.start(),e.end()]).max_by(|a,b|a.z.total_cmp(&b.z).then(a.x.total_cmp(&b.x))).ok_or_else(||invalid("turning: no outside meridian; select a profile"))?;
362    let mut at=start;let mut selected=Vec::new();
363    while !edges.is_empty() {
364        let candidates:Vec<_>=edges.iter().enumerate().filter_map(|(i,e)| {
365            let e=if e.start().distance(at)<=tol.point_eps {e.clone()}else if e.end().distance(at)<=tol.point_eps {e.reversed()}else{return None};
366            (e.end().z<=at.z+tol.point_eps).then_some((i,e))
367        }).collect();
368        if candidates.len()!=1 {return Err(invalid("turning: outside meridian is disconnected, ambiguous or folds; select a profile"));}
369        let (i,e)=candidates.into_iter().next().unwrap();at=e.end();edges.remove(i);selected.push(e);
370    }
371    if start.z-at.z<=tol.point_eps {return Err(invalid("turning: outside meridian has no axial extent"));}
372    Ok(crate::turning::profile_path::ProfileInput{contour:crate::turning::profile_path::chain_from_edges(&selected),pilot_radius:0.})
373}
374
375fn turning_path(input:&GenerateInput<'_>,context:&GenerateContext<'_>,tool:&Tool,runner:&mut Runner<'_>)->Result<Toolpath,CamError> {
376    use crate::{MachineKind,Strategy,Stock,StockCylinder};
377    use crate::turning;
378    let machine=resolved_machine(input,context)?.ok_or_else(||refused(input,"requires a resolved lathe or mill-turn machine; generic setup is a mill"))?;
379    if !matches!(machine.kind,MachineKind::Lathe|MachineKind::MillTurn{..}) {return Err(refused(input,"requires a lathe or mill-turn setup, not a milling setup"));}
380    let geometry=context.turning.ok_or_else(||refused(input,"requires certified turning profile/carriers, insert geometry and stick-out in GenerateContext"))?;
381    if *geometry.tool_id!=tool.id {return Err(refused(input,"turning geometry belongs to a different tool"));}
382    let setup=input.setup;let op=input.operation;let transform=&setup.model_to_machine;
383    let stock=match &setup.stock {Stock::Cylinder{base,axis,radius,height}=>StockCylinder{base:transform.apply_point(*base),axis:transform.apply_vector(*axis),radius:*radius,height:*height},_=>return Err(refused(input,"requires cylindrical setup stock with explicit stick-out"))};
384    turning::profile::bar_section(&stock,geometry.stick_out)?;
385    let extracted;
386    let profile=if let Some(p)=geometry.profile {p}else{extracted=turning::profile::extract(geometry.carriers,input.mesh,op.tolerance*0.125,runner)?;&extracted};
387    let g=geometry.tool_geometry;let stick=geometry.stick_out;
388    match &op.strategy {
389        Strategy::TurnFace(_)=>turning::face::generate(op,setup,tool,g,&stock,stick,profile,runner),
390        Strategy::TurnPartOff(_)=>turning::part_off::generate(op,setup,tool,g,&stock,stick,profile,runner),
391        Strategy::TurnGroove(_)=>turning::groove::generate(op,setup,tool,g,&stock,stick,profile,geometry.groove_center_z.ok_or_else(||refused(input,"requires selected groove centre Z"))?,runner),
392        Strategy::TurnThread(_)=>turning::thread_generation::generate(op,setup,tool,g,&stock,stick,profile,geometry.thread_radius.ok_or_else(||refused(input,"requires selected thread nominal radius"))?,runner).map(|p|p.canned),
393        Strategy::TurnProfile(p)=>{
394            let derived;
395            let contour=if let Some(contour)=geometry.contour {contour}else if p.outside && op.targets.chains.is_empty() && op.targets.drive_faces.is_empty() {
396                derived=turning_outer_contour(profile,&input.tolerance)?;&derived
397            }else{return Err(refused(input,"requires selected front-to-back profile contour and explicit pilot-bore radius"));};
398            let direction=match p.direction {crate::RoughingDirection::Axial=>turning::profile_path::RoughingDirection::Axial,crate::RoughingDirection::Radial=>turning::profile_path::RoughingDirection::Radial};
399            match (p.roughing,p.finishing) {
400                (true,true)=>turning::profile_finish::generate_combined(op,setup,tool,g,&stock,stick,profile,contour,direction,input.tolerance,runner),
401                (true,false)=>turning::profile_rough::generate(op,setup,tool,g,&stock,stick,profile,contour,direction,input.tolerance,runner),
402                (false,true)=>turning::profile_finish::generate(op,setup,tool,g,&stock,stick,profile,contour,input.tolerance,runner),
403                _=>Err(refused(input,"enable roughing or finishing")),
404            }
405        },
406        _=>Err(refused(input,"not a turning strategy")),
407    }
408}
409
410/// Live axial milling keeps the compensated Cartesian path and asks the
411/// controller's polar interpolator to drive spindle C. No synthetic C angles
412/// are attached to a path that was generated in a different frame.
413fn live_milling_path(input:&GenerateInput<'_>,context:&GenerateContext<'_>,tool:&Tool,runner:&mut Runner<'_>)->Result<Toolpath,CamError> {
414    let machine=resolved_machine(input,context)?.ok_or_else(||refused(input,"live tooling requires a resolved mill-turn machine"))?;
415    crate::turning::millturn::chain(machine,crate::Point3::ZERO)?;
416    if input.mesh.faces.iter().any(|f|f.protected_only) {
417        return Err(refused(input,"automatic polar milling needs an explicit rotating-part/fixed-fixture selection when protected bodies are present"));
418    }
419    if matches!(&machine.kind,crate::MachineKind::MillTurn{c_axis,..} if c_axis.limits_deg.is_some()) {
420        return Err(refused(input,"automatic polar milling requires unrestricted C travel; use an explicit indexed selection"));
421    }
422    let op=input.operation;
423    if op.tool_axis.is_some_and(|axis|axis.distance(crate::Point3::Z)>input.tolerance.angle_eps) {
424        return Err(refused(input,"axial live milling requires the spindle Z tool axis"));
425    }
426    let feeds=op.feeds.as_ref().unwrap_or(&tool.feeds);
427    if feeds.css {return Err(refused(input,"live milling requires fixed tool spindle rpm"));}
428    if !matches!(op.strategy,Strategy::Contour2D(_)|Strategy::Trace(_)) {return Err(refused(input,"live milling strategy requires a supported axial contour"));}
429    let geometry=planar_for(input,context)?;
430    let mut path=match op.strategy {
431        Strategy::Contour2D(_)=>crate::contour2d::generate(op,input.setup,tool,input.mesh,input.index,geometry.as_ref(),input.tolerance,runner),
432        Strategy::Trace(_)=>crate::trace::generate(op,input.setup,tool,input.mesh,input.index,geometry.as_ref(),input.tolerance,runner),
433        _=>return Err(refused(input,"live milling strategy requires a supported axial contour")),
434    }?;
435    let mut previous:Option<crate::Point3>=None;
436    for motion in &path.motions {
437        if let Some(p)=motion.kind.end() {
438            crate::turning::millturn::validate_position(machine,crate::Point3::new(p.x.hypot(p.y),0.,p.z),input.tolerance)?;
439            if motion.kind.is_cutting() {
440                if let Some(from)=previous {
441                    let samples=match motion.kind {
442                        crate::MotionKind::ArcCw{to,center,plane,pitch}|crate::MotionKind::ArcCcw{to,center,plane,pitch}=> {
443                            if plane!=crate::ArcPlane::Xy {return Err(refused(input,"automatic polar milling requires XY arcs"));}
444                            // Bound the complete circular arc, not merely its
445                            // endpoints, against physical radial travel.
446                            let radius=(from.x-center.x).hypot(from.y-center.y);
447                            for r in [(center.x.hypot(center.y)-radius).max(0.),center.x.hypot(center.y)+radius] {
448                                for z in [from.z,to.z] {crate::turning::millturn::validate_position(machine,crate::Point3::new(r,0.,z),input.tolerance)?;}
449                            }
450                            let mut points=vec![from];
451                            points.extend(crate::multiaxis::linearize_arc(from,to,center,plane,pitch,matches!(motion.kind,crate::MotionKind::ArcCw{..}),op.tolerance*0.125));points
452                        },
453                        _=>vec![from,p],
454                    };
455                    for pair in samples.windows(2) {
456                        runner.work(1,"live-polar-check",0,0)?;
457                        let edge=crate::planar::Segment::line(crate::Point2::new(pair[0].x,pair[0].y),crate::Point2::new(pair[1].x,pair[1].y));
458                        if edge.distance(crate::Point2::new(0.,0.))<=op.tolerance {return Err(refused(input,"polar live contour crosses the spindle singularity"));}
459                    }
460                }
461            }
462            previous=Some(p);
463        }
464    }
465    path.motions.insert(0,crate::Motion::new(crate::MotionKind::PolarInterpolation{enabled:true},crate::Feed::RAPID));
466    path.motions.push(crate::Motion::new(crate::MotionKind::PolarInterpolation{enabled:false},crate::Feed::RAPID));
467    path.report.counts.motions=path.motions.len() as u64;
468    Ok(path)
469}
470
471fn mill_turn_path(input:&GenerateInput<'_>,context:&GenerateContext<'_>,selection:&MillTurnInput<'_>,tool:&Tool,runner:&mut Runner<'_>)->Result<Toolpath,CamError> {
472    if input.operation.setup!=input.setup.id {return Err(refused(input,"operation setup identity mismatch"));}
473    let machine=resolved_machine(input,context)?.ok_or_else(||refused(input,"live tooling requires a resolved mill-turn machine"))?;
474    crate::turning::millturn::chain(machine,crate::Point3::ZERO)?;
475    match selection {
476        MillTurnInput::IndexedDrill{hole,indexed_mesh}=>{
477            if !matches!(input.operation.strategy,Strategy::Drill(_)){return Err(refused(input,"indexed live drilling requires Drill strategy"));}
478            selected_holes(input.operation,std::slice::from_ref(*hole))?;
479            crate::turning::millturn::drilling::generate_hole(input.operation,input.setup,machine,tool,hole,indexed_mesh,input.tolerance,runner).map(|p|p.canned)
480        },
481        MillTurnInput::Polar{contour,z,part,fixed}=>{
482            if !matches!(input.operation.strategy,Strategy::Contour2D(_)|Strategy::Trace(_)){return Err(refused(input,"polar live milling requires Contour2D or Trace with a compensated contour"));}
483            crate::turning::millturn::milling::polar(input.operation,machine,tool,contour,*z,part,fixed,input.tolerance,runner).map(|p|p.0)
484        },
485        MillTurnInput::Cylindrical{contour,radius,part,fixed}=>{
486            if !matches!(input.operation.strategy,Strategy::Contour2D(_)|Strategy::Trace(_)){return Err(refused(input,"cylindrical live milling requires Contour2D or Trace with a compensated contour"));}
487            crate::turning::millturn::milling::cylindrical(input.operation,machine,tool,contour,*radius,part,fixed,input.tolerance,runner).map(|p|p.0)
488        },
489    }
490}
491
492/// Generate one operation's toolpath.
493pub fn generate(input: &GenerateInput<'_>, runner: &mut Runner<'_>) -> Result<Toolpath, CamError> {
494    generate_with_context(input,&GenerateContext::default(),runner)
495}
496
497/// Generate with resolved model inputs. Existing single-operation arms are unchanged.
498pub fn generate_with_context(input:&GenerateInput<'_>,context:&GenerateContext<'_>,runner:&mut Runner<'_>)->Result<Toolpath,CamError> {
499    // The linkers below read the pre-operation stock from this scope (lane C4).
500    // ... and the finishing adapter the tool's holder (lane C16's shank and
501    // holder certification against that stock).
502    let holder=input.tools.iter().find(|t|t.id==input.operation.tool).and_then(|t|t.holder.as_ref());
503    crate::link::with_pre_op_stock(context.pre_op_stock,||crate::strategy_linking::with_tool_holder(holder,||generate_in_context(input,context,runner)))
504}
505
506fn generate_in_context(input:&GenerateInput<'_>,context:&GenerateContext<'_>,runner:&mut Runner<'_>)->Result<Toolpath,CamError> {
507    runner.token().check()?;
508    if context.resolved_planar.is_some() && !matches!(input.operation.strategy,Strategy::Face(_)|Strategy::Pocket2D(_)|Strategy::Contour2D(_)|Strategy::Slot(_)|Strategy::Trace(_)|Strategy::Chamfer2D(_)|Strategy::Engrave(_)|Strategy::Bore(_)|Strategy::Circular(_)|Strategy::ThreadMill(_)) {
509        return Err(refused(input,"resolved planar geometry is not supported by this strategy"));
510    }
511    if let Some((operation,_))=context.resolved_planar {
512        if operation!=&input.operation.id {return Err(refused(input,"resolved planar geometry belongs to another operation"));}
513        if !input.operation.targets.chains.is_empty() {return Err(refused(input,"supply resolved planar geometry or stored chains, not both"));}
514    }
515    if matches!(input.operation.strategy,Strategy::ManualNc(_)) {return crate::pattern::manual_nc::generate(input.operation,runner);}
516    if matches!(input.operation.strategy,Strategy::Pattern(_)) {return Err(refused(input,"requires generate_expanded to preserve source tool and WCS sections"));}
517    let op = input.operation;
518    let setup = input.setup;
519    let mesh = input.mesh;
520    let index = input.index;
521    let tol = input.tolerance;
522    if !op.targets.chains.is_empty() && !matches!(op.strategy,Strategy::Contour2D(_)|Strategy::Slot(_)|Strategy::Trace(_)|Strategy::Chamfer2D(_)|Strategy::Engrave(_)) {
523        return Err(refused(input,"selected chains are not supported by this strategy"));
524    }
525    if op.targets.target_solids.is_empty() && op.targets.chains.is_empty() {
526        return Err(CamError::EmptyTarget { operation_id: op.id.as_str().to_string() });
527    }
528    let tool = tool_of(op, input.tools)?;
529    let heights = heights_of(op, setup);
530    let feeds = feeds_of(op, tool);
531    if let Some(selection)=context.mill_turn {return mill_turn_path(input,context,selection,tool,runner);}
532    if op.live_tool==Some(true) && !context.machine.is_some_and(|m|matches!(m.kind,crate::MachineKind::MillTurn{..})) {
533        return Err(refused(input,"live tooling requires a resolved mill-turn machine"));
534    }
535    if let Some(machine)=context.machine {
536        resolved_machine(input,context)?;
537        if matches!(machine.kind,crate::MachineKind::MillTurn{..}) && !op.strategy.is_turning() && !op.strategy.is_targetless() && op.live_tool.unwrap_or(true) {
538            return live_milling_path(input,context,tool,runner);
539        }
540        if matches!(machine.kind,crate::MachineKind::Lathe) && matches!(op.strategy,Strategy::Drill(_)) {
541            let holes = selected_holes(op, input.holes)?;
542            return crate::turning::drill::generate(op,setup,tool,&holes,mesh,tol,runner).map(|p|p.canned);
543        }
544        if matches!(machine.kind,crate::MachineKind::Lathe|crate::MachineKind::MillTurn{..}) && !matches!(op.strategy,Strategy::TurnFace(_)|Strategy::TurnProfile(_)|Strategy::TurnGroove(_)|Strategy::TurnThread(_)|Strategy::TurnPartOff(_)) {
545            return Err(refused(input,"this setup requires an explicit live-tool selection or a turning strategy"));
546        }
547    }
548    if let Strategy::RestFinish(p)=&op.strategy {if p.source==crate::RestSource::Stock {
549        let stock=context.rest_stock.ok_or_else(||refused(input,"stock rest finishing requires the previous simulated DexelGrid"))?;
550        return crate::rest_finish::generate_from_stock(op,&cutter_of(tool)?,mesh,index,&heights,&feeds,tol,runner,stock);
551    }}
552    match &op.strategy {
553        // --- 3D finishing on the drop cutter ---
554        Strategy::Parallel(_) => crate::parallel::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
555        Strategy::Scallop(_) => crate::scallop::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
556        Strategy::Pencil(_) => crate::pencil::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
557        Strategy::Radial(_) => crate::radial::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
558        Strategy::Spiral(_) => crate::spiral::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
559        Strategy::MorphedSpiral(_) => crate::morphed_spiral::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
560        Strategy::Project(_) => crate::project::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
561        Strategy::Morph(_) => crate::morph::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
562        Strategy::RestFinish(p) => {
563            let reference = input
564                .tools
565                .iter()
566                .find(|t| t.id == p.reference_tool)
567                .ok_or_else(|| invalid(format!("{}: reference tool `{}` is not in the document", op.id, p.reference_tool)))?;
568            crate::rest_finish::generate(op, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner, &cutter_of(reference)?)
569        }
570        // --- the waterline family ---
571        Strategy::Contour3D(_) => crate::waterline::contour::generate(op, setup, tool, mesh, tol, Default::default(), runner),
572        Strategy::SteepShallow(_) => crate::waterline::steep_shallow::generate(op, setup, tool, mesh, tol, Default::default(), runner),
573        Strategy::Horizontal(_) => crate::waterline::horizontal::generate(op, setup, tool, mesh, tol, runner),
574        Strategy::Ramp(_) => crate::waterline::ramp::generate(op, setup, tool, mesh, tol, Default::default(), runner),
575        // --- multi-axis ---
576        Strategy::Swarf(_) => crate::swarf::generate(op, &cutter_of(tool)?, tool.holder.as_ref(), mesh, index, &heights, &feeds, tol, runner),
577        Strategy::MultiAxisContour(_) => {
578            crate::multiaxis_contour::generate(op, &cutter_of(tool)?, tool.holder.as_ref(), mesh, index, &heights, &feeds, tol, runner)
579        }
580        // --- 2.5D on planar regions ---
581        Strategy::Face(_) => crate::face::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
582        Strategy::Pocket2D(_) => crate::pocket2d::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
583        Strategy::Contour2D(_) => crate::contour2d::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
584        Strategy::Slot(_) => crate::slot::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
585        Strategy::Trace(_) => crate::trace::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
586        Strategy::Chamfer2D(_) => crate::chamfer2d::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
587        Strategy::Engrave(_) => crate::engrave::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
588        Strategy::Bore(_) => crate::bore::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
589        Strategy::Circular(_) => crate::circular::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
590        Strategy::ThreadMill(_) => crate::thread_mill::generate(op, setup, tool, mesh, index, planar_for(input,context)?.as_ref(), tol, runner),
591        // --- drilling: the canned stream (cycles); the simulation lane expands it ---
592        Strategy::Drill(_) => {
593            let holes = selected_holes(op, input.holes)?;
594            if holes.is_empty() {
595                return Err(invalid(format!("{}: no hole was recognised on its target bodies", op.id)));
596            }
597            crate::drill::strategy::generate(op, setup, tool, &holes, mesh, tol, runner).map(|out| out.canned)
598        }
599        // --- probing (lane C14) and the rotary finish (lane C10) ---
600        Strategy::ProbeWcs(_) => crate::probe_wcs::generate(op, setup, tool, mesh, tol, runner),
601        Strategy::ProbeGeometry(_) => crate::probe_geometry::generate(op, setup, tool, mesh, tol, runner),
602        Strategy::RotaryFinish(p) => crate::rotary::generate_rotary_finish(op, p, &cutter_of(tool)?, mesh, index, &heights, &feeds, tol, runner),
603        // --- operations requiring additional resolved model inputs ---
604        Strategy::MultiAxisFinish(p) => {
605            let inner=inner_path(input,context,runner)?;
606            let chain=machine_chain(input,context)?;
607            let machine=chain.as_ref().map(|chain|crate::multiaxis_finish::MachineCheck {chain,options:Default::default()});
608            crate::multiaxis_finish::generate(op,p,&inner,&cutter_of(tool)?,tool.holder.as_ref(),mesh,index,machine.as_ref(),&heights,&feeds,tol,runner)
609        },
610        Strategy::Adaptive2D(_) | Strategy::Adaptive3D(_) | Strategy::Pocket3D(_) => {
611            roughing_path(input,context,tool,runner)
612        }
613        Strategy::Flow(_) => {
614            if context.flow_faces.is_empty(){return Err(refused(input,"requires selected parametric face samplers from the geometry adapter"));}
615            crate::flow::generate(op,&cutter_of(tool)?,mesh,index,&heights,&feeds,tol,runner,context.flow_faces)
616        },
617        Strategy::RotaryWrap(p) => {
618            if context.source_operation.is_some() {return Err(refused(input,"requires a precomputed unwrapped source path; source_operation is only for MultiAxisFinish"));}
619            crate::rotary::wrap_toolpath(op,p,validated_inner_path(input,context)?,&cutter_of(tool)?,mesh,index,tol,runner)
620        },
621        Strategy::TurnFace(_) | Strategy::TurnProfile(_) | Strategy::TurnGroove(_) | Strategy::TurnThread(_) | Strategy::TurnPartOff(_) => {
622            turning_path(input,context,tool,runner)
623        }
624        Strategy::ManualNc(_) => crate::pattern::manual_nc::generate(op,runner),
625        Strategy::Pattern(_) => Err(refused(input,"requires generate_expanded to preserve source tool and WCS sections")),
626    }
627}
628