Skip to main content

brep_cam/autoprogram/
multi.rs

1//! Additional fixed-axis setups are proposals, certified by fresh recognition.
2//! A refusal is never converted into machinable geometry by rotating its label.
3use super::*;
4use crate::model::{Bounds3, Point3, RigidTransform, Stock};
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Axis { PositiveX, NegativeX, PositiveY, NegativeY, NegativeZ }
8impl Axis {
9    pub const ALL: [Self; 5] = [Self::PositiveX, Self::NegativeX, Self::PositiveY, Self::NegativeY, Self::NegativeZ];
10    pub fn name(self) -> &'static str { match self { Self::PositiveX => "+X", Self::NegativeX => "-X", Self::PositiveY => "+Y", Self::NegativeY => "-Y", Self::NegativeZ => "-Z" } }
11    pub fn vector(self) -> Point3 { match self { Self::PositiveX => Point3::X, Self::NegativeX => Point3::X.scale(-1.), Self::PositiveY => Point3::Y, Self::NegativeY => Point3::Y.scale(-1.), Self::NegativeZ => Point3::Z.scale(-1.) } }
12}
13
14/// Exact recognition for a proposed orientation. Bounds include the initial
15/// stock: later stock can only shrink, so these heights remain conservative.
16#[derive(Clone, Debug)]
17pub struct Candidate { pub axis: Axis, pub setup: Setup, pub recognized: Recognized, pub stock_top: Option<f64> }
18#[derive(Clone, Debug)]
19pub struct SetupPlan {
20    pub axis: Option<Axis>,
21    pub setup: Setup,
22    pub recognized: Recognized,
23    pub plan: Plan,
24    /// First-setup feature IDs satisfied here, each assigned at most once.
25    pub covered: Vec<String>,
26}
27#[derive(Clone, Debug)]
28pub struct MultiPlan { pub setups: Vec<SetupPlan>, pub unreached: Vec<Skipped> }
29
30pub fn candidate_setup(first: &Setup, axis: Axis, model_stock: Bounds3) -> Result<Setup, CamError> {
31    let inverse = first.machine_to_model();
32    let z = axis.vector();
33    let x = if z.x.abs() > 0.5 { Point3::Y } else { Point3::X };
34    let mut setup = first.clone();
35    setup.id = format!("{}-auto-{}", first.id, axis.name()).into();
36    setup.name = format!("{} ยท {} approach", first.name, axis.name());
37    setup.model_to_machine = RigidTransform::from_frame(inverse.apply_point(Point3::ZERO), inverse.apply_vector(x), inverse.apply_vector(z)).map_err(invalid)?;
38    setup.stock = Stock::FromRest { setup: first.id.clone() };
39    let bounds = setup.model_to_machine.apply_bounds(&model_stock);
40    let gap = (first.heights.clearance - first.heights.retract).abs().max(5.);
41    setup.heights.clearance = bounds.max.z + 2. * gap;
42    setup.heights.retract = bounds.max.z + gap;
43    setup.heights.feed = bounds.max.z + 1.;
44    setup.heights.top = None;
45    setup.heights.bottom = None;
46    Ok(setup)
47}
48
49/// Only an opening direction parallel to a candidate axis can certify the
50/// original floor. A different side face is not proof that an undercut was cut.
51pub fn needed_axes(recognized: &Recognized) -> Vec<Axis> {
52    Axis::ALL.into_iter().filter(|axis| recognized.features.iter().any(|f| !f.machinable && f.direction.dot(axis.vector()) > 1. - 1e-6)).collect()
53}
54
55fn same_feature(original: &Feature, candidate: &Feature) -> bool {
56    let family = |kind: &FeatureKind| match kind { FeatureKind::Pocket { .. } | FeatureKind::Step { .. } | FeatureKind::Slot { .. } => "floor", _ => kind.name() };
57    original.solid == candidate.solid && !original.faces.is_empty()
58        && original.faces.first() == candidate.faces.first()
59        && family(&original.kind) == family(&candidate.kind)
60        && candidate.machinable && candidate.direction.dot(Point3::Z) > 1. - 1e-6
61}
62
63pub fn plan_candidates<F>(input: &AutoInput<'_>, first: Plan, candidates: &[Candidate], mut planner: F) -> Result<MultiPlan, CamError>
64where F: FnMut(&AutoInput<'_>) -> Result<Plan, CamError> {
65    let mut pending: Vec<_> = input.recognized.features.iter().filter(|f| !f.machinable).collect();
66    let mut planning_refusals=std::collections::BTreeMap::<String,Vec<String>>::new();
67    let mut setups = vec![SetupPlan { axis: None, setup: input.setup.clone(), recognized: input.recognized.clone(), covered: input.recognized.features.iter().filter(|f| first.operations.iter().any(|p| p.feature == f.id)).map(|f| f.id.clone()).collect(), plan: first }];
68    for axis in Axis::ALL {
69        let Some(candidate) = candidates.iter().find(|c| c.axis == axis) else { continue };
70        let mut pairs = Vec::new();
71        for original in &pending {
72            if original.direction.dot(axis.vector()) < 1. - 1e-6 { continue; }
73            let matches: Vec<_> = candidate.recognized.features.iter().filter(|f| same_feature(original, f)).collect();
74            if matches.len() == 1 { pairs.push((original.id.clone(), matches[0].id.clone())); }
75        }
76        if pairs.is_empty() { continue; }
77        let mut setup = candidate.setup.clone();
78        setup.stock = Stock::FromRest { setup: setups.last().unwrap().setup.id.clone() };
79        let mut plan = planner(&AutoInput { recognized: &candidate.recognized, setup: &setup, stock_top: candidate.stock_top, ..*input })?;
80        for (original,id) in &pairs {
81            if plan.operations.iter().any(|p| &p.feature==id) {continue;}
82            let reasons:Vec<_>=plan.skipped.iter().filter(|s| &s.feature==id).map(|s|s.reason.as_str()).collect();
83            let reason=if reasons.is_empty() {"the planner produced no operations".into()}else{reasons.join("; ")};
84            planning_refusals.entry(original.clone()).or_default().push(format!("{}: {reason}",axis.name()));
85        }
86        pairs.retain(|(_, id)| plan.operations.iter().any(|p| &p.feature == id));
87        if pairs.is_empty() { continue; }
88        plan.operations.retain(|p| pairs.iter().any(|(_, id)| id == &p.feature));
89        plan.skipped.retain(|s| pairs.iter().any(|(_, id)| id == &s.feature));
90        let mut recognized = candidate.recognized.clone();
91        recognized.features.retain(|f| pairs.iter().any(|(_, id)| id == &f.id));
92        // IDs are preview identities, namespaced without weakening the durable
93        // face/kind/key-dimension identity used by Binding::matches.
94        let prefix = format!("auto{}:", axis.name());
95        for feature in &mut recognized.features { feature.id = format!("{prefix}{}", feature.id); }
96        // Face accounting follows the narrowing: claims by dropped features go,
97        // and a face left unclaimed that the first setup's features cover is
98        // body in this setup (the other setup covers it).
99        if let Some(accounting) = &mut recognized.accounting {
100            crate::features::accounting::retain_claims(accounting, |id| pairs.iter().any(|(_, kept)| kept == id).then(|| format!("{prefix}{id}")));
101            for a in accounting.iter_mut().filter(|a| a.features.is_empty() && a.body.is_none()) {
102                if let Some(first) = input.recognized.features.iter().find(|f| f.solid == a.solid && f.faces.contains(&a.face)) {
103                    a.body = Some(crate::features::BodyReason::OtherSetup { axis: "first".into(), feature: first.id.clone() });
104                    a.settle();
105                }
106            }
107        }
108        // In the first setup, a face no feature of its own claims and no body
109        // reason explains is body when this setup's programmed features cover it.
110        if let Some(accounting) = &mut setups[0].recognized.accounting {
111            for a in accounting.iter_mut().filter(|a| a.features.is_empty() && a.body.is_none()) {
112                if let Some(cover) = recognized.features.iter().find(|f| f.solid == a.solid && f.faces.contains(&a.face)) {
113                    a.body = Some(crate::features::BodyReason::OtherSetup { axis: axis.name().into(), feature: cover.id.clone() });
114                    a.settle();
115                }
116            }
117        }
118        for op in &mut plan.operations {
119            op.feature = format!("{prefix}{}", op.feature);
120            op.operation.id = format!("{prefix}{}", op.operation.id).into();
121            op.operation.setup = setup.id.clone();
122        }
123        for skipped in &mut plan.skipped { skipped.feature = format!("{prefix}{}", skipped.feature); }
124        plan.tool_changes = plan.operations.windows(2).filter(|w| w[0].tool != w[1].tool).count() as u32;
125        let covered: Vec<_> = pairs.into_iter().map(|(original, _)| original).collect();
126        pending.retain(|f| !covered.contains(&f.id));
127        setups.push(SetupPlan { axis: Some(axis), setup, recognized, plan, covered });
128    }
129    let unreached = pending.into_iter().map(|f| {
130        if let Some(reasons)=planning_refusals.get(&f.id) {
131            return Skipped{feature:f.id.clone(),kind:f.kind.name().into(),reason:format!("axis-aligned access found for feature {}, but no machining plan: {}; choose fitting tools/rules or skip",f.id,reasons.join("; "))};
132        }
133        let mut reasons = vec![f.reason.clone().unwrap_or_else(|| "access is uncertified".into())];
134        for candidate in candidates {
135            for found in candidate.recognized.features.iter().filter(|c| c.solid == f.solid && c.faces.first() == f.faces.first()) {
136                if let Some(reason) = &found.reason { if !reasons.contains(reason) { reasons.push(reason.clone()); } }
137            }
138        }
139        Skipped { feature: f.id.clone(), kind: f.kind.name().into(), reason: format!(
140            "no additional axis-aligned setup certifies feature {}: {}; requires 3D/5-axis review or skip", f.id, reasons.join("; ")) }
141    }).collect();
142    Ok(MultiPlan { setups, unreached })
143}
144