Skip to main content

brep_app/panels/
cam_autoprogram.rs

1//! Preview automatic programming before making one undoable document edit.
2//! Exact topology arrives through the existing runner request, including in
3//! the browser. The preview is invalidated when its document or bodies change.
4
5use crate::panels::{cam, cam_setup};
6use crate::document::cam::CamDocument;
7use brep_cam::autoprogram::{self, AutoInput, Plan, PlannedOperation};
8use brep_cam::features::{Recognized, RecognitionInput, TechDb};
9use brep_cam::kernel_adapter::{BodyInput, machine_snapshot, revolved_faces};
10use brep_cam::kernel_adapter::features::face_carriers;
11use brep_cam::kernel_adapter::planar::horizontal_faces;
12use brep_cam::library::LibraryUnits;
13use brep_cam::feeds::WorkMaterial;
14use brep_cam::model::{Setup, Tool, ToolGeometry, ToolKind};
15use brep_cam::{NullSink, Runner, Tolerance};
16use eframe::egui;
17use serde_json::{json, Value};
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19use brep_render::engine_state::EngineState;
20
21/// Transient preview; no operation or fallback tool is saved until Create.
22#[derive(Default)]
23pub struct AutoProgramPanel {
24    pending: Option<String>,
25    preview: Option<Preview>,
26    error: Option<String>,
27    pub open: bool,
28}
29
30
31struct Preview {
32    setup: String,
33    enabled: bool,
34    additional: Vec<SetupPreview>,
35    unreached: Vec<autoprogram::Skipped>,
36    document: CamDocument,
37    revisions: Vec<(String, u64)>,
38    recognized: Recognized,
39    plan: Plan,
40    tools: Vec<Tool>,
41    fallback: BTreeSet<String>,
42    selected: BTreeSet<String>,
43}
44
45struct SetupPreview {
46    orientation: crate::document::cam::SetupOrientation,
47    proposal: autoprogram::multi::SetupPlan,
48    enabled: bool,
49    selected: BTreeSet<String>,
50}
51
52fn body_names(setup: &Setup) -> BTreeSet<String> {
53    let mut names: BTreeSet<_> = setup.targets.iter().chain(&setup.protected).map(|r| r.solid.clone()).collect();
54    if let brep_cam::model::Stock::FromSolid { solid } = &setup.stock { names.insert(solid.clone()); }
55    names
56}
57
58fn revisions(state: &EngineState, setup: &Setup) -> Result<Vec<(String, u64)>, String> {
59    body_names(setup).into_iter().map(|name| {
60        let revision = state.scene.solid(&name).ok_or_else(|| format!("body `{name}` is not on screen"))?.revision;
61        Ok((name, revision))
62    }).collect()
63}
64
65/// Conservative starter tools paired with the builtin technology rules.
66/// These are proposals, clearly labelled, never claims about the machine's
67/// physical magazine. Only tools used by selected operations are imported.
68fn fallback_tools(document: &CamDocument) -> Result<Vec<Tool>, String> {
69    let mut tools = Vec::new();
70    let mut number = document.tools.iter().map(|t| t.number).max().unwrap_or(0);
71    for (kind, diameter, length) in [
72        (ToolKind::FaceMill, 40., 5.),
73        (ToolKind::Flat, 10., 30.), (ToolKind::Flat, 8., 30.),
74        (ToolKind::Flat, 6., 30.), (ToolKind::Flat, 4., 25.), (ToolKind::Flat, 2., 20.),
75        (ToolKind::Drill, 3., 40.), (ToolKind::Drill, 4., 40.),
76        (ToolKind::Drill, 6., 40.), (ToolKind::Drill, 8., 40.), (ToolKind::Drill, 10., 40.),
77        (ToolKind::SpotDrill, 10., 15.), (ToolKind::Chamfer, 10., 15.),
78    ] {
79        number = number.checked_add(1).ok_or("tool numbers are exhausted")?;
80        let mut id = format!("auto-builtin-{number}");
81        while document.tool(&id).is_some() { id.push('_'); }
82        let mut tool = Tool::new(id, number, kind, ToolGeometry::flat(diameter, length));
83        tool.name = format!("Builtin {diameter} mm {kind:?}");
84        tool.description = "Automatic programming fallback; verify the available physical tool before machining".into();
85        tool.material = "carbide".into();
86        tool.geometry.flute_count = Some(if matches!(kind, ToolKind::Drill | ToolKind::SpotDrill) { 2 } else { 3 });
87        if matches!(kind, ToolKind::Drill | ToolKind::SpotDrill | ToolKind::Chamfer) {
88            tool.geometry.included_angle_deg = Some(if kind == ToolKind::Drill { 118. } else { 90. });
89        }
90        brep_cam::library::validate_tool(&tool)?;
91        tools.push(tool);
92    }
93    Ok(tools)
94}
95
96fn phase(role: &str) -> u8 {
97    match role { "face" => 0, "rough" => 1, "finish" | "chamfer" => 2, "spot" => 3, "drill" => 4, "tap" => 5, _ => 6 }
98}
99
100/// Try document tools for EACH rule first. A missing tool for one rule
101/// cannot replace a usable document tool chosen for a different rule.
102fn plan_with_fallback(input: &AutoInput<'_>, fallback: &[Tool]) -> Result<Plan, String> {
103    let mut combined = input.tools.to_vec();
104    combined.extend_from_slice(fallback);
105    let mut operations = Vec::<PlannedOperation>::new();
106    let mut reasons = BTreeMap::<String, Vec<String>>::new();
107    for rule in &input.techdb.rules {
108        for operation in &rule.operations {
109            let mut db = input.techdb.clone();
110            db.rules = vec![brep_cam::features::techdb::Rule { feature: rule.feature.clone(), operations: vec![operation.clone()] }];
111            let first = autoprogram::plan(&AutoInput { techdb: &db, ..*input }).map_err(|e| e.to_string())?;
112            let second = autoprogram::plan(&AutoInput { techdb: &db, tools: &combined, ..*input }).map_err(|e| e.to_string())?;
113            for feature in input.recognized.features.iter().filter(|f| f.kind.name() == rule.feature) {
114                let own: Vec<_> = first.operations.iter().filter(|p| p.feature == feature.id).cloned().collect();
115                let chosen: Vec<_> = if own.is_empty() { second.operations.iter().filter(|p| p.feature == feature.id).cloned().collect() } else { own };
116                if chosen.is_empty() {
117                    if let Some(reason) = second.skipped.iter().find(|s| s.feature == feature.id) {
118                        reasons.entry(feature.id.clone()).or_default().push(reason.reason.clone());
119                    }
120                }
121                operations.extend(chosen);
122            }
123        }
124    }
125    let mut skipped = Vec::new();
126    for f in &input.recognized.features {
127        let why = reasons.remove(&f.id).unwrap_or_default();
128        if !operations.iter().any(|p| p.feature == f.id) {
129            skipped.push(autoprogram::Skipped { feature: f.id.clone(), kind: f.kind.name().into(), reason: if why.is_empty() {
130                f.reason.clone().unwrap_or_else(|| format!("no builtin technology rule for {}", f.kind.name()))
131            } else { why.join("; ") } });
132        } else {
133            for p in operations.iter_mut().filter(|p| p.feature == f.id) { p.notes.extend(why.clone()); }
134        }
135    }
136    let tool = |p: &PlannedOperation| combined.iter().find(|t| t.id == p.tool).expect("planner selected an input tool");
137    let index = |p: &PlannedOperation| input.recognized.features.iter().position(|f| f.id == p.feature).unwrap_or(usize::MAX);
138    operations.sort_by(|a, b| phase(&a.role).cmp(&phase(&b.role))
139        .then_with(|| if phase(&a.role) == 1 { tool(b).geometry.diameter.total_cmp(&tool(a).geometry.diameter) } else { std::cmp::Ordering::Equal })
140        .then(tool(a).number.cmp(&tool(b).number)).then(a.tool.as_str().cmp(b.tool.as_str())).then(index(a).cmp(&index(b))));
141    let mut ordered = Vec::<PlannedOperation>::new();
142    for ph in 0..=6 {
143        let group: Vec<_> = operations.iter().filter(|p| phase(&p.role) == ph).cloned().collect();
144        if ph != 1 {
145            if let Some(last) = ordered.last() {
146                let (same, rest): (Vec<_>, Vec<_>) = group.into_iter().partition(|p| p.tool == last.tool);
147                ordered.extend(same); ordered.extend(rest);
148                continue;
149            }
150        }
151        ordered.extend(group);
152    }
153    let tool_changes = ordered.windows(2).filter(|w| w[0].tool != w[1].tool).count() as u32;
154    Ok(Plan { operations: ordered, skipped, tool_changes })
155}
156
157fn recognize_setup(state: &EngineState, setup: &Setup) -> Result<(Recognized, brep_cam::MeshSnapshot), String> {
158    let tolerance = Tolerance::default();
159    let chord = 0.01;
160    let (mesh, unresolved) = machine_snapshot(setup, chord, tolerance, |name| state.scene.exact_solid(name)).map_err(|e| e.to_string())?;
161    if !unresolved.is_empty() { return Err(format!("exact topology unavailable for {}", unresolved.join(", "))); }
162    let mut carriers = Vec::new();
163    let mut regions = Vec::new();
164    let mut revolved = Vec::new();
165    for name in setup.targets.iter().map(|r| r.solid.as_str()).collect::<BTreeSet<_>>() {
166        let solid = state.scene.exact_solid(name).ok_or_else(|| format!("exact topology unavailable for `{name}`"))?;
167        let body = BodyInput { name, solid, protected_only: false };
168        carriers.extend(face_carriers(&body, setup, &tolerance));
169        regions.extend(horizontal_faces(solid, &setup.model_to_machine, chord, &tolerance));
170        revolved.extend(revolved_faces(&body, setup));
171    }
172    let mut sink = NullSink;
173    let mut runner = Runner::detached(&mut sink);
174    let holes = brep_cam::holes::recognize(&revolved, &mesh, tolerance, &mut runner).map_err(|e| e.to_string())?;
175    let recognized = brep_cam::features::recognize(&RecognitionInput { mesh: &mesh, carriers: &carriers, regions: &regions, holes: &holes, tolerance }).map_err(|e| e.to_string())?;
176    Ok((recognized, mesh))
177}
178
179fn build_preview(state: &EngineState, doc: CamDocument, setup_id: &str) -> Result<Preview, String> {
180    let setup = doc.setup(setup_id).ok_or_else(|| format!("no setup `{setup_id}`"))?;
181    if setup.targets.is_empty() { return Err("select the setup's target bodies before programming".into()); }
182    let revisions = revisions(state, setup)?;
183    let (recognized, first_mesh) = recognize_setup(state, setup)?;
184    let stock_top = cam_setup::stock_bounds(setup, &state.scene).map(|(min, max)| {
185        (0..8).map(|i| setup.model_to_machine.apply_point(brep_cam::model::Point3::new(
186            if i & 1 == 0 { min.x } else { max.x }, if i & 2 == 0 { min.y } else { max.y }, if i & 4 == 0 { min.z } else { max.z })).z)
187            .fold(f64::NEG_INFINITY, f64::max)
188    });
189    let db = TechDb::builtin();
190    let machine = doc.setup_machine(setup_id);
191    if setup.machine.is_some() && machine.is_none() { return Err("the setup's selected machine is missing".into()); }
192    let extra = fallback_tools(&doc)?;
193    let input = AutoInput { recognized: &recognized, setup, tools: &doc.tools,
194        machine: machine.as_ref(), material: WorkMaterial::Aluminium, techdb: &db, units: LibraryUnits::Millimeters, stock_top };
195    let mut plan = plan_with_fallback(&input, &extra)?;
196    autoprogram::access::constrain_open_extensions(&mut plan, &recognized, &first_mesh, &Tolerance::default()).map_err(|e| e.to_string())?;
197    let axes = autoprogram::multi::needed_axes(&recognized);
198    let mut candidates = Vec::new();
199    let mut orientations = BTreeMap::new();
200    let mut candidate_meshes = BTreeMap::new();
201    if !axes.is_empty() {
202        let (min, max) = cam_setup::stock_bounds(setup, &state.scene)
203            .ok_or("additional setups need a resolved initial stock boundary; regenerate/review the carried setup first")?;
204        let bounds = brep_cam::model::Bounds3 { min, max };
205        for axis in axes {
206            let mut proposed = autoprogram::multi::candidate_setup(setup, axis, bounds).map_err(|e| e.to_string())?;
207            let source = recognized.features.iter().find(|f| !f.machinable && f.direction.dot(axis.vector()) > 1. - 1e-6).ok_or("approach feature disappeared")?;
208            let face = source.faces.first().ok_or("approach feature has no persistent face")?;
209            let origin = setup.machine_to_model().apply_point(brep_cam::model::Point3::ZERO);
210            let mut orientation = crate::document::cam::SetupOrientation { z_ref: Some(face.clone()),
211                origin_ref: Some(format!("auto@{},{},{}", origin.x, origin.y, origin.z)), ..Default::default() };
212            let mut temporary = doc.clone();
213            temporary.setups.push(proposed.clone());
214            temporary.set_orientation(proposed.id.as_str(), orientation.clone());
215            let frame = cam::setup_frame(state, &temporary, proposed.id.as_str())?;
216            let expected = setup.machine_to_model().apply_vector(axis.vector());
217            orientation.flip_z = frame.z_axis.dot(expected) < 0.;
218            temporary.set_orientation(proposed.id.as_str(), orientation.clone());
219            proposed.model_to_machine = cam::setup_frame(state, &temporary, proposed.id.as_str())?.transform()?;
220            let top = proposed.model_to_machine.apply_bounds(&bounds).max.z;
221            proposed.heights.clearance = top + 20.; proposed.heights.retract = top + 10.; proposed.heights.feed = top + 5.;
222            orientations.insert(proposed.id.to_string(), orientation);
223            let (recognized, mesh) = recognize_setup(state, &proposed)?;
224            candidate_meshes.insert(proposed.id.to_string(), mesh);
225            candidates.push(autoprogram::multi::Candidate { axis,
226                stock_top: Some(proposed.model_to_machine.apply_bounds(&bounds).max.z), setup: proposed, recognized });
227        }
228    }
229    let mut multi = autoprogram::multi::plan_candidates(&input, plan, &candidates, |input| {
230        let mut plan = plan_with_fallback(input, &extra).map_err(brep_cam::CamError::InvalidInput)?;
231        let mesh = candidate_meshes.get(input.setup.id.as_str()).ok_or_else(|| brep_cam::CamError::InvalidInput("candidate mesh disappeared".into()))?;
232        autoprogram::access::constrain_open_extensions(&mut plan, input.recognized, mesh, &Tolerance::default())?;
233        Ok(plan)
234    }).map_err(|e| e.to_string())?;
235    let first = multi.setups.remove(0);
236    let plan = first.plan;
237    let selected = plan.operations.iter().map(|p| p.feature.clone()).collect();
238    let additional = multi.setups.into_iter().map(|proposal| SetupPreview {
239        orientation: orientations.remove(proposal.setup.id.as_str()).expect("candidate orientation"),
240        selected: proposal.plan.operations.iter().map(|p| p.feature.clone()).collect(), proposal, enabled: true,
241    }).collect();
242    let fallback = extra.iter().map(|t| t.id.to_string()).collect();
243    let mut tools = doc.tools.clone(); tools.extend(extra);
244    Ok(Preview { setup: setup_id.into(), enabled: true, additional, unreached: multi.unreached,
245        document: doc, revisions, recognized: first.recognized, plan, tools, fallback, selected })
246}
247
248impl AutoProgramPanel {
249    pub fn preview(&mut self, state: &mut EngineState, setup: &str) -> Result<(), String> {
250        if let Some(why) = cam::sync_setup_frame(state, setup) { return Err(why); }
251        let doc = cam::cam_document(state)?;
252        let setup_record = doc.setup(setup).ok_or_else(|| format!("no setup `{setup}`"))?;
253        let names = body_names(setup_record);
254        revisions(state, setup_record)?;
255        state.request_exact_solids(names.iter().map(String::as_str));
256        self.preview = None;
257        self.error = None;
258        self.pending = Some(setup.into());
259        self.open = true;
260        self.poll(state);
261        Ok(())
262    }
263
264    pub fn poll(&mut self, state: &mut EngineState) {
265        let Some(id) = self.pending.clone() else { return };
266        state.pump_topology();
267        if state.topology_pending() { return; }
268        self.pending = None;
269        match cam::cam_document(state).and_then(|doc| build_preview(state, doc, &id)) {
270            Ok(preview) => self.preview = Some(preview),
271            Err(why) => self.error = Some(why),
272        }
273    }
274
275    pub fn select(&mut self, feature: &str, selected: bool) -> Result<(), String> {
276        let p = self.preview.as_mut().ok_or("preview the part first")?;
277        let (recognized, plan, selection) = if p.recognized.features.iter().any(|f| f.id == feature) {
278            (&p.recognized, &p.plan, &mut p.selected)
279        } else if let Some(s) = p.additional.iter_mut().find(|s| s.proposal.recognized.features.iter().any(|f| f.id == feature)) {
280            (&s.proposal.recognized, &s.proposal.plan, &mut s.selected)
281        } else { return Err(format!("no recognized feature `{feature}`")); };
282        let _ = recognized;
283        if selected && !plan.operations.iter().any(|p| p.feature == feature) { return Err(format!("feature `{feature}` has no planned operations")); }
284        if selected { selection.insert(feature.into()); } else { selection.remove(feature); }
285        Ok(())
286    }
287
288    pub fn select_setup(&mut self, setup: &str, selected: bool) -> Result<(), String> {
289        let p = self.preview.as_mut().ok_or("preview the part first")?;
290        if p.setup == setup { p.enabled = selected; }
291        else if let Some(s) = p.additional.iter_mut().find(|s| s.proposal.setup.id.as_str() == setup) { s.enabled = selected; }
292        else { return Err(format!("no proposed setup `{setup}`")); }
293        Ok(())
294    }
295
296    fn current<'a>(&'a self, state: &EngineState) -> Result<&'a Preview, String> {
297        let preview = self.preview.as_ref().ok_or("preview the part first")?;
298        let doc = cam::cam_document(state)?;
299        if doc != preview.document || revisions(state, doc.setup(&preview.setup).ok_or("setup was removed")?)? != preview.revisions {
300            return Err("the model or CAM inputs changed; preview the part again".into());
301        }
302        Ok(preview)
303    }
304
305    pub fn create(&mut self, state: &mut EngineState) -> Result<Vec<String>, String> {
306        let p = self.current(state)?;
307        let mut doc = p.document.clone();
308        let first = doc.setup(&p.setup).ok_or("setup was removed")?.clone();
309        let mut groups = Vec::new();
310        if p.enabled { groups.push((None, first.clone(), &p.recognized, &p.plan, &p.selected)); }
311        for s in p.additional.iter().filter(|s| s.enabled) {
312            groups.push((Some(s.orientation.clone()), s.proposal.setup.clone(), &s.proposal.recognized, &s.proposal.plan, &s.selected));
313        }
314        if !groups.iter().any(|(_,_,_,plan,selection)| plan.operations.iter().any(|op| selection.contains(&op.feature))) {
315            return Err("select at least one programmable feature".into());
316        }
317        let fingerprints: BTreeMap<_, _> = body_names(&first).into_iter().map(|name| {
318            let (_, hash) = state.scene.exact_solid_fingerprinted(&name).ok_or_else(|| format!("exact body `{name}` changed; preview again"))?;
319            Ok((name, hash))
320        }).collect::<Result<_, String>>()?;
321        let mut tools = BTreeMap::<String, String>::new();
322        for (_, _, _, plan, selection) in &groups {
323            for planned in plan.operations.iter().filter(|op| selection.contains(&op.feature)) {
324                let id = planned.tool.to_string();
325                if tools.contains_key(&id) { continue; }
326                if !p.fallback.contains(&id) { tools.insert(id.clone(), id); continue; }
327                let mut tool = p.tools.iter().find(|t| t.id == planned.tool).ok_or("planned tool disappeared")?.clone();
328                let new_id = doc.next_id("T");
329                tool.id = new_id.as_str().into();
330                tool.number = doc.tools.iter().map(|t| t.number).max().unwrap_or(0).checked_add(1).ok_or("tool numbers are exhausted")?;
331                if let Some(holder) = &mut tool.holder { holder.id = format!("{new_id}-holder").into(); }
332                doc.tools.push(tool); tools.insert(id, new_id);
333            }
334        }
335        let mut created = Vec::new();
336        let mut previous = first.id.clone();
337        for (orientation, mut setup, recognized, plan, selection) in groups {
338            let selected: Vec<_> = plan.operations.iter().filter(|op| selection.contains(&op.feature)).collect();
339            if selected.is_empty() { continue; }
340            if let Some(orientation) = orientation {
341                setup.id = doc.next_id("SETUP").into();
342                // Unticked/empty proposals are absent from the actual stock chain.
343                setup.stock = brep_cam::model::Stock::FromRest { setup: previous.clone() };
344                doc.set_orientation(setup.id.as_str(), orientation);
345                doc.setups.push(setup.clone());
346            }
347            for planned in selected {
348                let mut planned = planned.clone();
349                let id = doc.next_id("OP");
350                planned.operation.id = id.as_str().into();
351                planned.operation.setup = setup.id.clone();
352                planned.operation.tool = tools[planned.tool.as_str()].as_str().into();
353                planned.tool = planned.operation.tool.clone();
354                let tool = doc.tool(planned.tool.as_str()).ok_or("planned tool disappeared")?;
355                let feature = recognized.features.iter().find(|f| f.id == planned.feature).ok_or("planned feature disappeared")?;
356                let binding = brep_cam::autoprogram::binding::Binding::new(&planned, feature, &setup, tool, fingerprints.clone());
357                doc.autoprogram_inputs.insert(id.clone(), binding);
358                doc.operations.push(planned.operation); created.push(id);
359            }
360            previous = setup.id;
361        }
362        cam::write_cam_document(state, &doc, None);
363        self.close();
364        Ok(created)
365    }
366
367    pub fn state(&self, state: &EngineState) -> Value {
368        let programmed: Vec<_> = cam::cam_document(state).ok().map(|doc| doc.operations.iter().filter_map(|op| {
369            let binding = doc.autoprogram_inputs.get(op.id.as_str())?;
370            let path = state.cam.results.get(op.id.as_str()).and_then(|r| r.toolpath.as_ref());
371            let minimum_cut_z = path.and_then(|path| path.motions.iter().filter_map(|m| match &m.kind {
372                brep_cam::model::MotionKind::Cut { to } | brep_cam::model::MotionKind::ArcCw { to, .. } | brep_cam::model::MotionKind::ArcCcw { to, .. } => Some(to.z), _ => None,
373            }).reduce(f64::min));
374            Some(json!({ "id": op.id, "setup": op.setup, "feature": binding.feature.id, "kind": binding.feature.kind,
375                "status": crate::panels::cam_generate::status_of(state, &doc, op.id.as_str()).word(), "minimumCutZ": minimum_cut_z,
376                "warnings": path.map(|p| &p.report.warnings), "reRecognised": path.is_some_and(|p| p.report.warnings.iter().any(|w| w.code == "autoprogram-re-recognised")) }))
377        }).collect()).unwrap_or_default();
378        let all_programmed_ok = !programmed.is_empty() && programmed.iter().all(|op| op["status"] == "ok");
379        let Some(p) = &self.preview else { return json!({ "open": self.open, "busy": self.pending.is_some(), "error": self.error, "features": [], "operations": [], "allProgrammedOk": all_programmed_ok, "programmed": programmed }); };
380        let feature_rows = |recognized: &Recognized, plan: &Plan, selected: &BTreeSet<String>| recognized.features.iter().map(|f| json!({
381            "id": f.id, "kind": f.kind.name(), "solid": f.solid, "selected": selected.contains(&f.id),
382            "operations": plan.operations.iter().filter(|op| op.feature == f.id).map(|op| json!({
383                "id": op.operation.id, "name": op.operation.name, "strategy": op.operation.strategy.kind_name(),
384                "role": op.role, "tool": op.tool, "toolName": p.tools.iter().find(|t| t.id == op.tool).map(|t| &t.name),
385                "toolSource": if p.fallback.contains(op.tool.as_str()) { "builtin" } else { "document" }, "notes": op.notes
386            })).collect::<Vec<_>>(),
387            "refusals": plan.skipped.iter().filter(|s| s.feature == f.id).map(|s| &s.reason).collect::<Vec<_>>()
388        })).collect::<Vec<_>>();
389        let features = feature_rows(&p.recognized, &p.plan, &p.selected);
390        let first = p.document.setup(&p.setup).expect("preview setup");
391        let mut setups = vec![json!({ "id": p.setup, "name": first.name, "axis": "+Z", "selected": p.enabled,
392            "additional": false, "orientation": first.model_to_machine, "stock": first.stock,
393            "features": features, "operationCount": if p.enabled { p.plan.operations.iter().filter(|op| p.selected.contains(&op.feature)).count() } else { 0 } })];
394        let mut previous = p.setup.clone();
395        for proposed in &p.additional {
396            let proposal = &proposed.proposal;
397            let count = if proposed.enabled { proposal.plan.operations.iter().filter(|op| proposed.selected.contains(&op.feature)).count() } else { 0 };
398            setups.push(json!({ "id": proposal.setup.id, "name": proposal.setup.name, "axis": proposal.axis.map(|a| a.name()),
399                "selected": proposed.enabled, "additional": true, "orientation": proposal.setup.model_to_machine,
400                "stock": brep_cam::model::Stock::FromRest { setup: previous.as_str().into() }, "covered": proposal.covered,
401                "features": feature_rows(&proposal.recognized, &proposal.plan, &proposed.selected), "operationCount": count }));
402            if count > 0 { previous = proposal.setup.id.to_string(); }
403        }
404        let operation_count: u64 = setups.iter().filter_map(|s| s["operationCount"].as_u64()).sum();
405
406        json!({ "open": self.open, "busy": false, "error": self.error, "stale": self.current(state).is_err(), "allProgrammedOk": all_programmed_ok, "programmed": programmed,
407            "setup": p.setup, "setups": setups, "setupCount": setups.len(), "unreached": p.unreached, "features": features, "featureCount": features.len(),
408            "selectedCount": p.selected.len(), "operationCount": operation_count,
409            "unassignedFaces": p.recognized.unassigned,
410            "faceGroups": p.recognized.accounting.as_ref().map(|a| { let c = brep_cam::features::accounting::group_counts(a);
411                json!({ "feature": c[0], "refused": c[1], "body": c[2], "unexplained": c[3] }) }),
412            "unexplainedFaces": p.recognized.accounting.as_ref().map(|a| a.iter().filter(|f| f.group == brep_cam::features::FaceGroup::Unexplained)
413                .map(|f| json!([f.face, f.why])).collect::<Vec<_>>()),
414            "material": "aluminium",
415            "notice": "Document tools are preferred. Tools labelled Builtin are proposed fallback tools. Cutting data assumes aluminium." })
416    }
417
418    pub fn close(&mut self) { self.open = false; self.pending = None; self.preview = None; self.error = None; }
419
420    /// The caller owns the small shared pane integration; all preview rows
421    /// and their stable hit keys are owned here.
422    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState, hits: &mut HashMap<String, egui::Rect>) {
423        self.poll(state);
424        if !self.open { return; }
425        ui.separator();
426        ui.heading("Program part automatically");
427        ui.label("Preview every feature before creating operations. Builtin tools are proposed only where document tools do not fit. Cutting data: aluminium.");
428        if self.pending.is_some() { ui.spinner(); ui.label("Reading exact part geometry…"); }
429        if let Some(why) = &self.error { ui.colored_label(egui::Color32::RED, why); }
430        if let Some(p) = &mut self.preview {
431            ui.label("Select setups before creating operations. Later setups continue from the preceding selected setup's stock.");
432            let mut groups = vec![(p.setup.as_str(), "Current setup", &mut p.enabled, &p.recognized, &p.plan, &mut p.selected)];
433            for extra in &mut p.additional {
434                groups.push((extra.proposal.setup.id.as_str(), extra.proposal.setup.name.as_str(), &mut extra.enabled,
435                    &extra.proposal.recognized, &extra.proposal.plan, &mut extra.selected));
436            }
437            for (id, name, enabled, recognized, plan, selected) in groups {
438                let response = ui.checkbox(enabled, name);
439                hits.insert(format!("cam:auto:setup:{id}"), response.rect);
440                if id != p.setup { ui.small("Stock: result of the preceding selected setup. Refixture and verify the work offset before running this setup."); }
441                ui.add_enabled_ui(*enabled, |ui| {
442                    for feature in &recognized.features {
443                        let ops: Vec<_> = plan.operations.iter().filter(|op| op.feature == feature.id).collect();
444                        let mut checked = selected.contains(&feature.id);
445                        let response = ui.add_enabled(!ops.is_empty(), egui::Checkbox::new(&mut checked, format!("{} · {} · {}", feature.id, feature.kind.name(), feature.solid)));
446                        hits.insert(format!("cam:auto:feature:{}", feature.id), response.rect);
447                        if response.changed() { if checked { selected.insert(feature.id.clone()); } else { selected.remove(&feature.id); } }
448                        for op in ops {
449                            let tool = p.tools.iter().find(|t| t.id == op.tool).expect("planned tool");
450                            ui.label(format!("{} — {} ({})", op.operation.name, tool.name, if p.fallback.contains(op.tool.as_str()) { "Builtin fallback" } else { "Document tool" }));
451                            for note in &op.notes { ui.small(note); }
452                        }
453                        for refusal in plan.skipped.iter().filter(|s| s.feature == feature.id) { ui.label(format!("Not programmed here: {}", refusal.reason)); }
454                    }
455                });
456            }
457            for refusal in &p.unreached { ui.label(format!("Not reached: {}", refusal.reason)); }
458            match &p.recognized.accounting {
459                None => { ui.small("Face accounting: unknown (recognised before accounting existed)."); }
460                Some(accounting) => {
461                    let c = brep_cam::features::accounting::group_counts(accounting);
462                    ui.label(format!("Faces: {} in features · {} refused · {} body · {} unexplained", c[0], c[1], c[2], c[3]));
463                    let unexplained: Vec<_> = accounting.iter().filter(|f| f.group == brep_cam::features::FaceGroup::Unexplained).collect();
464                    if !unexplained.is_empty() {
465                        egui::CollapsingHeader::new(format!("Unexplained faces ({})", unexplained.len())).id_salt("cam:auto:unexplained").show(ui, |ui| {
466                            for f in unexplained { ui.small(format!("{} · {}", f.face, f.why)); }
467                        });
468                    }
469                }
470            }
471        }
472        if let Err(why) = self.current(state) {
473            if self.preview.is_some() { ui.colored_label(egui::Color32::YELLOW, why); }
474        }
475        ui.horizontal(|ui| {
476            let create = ui.add_enabled(self.current(state).is_ok(), egui::Button::new("Create selected operations"));
477            hits.insert("cam:auto:create".into(), create.rect);
478            if create.clicked() { if let Err(why) = self.create(state) { self.error = Some(why); } }
479            let close = ui.button("Close");
480            hits.insert("cam:auto:close".into(), close.rect);
481            if close.clicked() { self.close(); }
482        });
483    }
484}
485
486#[cfg(feature = "automation")]
487#[derive(serde::Deserialize, schemars::JsonSchema)]
488#[serde(deny_unknown_fields)]
489pub struct CommandArgs {
490    /// preview, state, select, selectSetup, create or close.
491    pub action: String,
492    #[serde(default)]
493    pub setup: Option<String>,
494    #[serde(default)]
495    pub feature: Option<String>,
496    #[serde(default)]
497    pub selected: Option<bool>,
498}
499
500#[cfg(feature = "automation")]
501pub fn command(ctx: &mut crate::automation::command::Ctx<'_>, args: Value) -> Result<crate::automation::command::Outcome, String> {
502    use crate::automation::command::{parse_args, Outcome};
503    let args: CommandArgs = parse_args(args)?;
504    ctx.app.cam.autoprogram.poll(ctx.app.docs.engine_mut());
505    match args.action.as_str() {
506        "preview" => {
507            let setup = args.setup.ok_or("preview requires setup")?;
508            ctx.app.cam.set_open(None);
509            ctx.app.cam.autoprogram.preview(ctx.app.docs.engine_mut(), &setup)?;
510        }
511        "select" => ctx.app.cam.autoprogram.select(&args.feature.ok_or("select requires feature")?, args.selected.ok_or("select requires selected")?)?,
512        "selectSetup" => ctx.app.cam.autoprogram.select_setup(&args.setup.ok_or("selectSetup requires setup")?, args.selected.ok_or("selectSetup requires selected")?)?,
513        "create" => return Ok(Outcome::Done(json!({ "created": ctx.app.cam.autoprogram.create(ctx.app.docs.engine_mut())? }))),
514        "close" => ctx.app.cam.autoprogram.close(),
515        "state" => {},
516        _ => return Err("action must be preview, state, select, selectSetup, create or close".into()),
517    }
518    Ok(Outcome::Done(ctx.app.cam.autoprogram.state(ctx.app.docs.engine())))
519}