Skip to main content

brep_cam/autoprogram/
mod.rs

1//! Automatic programming (lane C13 rows 3 and 4): recognised features ->
2//! a setup's ordered operations through the technology table, tools from
3//! the library, feeds from lane C12, generation through the existing
4//! strategies, program assembly, and the report of what was recognised,
5//! programmed and skipped.
6//!
7//! Ordering: facing, roughing by descending cutter diameter, finishing
8//! (including chamfers), then holes: spotting, drilling, tapping/thread milling.
9//! Tool reuse is stable within a phase and cannot override roughing diameter
10//! or hole dependencies. Tool number and ID break ties deterministically.
11
12use crate::features::techdb::{Fit, OperationRule, TechDb};
13use crate::features::{Feature, FeatureKind, Recognized, WallLine};
14use crate::feeds::WorkMaterial;
15use crate::holes::Hole;
16use crate::library::LibraryUnits;
17use crate::model::{
18    Chamfer2dParams, Contour2dParams, CutDirection, Depths, FaceParams, Lead, LeadKind, Machine, MachineKind,
19    Operation, OperationId, Pocket2dParams, Setup, SlotParams, Stepover, Strategy, TargetSelection, Tool, ToolId,
20    ToolKind, Toolpath,
21};
22use crate::planar::machining::{PlanarChain, PlanarInput, PlanarRegion};
23use crate::planar::{chain_capsule, Chain, Loop, Region, Vec2};
24use crate::{CamError, Cutter, CutterProfile, MeshSnapshot, Runner, Tolerance, TriangleIndex};
25use serde::{Deserialize, Serialize};
26
27mod hole_ops;
28pub mod multi;
29pub mod access;
30pub mod binding;
31pub mod report;
32pub use report::{report, Report};
33
34/// What the planner reads.
35pub struct AutoInput<'a> {
36    pub recognized: &'a Recognized,
37    pub setup: &'a Setup,
38    /// The tool library (every tool must validate; see `library::validate_tool`).
39    pub tools: &'a [Tool],
40    /// The machine for feeds and speeds (`None`: a generic 3-axis mill).
41    pub machine: Option<&'a Machine>,
42    pub material: WorkMaterial,
43    pub techdb: &'a TechDb,
44    pub units: LibraryUnits,
45    /// Machine Z of the stock top when known.
46    pub stock_top: Option<f64>,
47}
48
49/// The geometry a planned operation is generated from.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub enum OperationGeometry {
52    Planar(PlanarInput),
53    Holes(Vec<Hole>),
54}
55
56#[derive(Clone, Debug, PartialEq)]
57pub struct PlannedOperation {
58    pub operation: Operation,
59    pub tool: ToolId,
60    pub geometry: OperationGeometry,
61    pub feature: String,
62    pub role: String,
63    pub notes: Vec<String>,
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct Skipped {
69    pub feature: String,
70    pub kind: String,
71    pub reason: String,
72}
73
74#[derive(Clone, Debug, Default, PartialEq)]
75pub struct Plan {
76    pub operations: Vec<PlannedOperation>,
77    pub skipped: Vec<Skipped>,
78    pub tool_changes: u32,
79}
80
81fn invalid(m: impl std::fmt::Display) -> CamError {
82    CamError::InvalidInput(format!("autoprogram: {m}"))
83}
84
85fn phase(role: &str) -> u8 {
86    match role {
87        "face" => 0,
88        "rough" => 1,
89        "finish" | "chamfer" => 2,
90        "spot" => 3,
91        "drill" => 4,
92        "tap" => 5,
93        _ => 6,
94    }
95}
96
97fn material_name(m: WorkMaterial) -> &'static str {
98    match m {
99        WorkMaterial::Aluminium => "aluminium",
100        WorkMaterial::MildSteel => "mildSteel",
101        WorkMaterial::AlloySteel => "alloySteel",
102        WorkMaterial::StainlessSteel => "stainlessSteel",
103        WorkMaterial::CastIron => "castIron",
104        WorkMaterial::Brass => "brass",
105        WorkMaterial::Titanium => "titanium",
106        WorkMaterial::Plastics => "plastics",
107        WorkMaterial::Wood => "wood",
108        WorkMaterial::Mdf => "mdf",
109    }
110}
111
112/// The feature's depth, width constraints and corner radius for tool fitting.
113struct Fitting<'a> {
114    depth: f64,
115    /// The tool must fit this region inward by its radius (plus a hair).
116    region: Option<&'a Region>,
117    corner_radius: Option<f64>,
118    /// The tool diameter may not exceed this (slot width, chamfer cone size...).
119    max_diameter: Option<f64>,
120    exact_diameter: Option<f64>,
121}
122
123/// Choose a tool for a rule: the largest of the rule's kinds that fits.
124fn choose_tool<'a>(rule: &OperationRule, tools: &'a [Tool], fit: &Fitting<'_>, clearance: Option<&(Region, Region)>, tol: &Tolerance) -> Result<&'a Tool, String> {
125    let mut candidates: Vec<&Tool> = tools.iter().filter(|t| rule.tool.kinds.contains(&t.kind)).collect();
126    if candidates.is_empty() {
127        return Err(format!("the library has no {:?} tool", rule.tool.kinds));
128    }
129    candidates.sort_by(|a, b| b.geometry.diameter.total_cmp(&a.geometry.diameter).then(a.number.cmp(&b.number)));
130    let mut why = Vec::new();
131    for t in candidates {
132        let d = t.geometry.diameter;
133        if t.geometry.cutting_length + tol.distance_eps < fit.depth {
134            why.push(format!("{}: cutting length {} below the depth {}", t.id, t.geometry.cutting_length, fit.depth));
135            continue;
136        }
137        // An island finisher needs room for the whole cutter, not only
138        // its centre line. Even a "largest" rule cannot override this.
139        if let Some((outline, allowed)) = clearance {
140            let margin = crate::pocket2d::wall_margin(tol, rule.tolerance);
141            let envelope = outline.offset(d + rule.stock_to_leave.radial + 2.0 * margin, tol)
142                .and_then(|expanded| expanded.difference(allowed, tol));
143            match envelope {
144                Ok(outside) if outside.area() <= tol.area_eps => {},
145                Ok(_) => { why.push(format!("{}: cutter sweep does not fit around the island", t.id)); continue; },
146                Err(e) => { why.push(format!("{}: island clearance could not be certified: {e}", t.id)); continue; },
147            }
148        }
149        match rule.tool.fit {
150            Fit::Largest => return Ok(t),
151            Fit::ExactDiameter => {
152                let want = fit.exact_diameter.ok_or("no exact diameter for the feature")?;
153                if (d - want).abs() <= 1e-6 {
154                    return Ok(t);
155                }
156                why.push(format!("{}: diameter {} is not {}", t.id, d, want));
157            }
158            Fit::LargestFitting => {
159                if let Some(m) = fit.max_diameter {
160                    if d > m + 1e-9 {
161                        why.push(format!("{}: diameter {} above {}", t.id, d, m));
162                        continue;
163                    }
164                }
165                if let Some(r) = fit.corner_radius {
166                    if 0.5 * d > r + 1e-9 {
167                        why.push(format!("{}: radius {} above the corner radius {}", t.id, 0.5 * d, r));
168                        continue;
169                    }
170                }
171                if let Some(region) = fit.region {
172                    let inner = region.offset(-(0.5 * d + crate::pocket2d::wall_margin(tol, rule.tolerance)), tol).map_err(|e| e.to_string())?;
173                    if inner.is_empty() {
174                        why.push(format!("{}: diameter {} does not fit the region", t.id, d));
175                        continue;
176                    }
177                }
178                return Ok(t);
179            }
180        }
181    }
182    Err(format!("no fitting tool: {}", why.join("; ")))
183}
184
185/// A closed loop as a planar chain (the loop's own direction).
186fn loop_chain(l: &Loop) -> Chain {
187    Chain { segments: l.segments.clone(), closed: true }
188}
189
190/// The region a pocket is cleared over: closed pockets their floor; open
191/// pockets and steps the floor extended past every open edge by the tool
192/// radius plus the overhang, cut back behind every planar wall.
193fn clearing_region(floor: &Region, open_edges: &[Chain], walls: &[WallLine], extend: f64, tol: &Tolerance) -> Result<Region, CamError> {
194    if open_edges.is_empty() {
195        return Ok(floor.clone());
196    }
197    let mut region = floor.clone();
198    for e in open_edges {
199        let cap = chain_capsule(e, extend, tol)?;
200        region = region.union(&cap, tol)?;
201    }
202    let big = 1.0e4;
203    for w in walls {
204        let t = w.inward.perp();
205        let corners = [
206            w.point.sub(t.scale(big)),
207            w.point.add(t.scale(big)),
208            w.point.add(t.scale(big)).add(w.inward.scale(big)),
209            w.point.sub(t.scale(big)).add(w.inward.scale(big)),
210        ];
211        let mut half = Loop::polygon(&corners);
212        if !half.is_ccw() {
213            half = half.reversed();
214        }
215        region = region.intersection(&Region::new(vec![half], tol)?, tol)?;
216    }
217    Ok(region)
218}
219
220/// Open-edge overhang is empty space only until it reaches another feature.
221/// Clip it against known higher material before the pocket strategy offsets it
222/// by the cutter radius; planar wall half-planes alone cannot protect a curved
223/// neighbour at the end of an open edge.
224fn protect_higher_material(mut region: Region, feature: &Feature, floor_z: f64, recognized: &Recognized, tol: &Tolerance) -> Result<Region, CamError> {
225    for other in &recognized.features {
226        if other.id == feature.id { continue; }
227        let (outline, z) = match &other.kind {
228            FeatureKind::Face { region, z } => (region, *z),
229            FeatureKind::Boss { outline, top_z, .. } => (outline, *top_z),
230            FeatureKind::Pocket { floor, floor_z, .. } | FeatureKind::Step { floor, floor_z, .. } => (floor, *floor_z),
231            _ => continue,
232        };
233        if z > floor_z + tol.distance_eps && !outline.loops.is_empty() { region = region.difference(outline, tol)?; }
234    }
235    Ok(region)
236}
237
238/// Plan the setup's operations.
239pub fn plan(input: &AutoInput<'_>) -> Result<Plan, CamError> {
240    let tol = Tolerance::default();
241    input.techdb.validate().map_err(invalid)?;
242    crate::library::validate_program_tools(input.tools).map_err(invalid)?;
243    let generic = Machine::new("generic-mill", "Generic 3-axis mill", MachineKind::Mill3);
244    let machine = input.machine.unwrap_or(&generic);
245    let factors = input.techdb.factors(material_name(input.material));
246    let mut planned: Vec<(u8, u32, usize, PlannedOperation)> = Vec::new();
247    let mut skipped = Vec::new();
248    for (fi, feature) in input.recognized.features.iter().enumerate() {
249        let kind = feature.kind.name();
250        if !feature.machinable {
251            skipped.push(Skipped { feature: feature.id.clone(), kind: kind.into(), reason: feature.reason.clone().unwrap_or_else(|| "not machinable in this setup".into()) });
252            continue;
253        }
254        if matches!(feature.kind, FeatureKind::Fillet { .. }) {
255            skipped.push(Skipped { feature: feature.id.clone(), kind: kind.into(), reason: "fillets need a 3D finishing strategy or a form cutter; not programmed".into() });
256            continue;
257        }
258        let Some(rule) = input.techdb.rule(kind) else {
259            skipped.push(Skipped { feature: feature.id.clone(), kind: kind.into(), reason: format!("the technology table has no rule for {kind}") });
260            continue;
261        };
262        let mut produced = 0;
263        let mut notes_for_skip = Vec::new();
264        for op_rule in &rule.operations {
265            let result=if op_rule.strategy=="drill" && matches!(feature.kind,FeatureKind::Hole(_)) {
266                hole_ops::plan_hole(input,feature,op_rule,machine)
267            } else {plan_operation(input, feature, op_rule, machine, &factors, &tol).map(|p|vec![p])};
268            match result {
269                Ok(ops) => { for p in ops {
270                    p.operation.validate().map_err(|m| invalid(format!("{}: {m}", p.operation.id)))?;
271                    let tool_number = input.tools.iter().find(|t| t.id == p.tool).map_or(0, |t| t.number);
272                    planned.push((phase(&p.role), tool_number, fi, p));
273                    produced += 1;
274                }}
275                Err(reason) => notes_for_skip.push(format!("{}: {reason}", op_rule.name)),
276            }
277        }
278        if produced == 0 {
279            skipped.push(Skipped { feature: feature.id.clone(), kind: kind.into(), reason: notes_for_skip.join("; ") });
280        } else if !notes_for_skip.is_empty() {
281            // Partial: record on the produced operations.
282            for (_, _, i, p) in planned.iter_mut() {
283                if *i == fi {
284                    p.notes.extend(notes_for_skip.iter().cloned());
285                }
286            }
287        }
288    }
289    // Phase, descending roughing diameter, then tool and feature order.
290    // Reuse across phases never overrides the roughing diameter priority.
291    let diameter=|p:&PlannedOperation| input.tools.iter().find(|t|t.id==p.tool).unwrap().geometry.diameter;
292    planned.sort_by(|a, b| a.0.cmp(&b.0).then_with(||if a.0==1 {diameter(&b.3).total_cmp(&diameter(&a.3))}else{std::cmp::Ordering::Equal}).then(a.1.cmp(&b.1)).then(a.3.tool.as_str().cmp(b.3.tool.as_str())).then(a.2.cmp(&b.2)));
293    let mut ordered: Vec<PlannedOperation> = Vec::new();
294    let mut last_tool: Option<ToolId> = None;
295    let mut i = 0;
296    while i < planned.len() {
297        let ph = planned[i].0;
298        let mut j = i;
299        while j < planned.len() && planned[j].0 == ph {
300            j += 1;
301        }
302        let mut group: Vec<PlannedOperation> = planned.drain(i..j).map(|(_, _, _, p)| p).collect();
303        if let Some(lt) = last_tool.as_ref().filter(|_|ph!=1) {
304            // Stable: pull the continuing tool's operations to the front.
305            let (same, rest): (Vec<_>, Vec<_>) = group.into_iter().partition(|p| &p.tool == lt);
306            group = same.into_iter().chain(rest).collect();
307        }
308        if let Some(last) = group.last() {
309            last_tool = Some(last.tool.clone());
310        }
311        ordered.extend(group);
312        // `drain` removed the group; the next group now starts at `i`.
313        let _ = &mut i;
314    }
315    let tool_changes = ordered.windows(2).filter(|w| w[0].tool != w[1].tool).count() as u32;
316    Ok(Plan { operations: ordered, skipped, tool_changes })
317}
318
319/// The enclosing pocket with this island filled in. Other islands remain
320/// protected holes. Recognition constructs the boss outline from this exact
321/// reversed floor loop, so identity does not depend on feature ordering.
322fn island_clearance(feature: &Feature, recognized: &Recognized, tol: &Tolerance) -> Result<Option<(Region, Region)>, CamError> {
323    let FeatureKind::Boss { outline, base_z, .. } = &feature.kind else { return Ok(None) };
324    // A standalone boss can be too close to another raised feature even
325    // without a recognized enclosing pocket. Bound its cutter sweep against
326    // all known higher material, as well as any enclosing floor boundary.
327    let (lo,hi)=outline.bounds();
328    let mut allowed=Region::new(vec![Loop::rectangle(lo.sub(crate::Point2::new(1.0e4,1.0e4)),hi.add(crate::Point2::new(1.0e4,1.0e4)))],tol)?;
329    'parent: for parent in &recognized.features {
330        if parent.solid != feature.solid { continue; }
331        let (FeatureKind::Pocket { floor, floor_z, .. } | FeatureKind::Step { floor, floor_z, .. }) = &parent.kind else { continue };
332        if (base_z - floor_z).abs() > tol.distance_eps { continue; }
333        for (i, boundary) in floor.loops.iter().enumerate() {
334            if !boundary.is_ccw() && outline.loops == vec![boundary.reversed()] {
335                allowed = floor.clone();
336                allowed.loops.remove(i);
337                break 'parent;
338            }
339        }
340    }
341    allowed=protect_higher_material(allowed,feature,*base_z,recognized,tol)?;
342    Ok(Some((outline.clone(),allowed)))
343}
344
345fn plan_operation(input: &AutoInput<'_>, feature: &Feature, rule: &OperationRule, machine: &Machine, factors: &crate::features::techdb::MaterialFactors, tol: &Tolerance) -> Result<PlannedOperation, String> {
346    let setup = input.setup;
347    let stock_top = input.stock_top;
348    let id = OperationId::new(format!("{}-{}", feature.id, rule.name));
349    let mut notes = Vec::new();
350    let targets = TargetSelection::solids([feature.solid.clone()]);
351    // Geometry and fitting per feature kind.
352    let (fitting, strategy_kind) = match &feature.kind {
353        FeatureKind::OuterProfile { top_z,bottom_z,.. } => (Fitting {depth:stock_top.unwrap_or(*top_z)-bottom_z+rule.break_through,region:None,corner_radius:None,max_diameter:None,exact_diameter:None},"outer-profile"),
354        FeatureKind::Face { region, z } => {
355            let top = stock_top.unwrap_or(z + input.techdb.face_allowance);
356            (Fitting { depth: top - z, region: Some(region), corner_radius: None, max_diameter: None, exact_diameter: None }, "face")
357        }
358        FeatureKind::Pocket { floor, floor_z, top_z, corner_radius, .. } => (Fitting { depth: top_z - floor_z, region: Some(floor), corner_radius: *corner_radius, max_diameter: None, exact_diameter: None }, "pocket"),
359        FeatureKind::Step { floor, floor_z, top_z, .. } => (Fitting { depth: top_z - floor_z, region: Some(floor), corner_radius: None, max_diameter: None, exact_diameter: None }, "step"),
360        FeatureKind::Slot { width, floor_z, top_z, .. } => (Fitting { depth: top_z - floor_z, region: None, corner_radius: None, max_diameter: Some(*width), exact_diameter: None }, "slot"),
361        FeatureKind::Boss { top_z, base_z, .. } => (Fitting { depth: top_z - base_z, region: None, corner_radius: None, max_diameter: None, exact_diameter: None }, "boss"),
362        FeatureKind::Hole(h) => (Fitting { depth: h.depth, region: None, corner_radius: None, max_diameter: None, exact_diameter: Some(h.diameter) }, "hole"),
363        FeatureKind::Chamfer { width, .. } => (Fitting { depth: width + rule.tip_offset, region: None, corner_radius: None, max_diameter: None, exact_diameter: None }, "chamfer"),
364        FeatureKind::Fillet { .. } => return Err("fillets need a 3D finishing strategy or a form cutter; not programmed".into()),
365    };
366    let _ = strategy_kind;
367    if rule.strategy == "drill" {return Err("drill rule requires the compound-hole planner".into());}
368    let tool=choose_tool(rule, input.tools, &fitting, island_clearance(feature, input.recognized, tol).map_err(|e| e.to_string())?.as_ref(), tol)?;
369    if matches!(feature.kind,FeatureKind::Boss{..}) {
370        notes.push("boss contour finishes walls only; pocket-around requires the separately programmed enclosing floor (external stock boundary is not inferred)".into());
371    }
372    let d = tool.geometry.diameter;
373    let stepover = (rule.stepover * d * factors.stepover).max(tol.sample_eps);
374    let step_down = rule.step_down * d * factors.step_down;
375    let depths = |top: f64, bottom: f64| Depths { top: Some(top), bottom: Some(bottom), step_down, finishing_step_down: 0.0, finishing_passes: 0, depth_first: true };
376    let mut linking = crate::model::LinkingParams::default();
377    linking.ramp.kind = rule.ramp;
378    if rule.lead > 0.0 {
379        linking.lead_in = Lead { kind: LeadKind::Arc, radius: rule.lead * d, sweep_deg: 90.0, linear_length: 0.0, vertical_radius: 0.0 };
380        linking.lead_out = linking.lead_in;
381    }
382    let (strategy, geometry) = match (&feature.kind, rule.strategy.as_str()) {
383        (FeatureKind::OuterProfile {outline,top_z,bottom_z,walls},"contour2D")=>{
384            if *walls!=crate::features::ProfileWalls::Vertical {return Err("outer profile requires certified vertical walls".into());}
385            // Bounds-offset stock uses model axes. Its bottom allowance
386            // proves contact only when model +Z is machine +Z.
387            let aligned=setup.model_to_machine.rotation[2].iter().zip([0.,0.,1.]).all(|(a,b)|(a-b).abs()<tol.angle_eps);
388            let on_stock_bottom=aligned && match &setup.stock {
389                crate::Stock::BoundsOffset{bottom,..}=>*bottom==0.,
390                crate::Stock::Box{min,..}=>(setup.model_to_machine.apply_point(*min).z-bottom_z).abs()<=tol.distance_eps,
391                _=>false,
392            };
393            let breakthrough=if on_stock_bottom {rule.break_through}else{0.};
394            let params=Contour2dParams{depths:depths(stock_top.unwrap_or(*top_z),bottom_z-breakthrough),outside:true,roughing_passes:rule.roughing_passes,roughing_stepover:stepover,finishing_passes:rule.finishing_passes.max(1),finishing_overlap:0.,tabs:Vec::new(),direction:CutDirection::Climb};
395            (Strategy::Contour2D(params),PlanarInput{chains:outline.loops.iter().map(|l|PlanarChain{chain:loop_chain(l),z:*bottom_z}).collect(),stock_top,..Default::default()})
396        }
397        (FeatureKind::Face { region, z }, "face") => {
398            let top = stock_top.unwrap_or(z + input.techdb.face_allowance);
399            if top <= *z + tol.distance_eps { return Err("stock top is at or below the face; no facing allowance to remove".into()); }
400            let params = FaceParams { depths: depths(top, *z), stepover: Stepover { distance: stepover, direction: CutDirection::Climb }, pass_angle_deg: 0.0, pass_extension: rule.extension, zig_zag: rule.zig_zag };
401            (Strategy::Face(params), PlanarInput { regions: vec![PlanarRegion { region: region.clone(), z: *z, upward: true }], stock_top, ..Default::default() })
402        }
403        (FeatureKind::Pocket { floor, floor_z, top_z, open_edges, walls, .. }, "pocket2D") | (FeatureKind::Step { floor, floor_z, top_z, open_edges, walls, .. }, "pocket2D") => {
404            let region = clearing_region(floor, open_edges, walls, 0.5 * d + input.techdb.open_pocket_overhang, tol).map_err(|e| e.to_string())?;
405            let region = if open_edges.is_empty() { region } else {
406                protect_higher_material(region, feature, *floor_z, input.recognized, tol).map_err(|e| e.to_string())?
407            };
408            // A single floor-finishing pass can reach outside the previous
409            // rougher's sweep. Clear those stock slivers before the shank
410            // descends into them, using the feature's roughing depth rule.
411            let mut pocket_depths=depths(stock_top.unwrap_or(*top_z).max(*top_z), *floor_z);
412            if pocket_depths.top.unwrap()-floor_z > tool.geometry.cutting_length + tol.distance_eps && pocket_depths.step_down <= 0. {
413                let rough_step=input.techdb.rule(feature.kind.name()).and_then(|r|r.operations.iter().find(|r|r.strategy=="pocket2D" && r.role=="rough" && r.step_down>0.)).map(|r|r.step_down)
414                    .ok_or("single-depth pocket finishing exceeds flute reach; supply a roughing step-down rule")?;
415                pocket_depths.step_down=rough_step*d*factors.step_down;
416                notes.push("stock exceeds flute reach; clear finishing overhang in roughing-sized depth levels".into());
417            }
418            if pocket_depths.step_down>0. { pocket_depths.step_down=pocket_depths.step_down.min(tool.geometry.cutting_length); }
419            let params = Pocket2dParams { depths: pocket_depths, stepover: Stepover { distance: stepover, direction: CutDirection::Climb }, offset_pattern: true, wall_finish: false, keep_islands: true };
420            (Strategy::Pocket2D(params), PlanarInput { regions: vec![PlanarRegion { region, z: *floor_z, upward: true }], stock_top, ..Default::default() })
421        }
422        (FeatureKind::Pocket { floor, floor_z, top_z, .. }, "contour2D") => {
423            // Outer loops are inside contours; island loops outside contours.
424            let mut chains = Vec::new();
425            for l in &floor.loops {
426                chains.push(PlanarChain { chain: loop_chain(l), z: *floor_z });
427            }
428            let mut wall_depths=depths(stock_top.unwrap_or(*top_z).max(*top_z),*floor_z);
429            if wall_depths.step_down<=0. && wall_depths.top.unwrap()-floor_z>tool.geometry.cutting_length+tol.distance_eps {
430                return Err("wall finishing exceeds flute reach; supply a positive step-down rule".into());
431            }
432            if wall_depths.step_down>0. {wall_depths.step_down=wall_depths.step_down.min(tool.geometry.cutting_length);}
433            let params = Contour2dParams { depths: wall_depths, outside: false, roughing_passes: rule.roughing_passes, roughing_stepover: stepover, finishing_passes: rule.finishing_passes.max(1), finishing_overlap: 0.0, tabs: Vec::new(), direction: CutDirection::Climb };
434            // Islands (clockwise loops) are machined on the outside: split them
435            // into their own input so each chain carries the right side.
436            let outer: Vec<PlanarChain> = chains.iter().filter(|c| Loop::new(c.chain.segments.clone()).is_ccw()).cloned().collect();
437            let islands: Vec<PlanarChain> = chains.iter().filter(|c| !Loop::new(c.chain.segments.clone()).is_ccw()).cloned().collect();
438            if !islands.is_empty() {
439                notes.push(format!("{} island wall(s) are finished by the island's own boss contour", islands.len()));
440            }
441            (Strategy::Contour2D(params), PlanarInput { chains: outer, stock_top, ..Default::default() })
442        }
443        (FeatureKind::Slot { centre, width, floor_z, top_z }, "slot") => {
444            let params = SlotParams { depths: depths(*top_z, *floor_z), width: *width, trochoidal: false };
445            (Strategy::Slot(params), PlanarInput { chains: vec![PlanarChain { chain: centre.clone(), z: *floor_z }], stock_top, ..Default::default() })
446        }
447        (FeatureKind::Boss { outline, top_z, base_z, .. }, "contour2D") => {
448            let chains: Vec<PlanarChain> = outline.loops.iter().map(|l| PlanarChain { chain: loop_chain(l), z: *base_z }).collect();
449            let params = Contour2dParams { depths: depths(*top_z, *base_z), outside: true, roughing_passes: rule.roughing_passes, roughing_stepover: stepover, finishing_passes: rule.finishing_passes.max(1), finishing_overlap: 0.0, tabs: Vec::new(), direction: CutDirection::Climb };
450            (Strategy::Contour2D(params), PlanarInput { chains, stock_top, ..Default::default() })
451        }
452        (FeatureKind::Chamfer { chain, width, z, .. }, "chamfer2D") => {
453            let params = Chamfer2dParams { width: *width, tip_offset: rule.tip_offset };
454            (Strategy::Chamfer2D(params), PlanarInput { chains: vec![PlanarChain { chain: chain.clone(), z: *z }], stock_top, ..Default::default() })
455        }
456        (FeatureKind::Face { .. }, "contour2D") => return Err("outer profile: recognition supplies a top outline but no certified profile bottom or stock boundary; select an explicit contour depth (tabs default off)".into()),
457        (kind, strategy) => return Err(format!("the rule's strategy {strategy} does not apply to a {}", kind.name())),
458    };
459    let mut op = Operation::new(id, format!("{} {}", feature.kind.name(), rule.name), setup.id.clone(), tool.id.clone(), strategy);
460    op.targets = targets;
461    op.tolerance = rule.tolerance;
462    op.stock_to_leave = rule.stock_to_leave;
463    op.linking = linking;
464    op.feeds = feeds_for(tool, machine, input, Some(stepover), &mut notes);
465    Ok(PlannedOperation { operation: op, tool: tool.id.clone(), geometry: OperationGeometry::Planar(geometry), feature: feature.id.clone(), role: rule.role.clone(), notes })
466}
467
468/// Feeds from lane C12 for the tool and material; the tool's own feeds
469/// (with a note) when the library tool carries no cutting data.
470fn feeds_for(tool: &Tool, machine: &Machine, input: &AutoInput<'_>, engagement: Option<f64>, notes: &mut Vec<String>) -> Option<crate::model::FeedsSpeeds> {
471    match crate::feeds::calculate(tool, machine, input.units, input.material, engagement) {
472        Ok(calc) => {
473            notes.extend(calc.adjustments.iter().map(|a| format!("feeds: {a}")));
474            Some(calc.feeds)
475        }
476        Err(e) => {
477            notes.push(format!("feeds: {e}; the tool's own feeds are used"));
478            None
479        }
480    }
481}
482
483/// One operation's generation outcome. Drilling keeps its canned cycles in
484/// `toolpath` and the expanded motions in `expanded` (for simulation).
485#[derive(Clone, Debug, PartialEq)]
486pub struct GeneratedOperation {
487    pub operation: OperationId,
488    pub toolpath: Option<Toolpath>,
489    pub expanded: Option<Toolpath>,
490    pub error: Option<String>,
491}
492
493impl GeneratedOperation {
494    /// The motions to simulate: the expanded stream when there is one.
495    pub fn simulated(&self) -> Option<&Toolpath> {
496        self.expanded.as_ref().or(self.toolpath.as_ref())
497    }
498}
499
500/// Generate every planned operation through its strategy. Per-operation
501/// failures are recorded, not fatal; cancellation is.
502pub fn generate(plan: &Plan, input: &AutoInput<'_>, mesh: &MeshSnapshot, index: Option<&dyn TriangleIndex>, tol: Tolerance, runner: &mut Runner<'_>) -> Result<Vec<GeneratedOperation>, CamError> {
503    let mut out = Vec::with_capacity(plan.operations.len());
504    for (i, p) in plan.operations.iter().enumerate() {
505        runner.stage("autoprogram-generate", &p.operation.name, i as u64, plan.operations.len() as u64)?;
506        let tool = input.tools.iter().find(|t| t.id == p.tool).ok_or_else(|| invalid(format!("tool {} missing", p.tool)))?;
507        let op = &p.operation;
508        let result: Result<(Toolpath, Option<Toolpath>), CamError> = match (&op.strategy, &p.geometry) {
509            (Strategy::Face(_)|Strategy::Pocket2D(_)|Strategy::Contour2D(_)|Strategy::Slot(_)|Strategy::Chamfer2D(_)|Strategy::Circular(_)|Strategy::ThreadMill(_), OperationGeometry::Planar(g)) => {
510                let dispatch_input=crate::dispatch::GenerateInput {operation:op,setup:input.setup,tools:input.tools,mesh,index,tolerance:tol,holes:&[]};
511                let context=crate::dispatch::GenerateContext {resolved_planar:Some((&op.id,g)),machine:if input.setup.machine.is_some(){input.machine}else{None},..Default::default()};
512                crate::dispatch::generate_with_context(&dispatch_input,&context,runner).map(|t|(t,None))
513            },
514            (Strategy::Drill(_), OperationGeometry::Holes(h)) => crate::drill::strategy::generate(op, input.setup, tool, h, mesh, tol, runner).map(|d| (d.canned, Some(d.expanded))),
515            (s, _) => Err(invalid(format!("no generator for {}", s.kind_name()))),
516        };
517        match result {
518            Ok((path, expanded)) => out.push(GeneratedOperation { operation: op.id.clone(), toolpath: Some(path), expanded, error: None }),
519            Err(CamError::Cancelled) => return Err(CamError::Cancelled),
520            Err(e) => out.push(GeneratedOperation { operation: op.id.clone(), toolpath: None, expanded: None, error: Some(e.to_string()) }),
521        }
522    }
523    Ok(out)
524}
525
526/// The program input of the generated operations (failed ones left out),
527/// ready for `program::assemble` and the posts.
528pub fn program_input(plan: &Plan, generated: &[GeneratedOperation], input: &AutoInput<'_>, name: &str) -> crate::program::ProgramInput {
529    let mut operations = Vec::new();
530    for p in &plan.operations {
531        let Some(g) = generated.iter().find(|g| g.operation == p.operation.id) else { continue };
532        let Some(path) = &g.toolpath else { continue };
533        let Some(tool) = input.tools.iter().find(|t| t.id == p.tool) else { continue };
534        operations.push(crate::program::OperationInput { operation: p.operation.clone(), tool: tool.clone(), toolpath: path.clone() });
535    }
536    crate::program::ProgramInput {
537        name: name.to_string(),
538        number: None,
539        units: match input.units {
540            LibraryUnits::Millimeters => crate::program::Units::Mm,
541            LibraryUnits::Inches => crate::program::Units::In,
542        },
543        machine: input.machine.cloned(),
544        setups: vec![crate::program::SetupInput { setup: input.setup.clone(), operations }],
545        ..Default::default()
546    }
547}
548
549/// The cutter of a planned operation's tool (for simulation).
550pub fn cutter_of(plan: &Plan, input: &AutoInput<'_>, operation: &OperationId) -> Option<Cutter> {
551    let p = plan.operations.iter().find(|p| &p.operation.id == operation)?;
552    let tool = input.tools.iter().find(|t| t.id == p.tool)?;
553    let c = Cutter::from_tool(tool).ok()?;
554    let _ = c.radius();
555    Some(c)
556}
557