Skip to main content

brep_app/panels/
cam.rs

1//! The CAM pane — the browser tree of the CAM workbench
2//! ([`crate::workbench::cam`]): **Setups**, each with its operations, **NC
3//! Programs** and the **Tool Library**, over the document's `cam` block.
4//!
5//! The pane holds only the widget's transient state (its hit rects, the
6//! selected row, an action a toolbar button handed it). Every record it draws
7//! is read from the engine's document each frame through
8//! [`crate::document::cam`], which is the one place the block's shape is
9//! spelled, and every edit is one checkpointed write of the whole block
10//! ([`History::set_cam_block`](brep_render::history::History::set_cam_block)) — undo and save / reload come from the
11//! document, not from here.
12//!
13//! A block the document module cannot read is left exactly as it is: the
14//! pane shows why under its header and refuses every mutation until the
15//! document is repaired, so nothing a user wrote is ever saved over.
16
17use crate::automation::hit_keys::HitKeyDoc;
18use crate::document::cam::{self as cam_doc, resolve_frame, strategy_kinds, CamDocument, SetupFrame, SetupOrientation};
19use crate::form_view::{form_view, FormViewSpec};
20use crate::panels::file::{SaveBody, SaveRequest};
21use crate::panels::{cam_generate, cam_library, cam_machine, cam_operation, cam_setup, cam_tool};
22use crate::store::ModelStore;
23use brep_cam::model::{Point3, RigidTransform};
24use brep_render::engine_state::{EngineState, RefSelectTarget};
25use eframe::egui;
26use serde_json::{json, Value};
27use std::collections::{BTreeMap, HashMap};
28
29/// An action a toolbar button hands the pane (the shell's dispatch arms in
30/// `app.rs`), performed on the next frame the pane draws — which the shell
31/// guarantees by surfacing the pane in the same arm.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CamRequest {
34    NewSetup,
35    NewTool,
36    NewOperation,
37    Generate,
38}
39
40/// The document's `cam` block, or why it cannot be read.
41/// The handle an operation row shows for its tool: the tool's NUMBER as the
42/// Tool Library row shows it ("T1"), not its record id ("T2") — the two read
43/// as different tools when the ids and the numbers drifted apart
44/// (ui-polish-2026-10-28 row 6). A tool the document no longer holds keeps
45/// its id, which is the honest reading of a dangling reference.
46fn tool_handle(doc: &CamDocument, tool: &brep_cam::model::ToolId) -> String {
47    doc.tools.iter().find(|t| &t.id == tool).map_or_else(|| tool.to_string(), |t| format!("T{}", t.number))
48}
49
50pub fn cam_document(state: &EngineState) -> Result<CamDocument, String> {
51    cam_doc::load(state.history.cam_block())
52}
53
54/// Write the block back as a USER edit (one undo step). `coalesce` groups a
55/// run of rapid same-target edits (typing in one field) into one step.
56pub fn write_cam_document(state: &mut EngineState, doc: &CamDocument, coalesce: Option<&str>) {
57    state.history.set_cam_block(cam_doc::store(doc), coalesce);
58    state.dirty = true;
59}
60
61/// Write the block back WITHOUT an undo step — for a derived value that
62/// belongs to the edit just made (the frame a pick resolves to).
63fn write_cam_document_no_undo(state: &mut EngineState, doc: &CamDocument) {
64    state.history.set_cam_block_no_undo(cam_doc::store(doc));
65    state.dirty = true;
66}
67
68/// The frame setup `id`'s picks resolve to against the model on screen, or
69/// why they do not.
70pub fn setup_frame(state: &EngineState, doc: &CamDocument, id: &str) -> Result<SetupFrame, String> {
71    let orientation = doc.orientation(id);
72    let face = |name: &str| cam_doc::classify_face(&face_triangles(state, name));
73    // A feature's published axis (a helix's `…:Axis`) reads as a straight
74    // edge along it.
75    let edge = |name: &str| {
76        state.scene.edge_polyline_world(name).or_else(|| {
77            state.cam_named_axis(name).map(|(p, d)| vec![p, [p[0] + d[0], p[1] + d[1], p[2] + d[2]]])
78        })
79    };
80    resolve_frame(&orientation, &face, &edge)
81}
82
83/// The world triangles of the face named `name` (empty when no solid on
84/// screen carries it), for the orientation's face classifier.
85fn face_triangles(state: &EngineState, name: &str) -> Vec<[[f64; 3]; 3]> {
86    for solid in state.scene.solids() {
87        let Some(face) = solid.faces.iter().find(|f| f.name == name) else { continue };
88        let (positions, indices) = (&solid.mesh.positions, &solid.mesh.indices);
89        let start = face.tri_start as usize;
90        let end = (start + face.tri_count as usize).min(indices.len() / 3);
91        let p = |i: u32| {
92            let v = positions[i as usize];
93            [v[0] as f64, v[1] as f64, v[2] as f64]
94        };
95        return (start..end).map(|t| [p(indices[t * 3]), p(indices[t * 3 + 1]), p(indices[t * 3 + 2])]).collect();
96    }
97    Vec::new()
98}
99
100/// Apply a form PATCH (`cam_setup::apply_setup_form`) to setup `id`:
101/// one undo step, coalesced per setup and field run when `coalesce` is set.
102pub fn update_setup(state: &mut EngineState, id: &str, patch: &Value, coalesce: Option<&str>) -> Result<(), String> {
103    edit_cam_document(state, coalesce, |doc| {
104        let mut orientation = doc.orientation(id);
105        let setup = doc.setup_mut(id).ok_or_else(|| format!("no setup `{id}`"))?;
106        cam_setup::apply_setup_form(patch, setup, &mut orientation)?;
107        // A rest stock starts from an EARLIER setup's simulated stock.
108        if let brep_cam::model::Stock::FromRest { setup: source } = &setup.stock {
109            let source = source.as_str().to_string();
110            let at = |name: &str| doc.setups.iter().position(|s| s.id.as_str() == name);
111            match (at(&source), at(id)) {
112                (Some(s), Some(me)) if s < me => {}
113                (Some(_), _) => return Err(format!("stockSetup: setup `{source}` comes after `{id}`; a rest stock starts from an earlier setup")),
114                (None, _) => return Err(format!("stockSetup: no setup `{source}`")),
115            }
116        }
117        doc.set_orientation(id, orientation);
118        cam_setup::apply_machine_form(patch, doc, id)?;
119        Ok(())
120    })?;
121    sync_setup_frame(state, id);
122    Ok(())
123}
124
125/// Write the frame setup `id`'s picks resolve to into its `modelToMachine`
126/// when it differs from what is stored — the derived half of a pick or an
127/// orientation edit, written without its own undo step. Picks that do not
128/// resolve leave the stored transform alone; the dialog shows why.
129pub fn sync_setup_frame(state: &mut EngineState, id: &str) -> Option<String> {
130    let doc = cam_document(state).ok()?;
131    let frame = match setup_frame(state, &doc, id) {
132        Ok(frame) => frame,
133        Err(why) => return Some(why),
134    };
135    let transform = match frame.transform() {
136        Ok(t) => t,
137        Err(why) => return Some(why),
138    };
139    let setup = doc.setup(id)?;
140    if setup.model_to_machine == transform {
141        return None;
142    }
143    let mut next = doc.clone();
144    next.setup_mut(id)?.model_to_machine = transform;
145    write_cam_document_no_undo(state, &next);
146    None
147}
148
149/// Apply a form PATCH (`cam_tool::apply_tool_form`) to tool `id`: one undo
150/// step, coalesced per tool and field run when `coalesce` is set.
151pub fn update_tool(state: &mut EngineState, id: &str, patch: &Value, coalesce: Option<&str>) -> Result<(), String> {
152    edit_cam_document(state, coalesce, |doc| {
153        let tool = doc.tool_mut(id).ok_or_else(|| format!("no tool `{id}`"))?;
154        cam_tool::apply_tool_form(patch, tool)
155    })
156}
157
158/// Add a custom machine copied from `base` (a built-in id, or another custom
159/// machine's), named `name`. Returns the id.
160pub fn add_machine(state: &mut EngineState, base: &str, name: Option<&str>) -> Result<String, String> {
161    edit_cam_document(state, None, |doc| {
162        let source = doc.machine(base).ok_or_else(|| format!("base: no machine `{base}` (a built-in id or a document machine)"))?;
163        Ok(doc.add_machine(source, name))
164    })
165}
166
167/// Apply a machine editor PATCH (`cam_machine::apply_machine_form`) to the
168/// document's machine `id`; a built-in is copied first (`add_machine`).
169pub fn update_machine(state: &mut EngineState, id: &str, patch: &Value, coalesce: Option<&str>) -> Result<(), String> {
170    edit_cam_document(state, coalesce, |doc| {
171        let machine = doc.machine_mut(id).ok_or_else(|| format!("no document machine `{id}` (a built-in is copied into the document first)"))?;
172        cam_machine::apply_machine_form(patch, machine)
173    })
174}
175
176/// Duplicate tool `id` under a fresh id and the next free number. Returns
177/// the new id.
178pub fn duplicate_tool(state: &mut EngineState, id: &str) -> Result<String, String> {
179    edit_cam_document(state, None, |doc| {
180        let source = doc.tool(id).ok_or_else(|| format!("no tool `{id}`"))?.clone();
181        let number = doc.tools.iter().map(|t| t.number).max().unwrap_or(0) + 1;
182        let new_id = doc.next_id("T");
183        doc.tools.push(cam_tool::duplicate(&source, &new_id, number));
184        Ok(new_id)
185    })
186}
187
188/// Feeds and speeds for tool `id` from `material`, through C12's
189/// calculator, written into the tool. Returns the calculator's adjustments.
190pub fn calculate_tool_feeds(state: &mut EngineState, id: &str, material: &str) -> Result<Vec<String>, String> {
191    edit_cam_document(state, None, |doc| {
192        let tool = doc.tool_mut(id).ok_or_else(|| format!("no tool `{id}`"))?;
193        cam_tool::calculate_feeds(tool, material)
194    })
195}
196
197/// Add user-library tool `library_id` to the document under a fresh id and
198/// the next free number (its name and geometry as the library has them).
199pub fn add_tool_from_library(state: &mut EngineState, store: &dyn ModelStore, library_id: &str) -> Result<String, String> {
200    let source = cam_library::tool(store, library_id)?;
201    edit_cam_document(state, None, |doc| {
202        let number = doc.tools.iter().map(|t| t.number).max().unwrap_or(0) + 1;
203        let id = doc.next_id("T");
204        let mut tool = source.clone();
205        tool.id = id.as_str().into();
206        tool.number = number;
207        if let Some(holder) = &mut tool.holder {
208            holder.id = format!("{id}-holder").into();
209        }
210        doc.tools.push(tool);
211        Ok(id)
212    })
213}
214
215/// Import a library file's text (`cam_library::import_text`) into the
216/// document's tools, each under a fresh id and number. Returns the ids added
217/// and the importer's skipped rows.
218pub fn import_tools(state: &mut EngineState, name: &str, text: &str) -> Result<(Vec<String>, Vec<String>), String> {
219    let report = cam_library::import_text(name, text)?;
220    let issues: Vec<String> = report.issues.iter().map(|i| format!("row {}: {}", i.row, i.message)).collect();
221    let ids = edit_cam_document(state, None, |doc| {
222        let mut ids = Vec::new();
223        let mut number = doc.tools.iter().map(|t| t.number).max().unwrap_or(0);
224        for mut tool in report.document.library.tools.clone() {
225            number += 1;
226            let id = doc.next_id("T");
227            tool.id = id.as_str().into();
228            tool.number = number;
229            if let Some(holder) = &mut tool.holder {
230                holder.id = format!("{id}-holder").into();
231            }
232            doc.tools.push(tool);
233            ids.push(id);
234        }
235        Ok(ids)
236    })?;
237    Ok((ids, issues))
238}
239
240/// The document's tools as a library document, for export.
241pub fn export_document_tools(state: &EngineState) -> Result<String, String> {
242    let doc = cam_document(state)?;
243    let mut library = brep_cam::library::ToolLibrary::new("Document tools");
244    library.tools = doc.tools.clone();
245    brep_cam::library::interchange::LibraryDocument::new(library).to_json()
246}
247
248/// Apply a form PATCH (`cam_operation::apply_operation_form`) to operation
249/// `id`: one undo step, coalesced per operation and field run when
250/// `coalesce` is set. Face references the patch names get their owning
251/// solid from the scene.
252pub fn update_operation(state: &mut EngineState, id: &str, patch: &Value, coalesce: Option<&str>) -> Result<(), String> {
253    let owners = face_owners(state);
254    let edge_owners = edge_owners(state);
255    edit_cam_document(state, coalesce, |doc| {
256        let tools = doc.tools.clone();
257        // The multi-axis finish's source designation (lane C17), refused by
258        // field before anything is applied.
259        crate::panels::cam_multiaxis::apply_source(patch, doc, id)?;
260        let operation = doc.operation_mut(id).ok_or_else(|| format!("no operation `{id}`"))?;
261        cam_operation::apply_operation_form(patch, operation, &tools)?;
262        cam_operation::resolve_face_owners(operation, &|face| owners.get(face).cloned());
263        cam_operation::resolve_edge_owners(operation, &|edge| edge_owners.get(edge).cloned());
264        Ok(())
265    })
266}
267
268/// Every edge name on screen → its solid's name.
269fn edge_owners(state: &EngineState) -> HashMap<String, String> {
270    let mut owners = HashMap::new();
271    for solid in state.scene.solids() {
272        for edge in solid.edges.iter().filter(|e| !e.name.is_empty()) {
273            owners.insert(edge.name.clone(), solid.name.clone());
274        }
275    }
276    owners
277}
278
279/// Every face name on screen → its solid's name.
280fn face_owners(state: &EngineState) -> HashMap<String, String> {
281    let mut owners = HashMap::new();
282    for solid in state.scene.solids() {
283        for face in &solid.faces {
284            owners.insert(face.name.clone(), solid.name.clone());
285        }
286    }
287    owners
288}
289
290/// Fill the owning solid of operation `id`'s picked faces from the scene —
291/// the derived half of a face pick, written without its own undo step — and
292/// sample a committed machining-boundary pick into its containment boundary.
293/// A boundary pick that cannot be used (not a sketch profile, a plane not
294/// across the setup's Z) is consumed and refused by name; the boundary stays.
295pub fn sync_operation_faces(state: &mut EngineState, id: &str) -> Result<(), String> {
296    let owners = face_owners(state);
297    let edge_owners = edge_owners(state);
298    // A committed chain pick is folded into the model by the load; the block
299    // is rewritten once so it no longer carries the staging key.
300    let block = state.history.cam_block();
301    let pending = block.as_ref().is_some_and(|b| cam_doc::has_pending_chain_pick(b));
302    let boundary_pick = block.as_ref().and_then(|b| cam_doc::pending_boundary_pick(b, id));
303    let Ok(mut doc) = cam_document(state) else { return Ok(()) };
304    let setup = doc.operation(id).and_then(|op| doc.setup(op.setup.as_str())).cloned();
305    let tools = doc.tools.clone();
306    let Some(operation) = doc.operation_mut(id) else { return Ok(()) };
307    let faces = cam_operation::resolve_face_owners(operation, &|face| owners.get(face).cloned());
308    let edges = cam_operation::resolve_edge_owners(operation, &|edge| edge_owners.get(edge).cloned());
309    let mut refused = None;
310    if let Some(names) = &boundary_pick {
311        let chord = operation.tolerance / 4.0;
312        let sampled = setup
313            .as_ref()
314            .ok_or_else(|| format!("{id}: its setup is not defined"))
315            .and_then(|setup| boundary_from_profile(state, names, setup, chord))
316            .and_then(|loops| {
317                let loops: Vec<Value> = loops.iter().map(|l| json!(l.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())).collect();
318                cam_operation::apply_operation_form(&json!({ "strategy": { "containment": { "boundary": loops } } }), operation, &tools).map_err(|e| format!("{id}: {e}"))
319            });
320        refused = sampled.err();
321    }
322    if faces || edges || pending || boundary_pick.is_some() {
323        write_cam_document_no_undo(state, &doc);
324    }
325    refused.map_or(Ok(()), Err)
326}
327
328/// The loops of the sketch profile `names` picks (every region, outer and
329/// holes — the containment reads loops even-odd), sampled within `chord` and
330/// taken into the machine XY of `setup`. Refused by name for anything but one
331/// sketch profile, and for a profile whose plane is not across machine Z.
332fn boundary_from_profile(state: &EngineState, names: &[String], setup: &brep_cam::model::Setup, chord: f64) -> Result<Vec<Vec<brep_cam::model::Point2>>, String> {
333    use brep_cam::model::{ChainSource, Point2};
334    let chain = cam_doc::chain_from_names(names).ok_or_else(|| "nothing was picked for the machining boundary".to_string())?;
335    if !matches!(chain.source, ChainSource::SketchProfile { .. }) {
336        return Err(format!("the machining boundary is picked as a sketch profile (a sketch's face), not `{}`", names.join(", ")));
337    }
338    let (_, profiles) = state.cam_chain_geometry(std::slice::from_ref(&chain))?;
339    let (sketch, profile) = profiles.first().ok_or_else(|| "the picked sketch has no profile".to_string())?;
340    let mut loops = Vec::new();
341    for region in &profile.regions {
342        for profile_loop in region {
343            let mut points: Vec<Point3> = Vec::new();
344            for curve in &profile_loop.curves {
345                for p in sample_curve(curve, chord)? {
346                    let p = setup.model_to_machine.apply_point(Point3::new(p.x, p.y, p.z));
347                    if points.last().is_none_or(|q: &Point3| q.distance(p) > 1e-9) {
348                        points.push(p);
349                    }
350                }
351            }
352            if points.len() > 1 && points[0].distance(*points.last().expect("non-empty")) <= 1e-9 {
353                points.pop();
354            }
355            let (lo, hi) = points.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.z), hi.max(p.z)));
356            if hi - lo > chord.max(1e-6) {
357                return Err(format!("sketch `{sketch}` is not across the setup's Z (its profile spans {:.4} in machine Z); pick a profile parallel to the machine's XY", hi - lo));
358            }
359            loops.push(points.iter().map(|p| Point2::new(p.x, p.y)).collect());
360        }
361    }
362    if loops.is_empty() {
363        return Err(format!("sketch `{sketch}` has no closed profile"));
364    }
365    Ok(loops)
366}
367
368/// Points along `curve` whose chords stay within `chord` of it: a line its
369/// two ends; a curved span split until each chord's midpoint deviation is
370/// within `chord` (16 halvings at most).
371fn sample_curve(curve: &brep_render::brep_kernel::NurbsCurve, chord: f64) -> Result<Vec<brep_render::brep_kernel::Vec3>, String> {
372    use brep_render::brep_kernel::Vec3;
373    let [a, b] = curve.domain()?;
374    let at = |t: f64| curve.evaluate(t);
375    let dist = |p: Vec3, q: Vec3, m: Vec3| {
376        let (dx, dy, dz) = (q.x - p.x, q.y - p.y, q.z - p.z);
377        let (mx, my, mz) = (m.x - p.x, m.y - p.y, m.z - p.z);
378        let len2 = dx * dx + dy * dy + dz * dz;
379        let t = if len2 > 0.0 { ((mx * dx + my * dy + mz * dz) / len2).clamp(0.0, 1.0) } else { 0.0 };
380        ((mx - t * dx).powi(2) + (my - t * dy).powi(2) + (mz - t * dz).powi(2)).sqrt()
381    };
382    let mut out = vec![at(a)?];
383    // A curved span starts at eight pieces, so an S-bend's chord midpoint
384    // cannot sit on the curve by chance.
385    let pieces = if curve.degree <= 1 { (curve.control_points.len() - 1).max(1) } else { 8 * (curve.control_points.len() - curve.degree).max(1) };
386    let mut stack: Vec<(f64, f64, u32)> = (0..pieces).rev().map(|i| (a + (b - a) * i as f64 / pieces as f64, a + (b - a) * (i + 1) as f64 / pieces as f64, 0)).collect();
387    while let Some((t0, t1, depth)) = stack.pop() {
388        let (p0, p1, pm) = (at(t0)?, at(t1)?, at(0.5 * (t0 + t1))?);
389        if curve.degree > 1 && depth < 16 && dist(p0, p1, pm) > chord {
390            stack.push((0.5 * (t0 + t1), t1, depth + 1));
391            stack.push((t0, 0.5 * (t0 + t1), depth + 1));
392        } else {
393            out.push(p1);
394        }
395    }
396    Ok(out)
397}
398
399/// Arm the reference picker for operation `id`'s field `key`; refused for a
400/// key that is not one of its reference fields.
401pub fn begin_operation_pick(state: &mut EngineState, id: &str, key: &str) -> Result<(), String> {
402    let doc = cam_document(state)?;
403    let operation = doc.operation(id).ok_or_else(|| format!("no operation `{id}`"))?;
404    let (path, filter, multiple, label) = cam_operation::pick_path(id, key).ok_or_else(|| format!("`{key}` is not a reference field of an operation"))?;
405    let params = cam_operation::operation_form_params(operation);
406    let seed: Vec<String> = params["targets"].get(key).and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
407    if state.ref_select_active() {
408        state.finish_ref_select();
409    }
410    state.begin_ref_select_for_cam(id, path, format!("{}: {label}", operation.name), filter, multiple, seed);
411    Ok(())
412}
413
414/// Queue the generation of `ops` (every enabled operation with targets when
415/// `None`): the jobs the engine's runner takes, one operation each. Returns
416/// the ids queued and, per operation that could not be queued, why.
417pub fn generate(state: &mut EngineState, ops: Option<&[String]>) -> Result<(Vec<String>, Vec<(String, String)>), String> {
418    let doc = cam_document(state)?;
419    let ids: Vec<String> = match ops {
420        Some(ids) => ids.to_vec(),
421        None => doc.operations.iter().filter(|op| op.enabled).map(|op| op.id.as_str().to_string()).collect(),
422    };
423    let mut jobs = Vec::new();
424    let mut refused = Vec::new();
425    for id in ids {
426        match cam_generate::job_for(state, &doc, &id) {
427            Ok(job) => jobs.push(job),
428            Err(why) => refused.push((id, why)),
429        }
430    }
431    state.cam.job_builder.get_or_insert(cam_generate::job_at_dispatch);
432    let queued = state.cam_enqueue(jobs);
433    Ok((queued, refused))
434}
435
436/// Arm the reference picker for setup `id`'s field `key`, seeded with the
437/// field's current names; refused for a key that is not a reference field.
438pub fn begin_setup_pick(state: &mut EngineState, id: &str, key: &str) -> Result<(), String> {
439    let doc = cam_document(state)?;
440    let setup = doc.setup(id).ok_or_else(|| format!("no setup `{id}`"))?;
441    let (path, filter, multiple, label) = cam_setup::pick_path(id, key).ok_or_else(|| format!("`{key}` is not a reference field of a setup"))?;
442    let params = cam_setup::setup_form_params(setup, &doc.orientation(id));
443    let seed: Vec<String> = match params.get(key) {
444        Some(Value::Array(list)) => list.iter().filter_map(Value::as_str).map(str::to_string).collect(),
445        Some(Value::String(s)) if !s.is_empty() => vec![s.clone()],
446        _ => Vec::new(),
447    };
448    if state.ref_select_active() {
449        state.finish_ref_select();
450    }
451    state.begin_ref_select_for_cam(id, path, format!("{}: {label}", setup.name), filter, multiple, seed);
452    Ok(())
453}
454
455/// Read, change and write the block in one step; a block that cannot be read
456/// refuses the change with the read error, and a refused change writes
457/// nothing.
458pub fn edit_cam_document<T>(
459    state: &mut EngineState,
460    coalesce: Option<&str>,
461    change: impl FnOnce(&mut CamDocument) -> Result<T, String>,
462) -> Result<T, String> {
463    let mut doc = cam_document(state)?;
464    let out = change(&mut doc)?;
465    write_cam_document(state, &doc, coalesce);
466    Ok(out)
467}
468
469/// Add a setup: returns the id.
470pub fn add_setup(state: &mut EngineState, name: Option<&str>) -> Result<String, String> {
471    edit_cam_document(state, None, |doc| Ok(doc.add_setup(name)))
472}
473
474/// Add a tool: returns the id.
475pub fn add_tool(state: &mut EngineState, name: Option<&str>) -> Result<String, String> {
476    edit_cam_document(state, None, |doc| Ok(doc.add_tool(name)))
477}
478
479/// Add an operation of strategy kind `kind` to `setup` with `tool`; refused,
480/// naming what is missing, when any of them does not exist. Returns the id.
481pub fn add_operation(state: &mut EngineState, setup: &str, tool: &str, kind: &str, name: Option<&str>) -> Result<String, String> {
482    edit_cam_document(state, None, |doc| doc.add_operation(setup, tool, kind, name))
483}
484
485/// Add an NC program over `operations` (each must exist). Returns the id.
486pub fn add_program(state: &mut EngineState, name: Option<&str>, post: &str, operations: &[String]) -> Result<String, String> {
487    add_program_with(state, name, post, operations, &BTreeMap::new())
488}
489
490/// [`add_program`] with post properties, in one step: a refused property
491/// leaves no program behind.
492pub fn add_program_with(state: &mut EngineState, name: Option<&str>, post: &str, operations: &[String], properties: &BTreeMap<String, Value>) -> Result<String, String> {
493    edit_cam_document(state, None, |doc| {
494        let id = doc.add_program(name, post, operations)?;
495        if !properties.is_empty() {
496            doc.set_program_properties(&id, properties)?;
497        }
498        Ok(id)
499    })
500}
501
502/// Set or clear (`null`) post properties of NC program `id`.
503pub fn set_program_properties(state: &mut EngineState, id: &str, patch: &BTreeMap<String, Value>) -> Result<(), String> {
504    edit_cam_document(state, None, |doc| doc.set_program_properties(id, patch))
505}
506
507/// Remove the record with `id` (see [`CamDocument::remove`]).
508pub fn remove_record(state: &mut EngineState, id: &str) -> Result<(), String> {
509    edit_cam_document(state, None, |doc| doc.remove(id))
510}
511
512/// One setup's resolved geometry, as the viewport draws it and `cam_state`
513/// reports it.
514#[derive(Clone, Debug, PartialEq)]
515pub struct SetupGeometry {
516    pub frame: Result<SetupFrame, String>,
517    pub stock: Option<(Point3, Point3)>,
518}
519
520/// A stamp of everything the setups' geometry is read from: the document
521/// revision (the block) and the scene's solids (count, content revisions,
522/// visibility). Equal stamps mean equal frames and stock boxes.
523fn geometry_stamp(state: &EngineState) -> (u64, u64) {
524    let mut scene: u64 = 0xcbf2_9ce4_8422_2325;
525    for solid in state.scene.solids() {
526        for byte in solid.name.bytes().chain([solid.visible as u8]).chain(solid.revision.to_le_bytes()) {
527            scene ^= byte as u64;
528            scene = scene.wrapping_mul(0x0000_0100_0000_01b3);
529        }
530    }
531    (state.history.revision(), scene)
532}
533
534/// The block as the `cam_state` command and the `__brepCam` blob report it:
535/// every record in its model serde form, each setup with the ids of its
536/// operations, the strategy kinds an operation can be, the pane's selection
537/// and its last notice — or, for a block that cannot be read, `error` and
538/// empty lists. The setups' frames and stock boxes come from the pane's
539/// cache ([`CamPanel::refresh_geometry`]), which the caller refreshes first.
540pub fn cam_state_json(state: &EngineState, panel: &CamPanel) -> String {
541    let (doc, error) = match cam_document(state) {
542        Ok(doc) => (doc, None),
543        Err(why) => (CamDocument::default(), Some(why)),
544    };
545    json!({
546        "setups": doc.setups.iter().map(|s| {
547            let mut record = serde_json::to_value(s).unwrap_or(Value::Null);
548            if let Some(object) = record.as_object_mut() {
549                object.insert("operations".into(), json!(doc.operations_of(s.id.as_str()).map(|op| op.id.as_str()).collect::<Vec<_>>()));
550            }
551            record
552        }).collect::<Vec<_>>(),
553        "tools": doc.tools,
554        "operations": doc.operations,
555        // What each operation's strategy still needs before it generates.
556        "problems": doc.operations.iter().filter_map(|op| cam_operation::strategy_problem(op).map(|p| (op.id.as_str().to_string(), json!(p)))).collect::<serde_json::Map<String, Value>>(),
557        "ncPrograms": doc.nc_programs,
558        "multiAxisSources": doc.multi_axis_sources,
559        "idCounter": doc.id_counter,
560        "strategies": strategy_kinds(),
561        "orientations": doc.orientations,
562        // Every setup's resolved frame and stock box, as the viewport draws
563        // them: `null` where the picks do not resolve (`frameError` says why).
564        "frames": doc.setups.iter().map(|s| {
565            let id = s.id.as_str();
566            let geometry = panel.geometry.get(id).cloned().unwrap_or_else(|| SetupGeometry { frame: setup_frame(state, &doc, id), stock: cam_setup::stock_bounds(s, &state.scene) });
567            let (frame, frame_error) = match geometry.frame {
568                Ok(frame) => (Some(frame), None),
569                Err(why) => (None, Some(why)),
570            };
571            let p = |p: Point3| json!([p.x, p.y, p.z]);
572            (id.to_string(), json!({
573                "origin": frame.map(|f| p(f.origin)),
574                "xAxis": frame.map(|f| p(f.x_axis)),
575                "zAxis": frame.map(|f| p(f.z_axis)),
576                "frameError": frame_error,
577                "stock": geometry.stock.map(|(min, max)| json!({ "min": p(min), "max": p(max) })),
578                // A bounds-offset stock in the setup's own frame: top / bottom along machine Z.
579                "stockMachine": cam_setup::stock_bounds_machine(s, &state.scene).map(|b| json!({ "min": p(b.min), "max": p(b.max) })),
580            }))
581        }).collect::<serde_json::Map<String, Value>>(),
582        "selected": panel.selected,
583        "open": panel.open,
584        "notice": panel.notice,
585        "error": error,
586        // Generation: the engine's queue and results, and each operation's
587        // status against the document as it stands (stale when its inputs moved).
588        "generation": state.cam_generation_json(),
589        "autoprogram": panel.autoprogram.state(state),
590        "statuses": doc.operations.iter().map(|op| {
591            let id = op.id.as_str();
592            let status = cam_generate::status_of(state, &doc, id);
593            let detail = match &status {
594                cam_generate::OpStatus::Ok { motions, cutting_length } => json!({ "motions": motions, "cuttingLength": cutting_length }),
595                cam_generate::OpStatus::Stale { motions } => json!({ "motions": motions }),
596                cam_generate::OpStatus::Error(e) => json!({ "error": e }),
597                _ => json!({}),
598            };
599            (id.to_string(), json!({ "status": status.word(), "detail": detail }))
600        }).collect::<serde_json::Map<String, Value>>(),
601        "scrub": panel.scrub,
602        // The document's custom machines and the built-in library's ids (C17c).
603        "machines": doc.machines,
604        "builtinMachines": brep_cam::kinematics::library::builtin_ids(),
605        "hiddenToolpaths": panel.hidden_toolpaths.iter().collect::<Vec<_>>(),
606    })
607    .to_string()
608}
609
610/// What the pane asks the shell for, drained once per frame by `app.rs`:
611/// the file chooser (an import) and the save door (an export) are the
612/// shell's, like the PLM pane's.
613#[derive(Debug, Default)]
614pub struct CamShellRequests {
615    /// Open the file chooser for `(tag, title)`.
616    pub pick_file: Option<(String, String)>,
617    /// Files to save or download.
618    pub save_files: Vec<SaveRequest>,
619}
620
621/// The chooser tag a tool import is asked under.
622pub const IMPORT_TOOLS_TAG: &str = "cam-tools";
623
624/// The pane's transient UI state.
625pub struct CamPanel {
626    pub autoprogram: crate::panels::cam_autoprogram::AutoProgramPanel,
627    hits: HashMap<String, egui::Rect>,
628    /// The selected row's id (a setup, operation, tool or program).
629    selected: Option<String>,
630    /// An action handed over by a toolbar button, performed on the next draw.
631    request: Option<CamRequest>,
632    /// What the last action said that was not a record (a refusal, a count),
633    /// drawn under the header until the next action.
634    notice: Option<String>,
635    /// The setup whose dialog is open, drawn in place of the tree.
636    open: Option<String>,
637    /// The form field (its key) a pick is running in, so the form draws that
638    /// field focused: the engine's pick state carries the BLOCK path, which
639    /// is not the field's.
640    picking_field: Option<String>,
641    /// The last overlay group pushed, so an unchanged frame costs nothing.
642    overlay: String,
643    /// Every setup's resolved frame and stock box, by setup id, as of
644    /// `geometry_stamp` — walking every visible solid's mesh is O(mesh), and
645    /// the state blob is published every frame, so it is done once per
646    /// change, not once per frame.
647    geometry: HashMap<String, SetupGeometry>,
648    geometry_stamp: Option<(u64, u64)>,
649    /// Requests for the shell, taken each frame ([`Self::take_shell_requests`]).
650    shell: CamShellRequests,
651    /// The user library's tools (id, name, number), re-read when the pane
652    /// draws the library menu; `None` until then.
653    user_library: Option<Result<Vec<(String, String, u32)>, String>>,
654    /// The last import's outcome: ids added and rows skipped.
655    import_report: Option<(Vec<String>, Vec<String>)>,
656    /// The work material chosen in the tool dialog for Calculate feeds —
657    /// the calculator's input, not a tool field, so it lives here.
658    feeds_material: Option<String>,
659    /// The toolpath preview's scrub position on the selected operation, as a
660    /// fraction of its segments; `None` draws no tool marker.
661    scrub: Option<f64>,
662    /// The simulation player section (lane C17), drawn under the tree.
663    pub sim: crate::panels::cam_sim::CamSimPanel,
664    /// The NC program post preview (contract C19-27), drawn under the tree.
665    pub post: crate::panels::cam_post::CamPostPanel,
666    /// The toolpath overlay groups last pushed, by operation, so a result
667    /// that is gone is removed from the viewport.
668    overlay_ops: std::collections::BTreeSet<String>,
669    /// What the pushed overlay was built from (the setup group's text, the
670    /// results stamp, the selection, the scrub, the hidden set's stamp), so a
671    /// frame with nothing changed serialises no toolpath.
672    overlay_key: Option<(String, u64, Option<String>, Option<u64>, u64)>,
673    /// Operations whose toolpath is hidden in the viewport (the row menu's
674    /// Hide toolpath / `cam_toolpath_visible`); pane state, not the document's.
675    hidden_toolpaths: std::collections::BTreeSet<String>,
676    hidden_stamp: u64,
677}
678
679impl Default for CamPanel {
680    fn default() -> Self {
681        Self::new()
682    }
683}
684
685/// Section hit keys.
686const SECTION_SETUPS: &str = "cam:section:setups";
687const SECTION_PROGRAMS: &str = "cam:section:programs";
688const SECTION_TOOLS: &str = "cam:section:tools";
689const SECTION_MACHINES: &str = "cam:section:machines";
690
691impl CamPanel {
692    pub fn new() -> Self {
693        Self {
694            autoprogram: Default::default(),
695            hits: HashMap::new(),
696            selected: None,
697            request: None,
698            notice: None,
699            open: None,
700            picking_field: None,
701            overlay: String::new(),
702            geometry: HashMap::new(),
703            geometry_stamp: None,
704            shell: CamShellRequests::default(),
705            user_library: None,
706            import_report: None,
707            feeds_material: None,
708            scrub: None,
709            sim: crate::panels::cam_sim::CamSimPanel::new(),
710            post: crate::panels::cam_post::CamPostPanel::new(),
711            overlay_ops: std::collections::BTreeSet::new(),
712            overlay_key: None,
713            hidden_toolpaths: std::collections::BTreeSet::new(),
714            hidden_stamp: 0,
715        }
716    }
717
718    /// Show or hide operation `id`'s toolpath in the viewport.
719    pub fn set_toolpath_visible(&mut self, id: &str, visible: bool) {
720        let changed = if visible { self.hidden_toolpaths.remove(id) } else { self.hidden_toolpaths.insert(id.to_string()) };
721        if changed {
722            self.hidden_stamp += 1;
723        }
724    }
725
726    /// The operations whose toolpath is hidden.
727    pub fn hidden_toolpaths(&self) -> &std::collections::BTreeSet<String> {
728        &self.hidden_toolpaths
729    }
730
731    /// Set the preview's scrub fraction (`None` clears the marker).
732    pub fn set_scrub(&mut self, fraction: Option<f64>) {
733        self.scrub = fraction.map(|f| f.clamp(0.0, 1.0));
734    }
735
736    /// The requests the pane made this frame, for the shell.
737    pub fn take_shell_requests(&mut self) -> CamShellRequests {
738        if let Some(save) = self.sim.pending_save.take() {
739            self.shell.save_files.push(save);
740        }
741        if let Some(save) = self.post.pending_save.take() {
742            self.shell.save_files.push(save);
743        }
744        std::mem::take(&mut self.shell)
745    }
746
747    /// The file the chooser delivered for an import: read as text, imported
748    /// into the document's tools, the outcome shown under the header.
749    pub fn deliver_picked(&mut self, state: &mut EngineState, name: &str, bytes: &[u8]) {
750        let text = String::from_utf8_lossy(bytes);
751        match import_tools(state, name, &text) {
752            Ok((ids, issues)) => {
753                self.notice = Some(format!(
754                    "imported {} tool{} from {name}{}",
755                    ids.len(),
756                    if ids.len() == 1 { "" } else { "s" },
757                    if issues.is_empty() { String::new() } else { format!(" ({} row{} skipped)", issues.len(), if issues.len() == 1 { "" } else { "s" }) }
758                ));
759                self.import_report = Some((ids, issues));
760            }
761            Err(why) => self.refuse(state, format!("import {name}: {why}")),
762        }
763    }
764
765    /// The user library as the pane last read it (`None` = not read yet).
766    pub fn user_library(&self) -> Option<&Result<Vec<(String, String, u32)>, String>> {
767        self.user_library.as_ref()
768    }
769
770    /// Re-read the user library's listing from the store.
771    pub fn refresh_user_library(&mut self, store: &dyn ModelStore) {
772        self.user_library = Some(cam_library::load(store).map(|lib| lib.library.tools.iter().map(|t| (t.id.as_str().to_string(), t.name.clone(), t.number)).collect()));
773    }
774
775    /// Bring the setups' geometry cache up to date with the document and the
776    /// scene. Cheap when nothing changed (two counters and a walk of the
777    /// solid list's headers); the callers that publish or draw from the
778    /// cache call it first.
779    pub fn refresh_geometry(&mut self, state: &EngineState) {
780        let stamp = geometry_stamp(state);
781        if self.geometry_stamp == Some(stamp) {
782            return;
783        }
784        self.geometry.clear();
785        if let Ok(doc) = cam_document(state) {
786            for setup in &doc.setups {
787                let id = setup.id.as_str();
788                self.geometry.insert(
789                    id.to_string(),
790                    SetupGeometry { frame: setup_frame(state, &doc, id), stock: cam_setup::stock_bounds(setup, &state.scene) },
791                );
792            }
793        }
794        self.geometry_stamp = Some(stamp);
795    }
796
797    /// The setup whose dialog is open.
798    pub fn open_setup(&self) -> Option<&str> {
799        self.open.as_deref()
800    }
801
802    /// Open setup `id`'s dialog (`None` closes it).
803    pub fn set_open(&mut self, id: Option<String>) {
804        if id != self.open {
805            self.picking_field = None;
806        }
807        self.open = id;
808    }
809
810    /// Hand the pane an action; it runs on the next frame the pane draws.
811    pub fn request(&mut self, request: CamRequest) {
812        self.request = Some(request);
813    }
814
815    /// The selected row's id.
816    pub fn selected(&self) -> Option<&str> {
817        self.selected.as_deref()
818    }
819
820    /// Select a row by id (`None` clears). The id is not checked against the
821    /// block here: a row that no longer exists simply draws nothing selected.
822    pub fn select(&mut self, id: Option<String>) {
823        self.selected = id;
824    }
825
826    /// The setup an operation would go into: the selected setup, the selected
827    /// operation's setup, or the only setup there is.
828    fn target_setup(&self, doc: &CamDocument) -> Option<String> {
829        if let Some(selected) = &self.selected {
830            if doc.setup(selected).is_some() {
831                return Some(selected.clone());
832            }
833            if let Some(op) = doc.operation(selected) {
834                return Some(op.setup.as_str().to_string());
835            }
836        }
837        match doc.setups.as_slice() {
838            [only] => Some(only.id.as_str().to_string()),
839            _ => None,
840        }
841    }
842
843    /// The tool an operation would use: the selected tool, or the only one.
844    fn target_tool(&self, doc: &CamDocument) -> Option<String> {
845        if let Some(selected) = &self.selected {
846            if doc.tool(selected).is_some() {
847                return Some(selected.clone());
848            }
849        }
850        match doc.tools.as_slice() {
851            [only] => Some(only.id.as_str().to_string()),
852            _ => None,
853        }
854    }
855
856    /// Say why an action did not produce a record: a toast, and the line
857    /// under the header.
858    fn refuse(&mut self, state: &mut EngineState, why: String) {
859        state.push_notice(format!("CAM: {why}"));
860        self.notice = Some(why);
861    }
862
863    /// Perform a handed-over action against the engine.
864    fn perform(&mut self, request: CamRequest, state: &mut EngineState) {
865        self.notice = None;
866        let doc = match cam_document(state) {
867            Ok(doc) => doc,
868            Err(why) => return self.refuse(state, why),
869        };
870        let outcome = match request {
871            CamRequest::NewSetup => add_setup(state, None),
872            CamRequest::NewTool => add_tool(state, None),
873            CamRequest::NewOperation => match (self.target_setup(&doc), self.target_tool(&doc)) {
874                (Some(setup), Some(tool)) => add_operation(state, &setup, &tool, "parallel", None),
875                (None, _) if doc.setups.is_empty() => Err("no setup yet: press New setup first".to_string()),
876                (None, _) => Err("select the setup the operation goes into".to_string()),
877                (_, None) if doc.tools.is_empty() => Err("no tool yet: press New tool first".to_string()),
878                (_, None) => Err("select the tool the operation uses".to_string()),
879            },
880            CamRequest::Generate => {
881                if doc.operations.is_empty() {
882                    Err("nothing to generate: add an operation first".to_string())
883                } else {
884                    match generate(state, None) {
885                        Ok((queued, refused)) => {
886                            if !refused.is_empty() {
887                                self.notice = Some(refused.iter().map(|(_, why)| why.as_str()).collect::<Vec<_>>().join("; "));
888                            }
889                            if queued.is_empty() && refused.is_empty() {
890                                self.notice = Some("every operation is already queued or generating".to_string());
891                            }
892                            return;
893                        }
894                        Err(why) => Err(why),
895                    }
896                }
897            }
898        };
899        match outcome {
900            Ok(id) => self.selected = Some(id),
901            Err(why) => self.refuse(state, why),
902        }
903    }
904
905    /// Draw the pane: perform any handed-over action, then the header and the
906    /// three sections, applying at most one deferred engine mutation.
907    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
908        self.hits.clear();
909        // Unprefixed, as `scroll_into_view` looks it up (`<panel>/panel:clip`).
910        self.hits.insert("panel:clip".into(), ui.clip_rect());
911        if let Some(request) = self.request.take() {
912            self.perform(request, state);
913        }
914        self.refresh_geometry(state);
915        let (doc, error) = match cam_document(state) {
916            Ok(doc) => (doc, None),
917            Err(why) => (CamDocument::default(), Some(why)),
918        };
919
920        self.autoprogram.poll(state);
921        // --- header -----------------------------------------------------------
922        let mut request = None;
923        ui.horizontal_wrapped(|ui| {
924            for (key, label, hover, what) in [
925                ("cam:new:setup", "New setup", "Add a setup (work coordinate system, stock, heights)", CamRequest::NewSetup),
926                ("cam:new:tool", "New tool", "Add a tool to the document's library", CamRequest::NewTool),
927                ("cam:new:operation", "New operation", "Add an operation to the selected setup with the selected tool", CamRequest::NewOperation),
928                ("cam:generate", "Generate", "Compute the toolpaths of every operation that needs it", CamRequest::Generate),
929            ] {
930                let button = ui.button(label).on_hover_text(hover);
931                self.hits.insert(key.into(), button.rect);
932                if button.clicked() {
933                    request = Some(what);
934                }
935            }
936            let auto = ui.button("Program part automatically");
937            self.hits.insert("cam:auto:open".into(), auto.rect);
938            if auto.clicked() {
939                self.open = None;
940                let setup = self.target_setup(&doc);
941                match setup.ok_or_else(|| "select a setup before programming".to_string()).and_then(|id| self.autoprogram.preview(state, &id)) { Ok(()) => {}, Err(why) => self.notice = Some(why) }
942            }
943            ui.add(
944                egui::Label::new(
945                    egui::RichText::new(format!(
946                        "{} setup{} | {} tool{} | {} operation{}",
947                        doc.setups.len(),
948                        if doc.setups.len() == 1 { "" } else { "s" },
949                        doc.tools.len(),
950                        if doc.tools.len() == 1 { "" } else { "s" },
951                        doc.operations.len(),
952                        if doc.operations.len() == 1 { "" } else { "s" },
953                    ))
954                    .weak(),
955                )
956                .wrap_mode(egui::TextWrapMode::Extend),
957            );
958        });
959        if let Some(error) = &error {
960            let label = ui.label(egui::RichText::new(error).color(egui::Color32::from_rgb(0xf8, 0x51, 0x49)));
961            self.hits.insert("cam:error".into(), label.rect);
962        }
963        if let Some(notice) = &self.notice {
964            let label = ui.label(egui::RichText::new(notice).weak().color(egui::Color32::from_rgb(0xd2, 0x99, 0x22)));
965            self.hits.insert("cam:notice".into(), label.rect);
966        }
967        if state.cam.busy() {
968            ui.horizontal_wrapped(|ui| {
969                let text = match (&state.cam.progress, state.cam.in_flight()) {
970                    (Some(p), _) if p.total > 0 => format!("generating {}: {} {}/{}", p.operation, p.phase, p.current, p.total),
971                    (Some(p), _) => format!("generating {}: {}", p.operation, p.phase),
972                    (None, Some(op)) => format!("generating {op}\u{2026}"),
973                    (None, None) => "generation queued\u{2026}".to_string(),
974                };
975                let queued = state.cam.queued().len();
976                let line = ui.label(egui::RichText::new(if queued > 0 { format!("{text} (+{queued} queued)") } else { text }).weak());
977                self.hits.insert("cam:progress".into(), line.rect);
978                let cancel = ui.button("Cancel").on_hover_text("Drop the queue; the operation generating stops at its next chunk where the runner can");
979                self.hits.insert("cam:cancel".into(), cancel.rect);
980                if cancel.clicked() {
981                    state.cam_cancel();
982                }
983            });
984        } else if let Some(notice) = &state.cam.notice {
985            let line = ui.label(egui::RichText::new(notice).weak());
986            self.hits.insert("cam:progress".into(), line.rect);
987        }
988        ui.add_space(2.0);
989
990        if self.autoprogram.open {
991            self.autoprogram.show(ui, state, &mut self.hits);
992            if let Some(request) = request {
993                self.perform(request, state);
994            }
995            return;
996        }
997
998        // --- the open setup's dialog, in place of the tree ----------------------
999        if let Some(id) = self.open.clone() {
1000            if doc.setup(&id).is_some() {
1001                self.show_setup_form(ui, state, &doc, &id);
1002                if let Some(request) = request {
1003                    self.perform(request, state);
1004                }
1005                return;
1006            }
1007            if doc.tool(&id).is_some() {
1008                self.show_tool_form(ui, state, store, &doc, &id);
1009                if let Some(request) = request {
1010                    self.perform(request, state);
1011                }
1012                return;
1013            }
1014            if doc.operation(&id).is_some() {
1015                self.show_operation_form(ui, state, &doc, &id);
1016                if let Some(request) = request {
1017                    self.perform(request, state);
1018                }
1019                return;
1020            }
1021            if doc.machines.iter().any(|m| m.id.as_str() == id) {
1022                self.show_machine_form(ui, state, &doc, &id);
1023                if let Some(request) = request {
1024                    self.perform(request, state);
1025                }
1026                return;
1027            }
1028            self.open = None;
1029        }
1030        self.push_overlay(state, &doc, self.selected.clone());
1031
1032        // --- the tree ---------------------------------------------------------
1033        let mut clicked: Option<String> = None;
1034        let mut remove: Option<String> = None;
1035        let mut open_operation: Option<String> = None;
1036        // (operation id, currently hidden) from the row menu's Hide / Show toolpath.
1037        let mut toggle_toolpath: Option<(String, bool)> = None;
1038        let row = |ui: &mut egui::Ui, hits: &mut HashMap<String, egui::Rect>, id: &str, text: String, selected: bool, clicked: &mut Option<String>, remove: &mut Option<String>| {
1039            ui.horizontal(|ui| {
1040                let label = ui.selectable_label(selected, text);
1041                hits.insert(format!("cam:row:{id}"), label.rect);
1042                if label.clicked() {
1043                    *clicked = Some(id.to_string());
1044                }
1045                label.context_menu(|ui| {
1046                    let delete = ui.button("Delete");
1047                    hits.insert(format!("cam:menuitem:{id}:delete"), delete.rect);
1048                    if delete.clicked() {
1049                        *remove = Some(id.to_string());
1050                        ui.close();
1051                    }
1052                });
1053            });
1054        };
1055
1056        let setups = egui::CollapsingHeader::new(format!("Setups ({})", doc.setups.len()))
1057            .id_salt("cam-setups")
1058            .default_open(true)
1059            .show(ui, |ui| {
1060                if doc.setups.is_empty() {
1061                    ui.label(egui::RichText::new("No setups \u{2014} press New setup").weak());
1062                }
1063                for setup in &doc.setups {
1064                    let id = setup.id.as_str();
1065                    let ops: Vec<_> = doc.operations_of(id).collect();
1066                    let selected = self.selected.as_deref() == Some(id);
1067                    let header = egui::CollapsingHeader::new(setup.name.as_str())
1068                        .id_salt(("cam-setup", id))
1069                        .default_open(true)
1070                        .show(ui, |ui| {
1071                            if ops.is_empty() {
1072                                ui.label(egui::RichText::new("No operations \u{2014} press New operation").weak());
1073                            }
1074                            for op in ops {
1075                                let op_id = op.id.as_str();
1076                                let status = cam_generate::status_of(state, &doc, op_id);
1077                                let text = format!("{} {}  \u{00b7} {} \u{00b7} {}{}", status.mark(), op.name, op.strategy.kind_name(), tool_handle(&doc, &op.tool), if op.enabled { "" } else { " \u{00b7} off" });
1078                                ui.horizontal(|ui| {
1079                                    let hover = match &status {
1080                                        cam_generate::OpStatus::Ok { motions, cutting_length } => format!("generated: {motions} motions, {cutting_length:.2} cutting"),
1081                                        cam_generate::OpStatus::Stale { motions } => format!("stale: {motions} motions from inputs that have changed; generate again"),
1082                                        cam_generate::OpStatus::Error(e) => format!("failed: {e}"),
1083                                        other => other.word().to_string(),
1084                                    };
1085                                    let label = ui.selectable_label(self.selected.as_deref() == Some(op_id), text).on_hover_text(hover);
1086                                    self.hits.insert(format!("cam:row:{op_id}"), label.rect);
1087                                    if label.double_clicked() {
1088                                        open_operation = Some(op_id.to_string());
1089                                    } else if label.clicked() {
1090                                        clicked = Some(op_id.to_string());
1091                                    }
1092                                    label.context_menu(|ui| {
1093                                        let edit = ui.button("Edit operation\u{2026}");
1094                                        self.hits.insert(format!("cam:menuitem:{op_id}:edit"), edit.rect);
1095                                        if edit.clicked() {
1096                                            open_operation = Some(op_id.to_string());
1097                                            ui.close();
1098                                        }
1099                                        let hidden = self.hidden_toolpaths.contains(op_id);
1100                                        let toggle = ui.button(if hidden { "Show toolpath" } else { "Hide toolpath" });
1101                                        self.hits.insert(format!("cam:menuitem:{op_id}:toolpath"), toggle.rect);
1102                                        if toggle.clicked() {
1103                                            toggle_toolpath = Some((op_id.to_string(), hidden));
1104                                            ui.close();
1105                                        }
1106                                        let delete = ui.button("Delete");
1107                                        self.hits.insert(format!("cam:menuitem:{op_id}:delete"), delete.rect);
1108                                        if delete.clicked() {
1109                                            remove = Some(op_id.to_string());
1110                                            ui.close();
1111                                        }
1112                                    });
1113                                });
1114                            }
1115                        });
1116                    self.hits.insert(format!("cam:row:{id}"), header.header_response.rect);
1117                    if header.header_response.double_clicked() {
1118                        self.open = Some(id.to_string());
1119                        self.selected = Some(id.to_string());
1120                    } else if header.header_response.clicked() {
1121                        clicked = Some(id.to_string());
1122                    }
1123                    header.header_response.context_menu(|ui| {
1124                        let edit = ui.button("Edit setup\u{2026}");
1125                        self.hits.insert(format!("cam:menuitem:{id}:edit"), edit.rect);
1126                        if edit.clicked() {
1127                            self.open = Some(id.to_string());
1128                            ui.close();
1129                        }
1130                    });
1131                    header.header_response.context_menu(|ui| {
1132                        let delete = ui.button("Delete setup and its operations");
1133                        self.hits.insert(format!("cam:menuitem:{id}:delete"), delete.rect);
1134                        if delete.clicked() {
1135                            remove = Some(id.to_string());
1136                            ui.close();
1137                        }
1138                    });
1139                    if selected {
1140                        ui.painter().rect_stroke(
1141                            header.header_response.rect,
1142                            2.0,
1143                            egui::Stroke::new(1.0, ui.visuals().selection.stroke.color),
1144                            egui::StrokeKind::Inside,
1145                        );
1146                    }
1147                }
1148            });
1149        self.hits.insert(SECTION_SETUPS.into(), setups.header_response.rect);
1150
1151        let mut post_program: Option<String> = None;
1152        let programs = egui::CollapsingHeader::new(format!("NC Programs ({})", doc.nc_programs.len()))
1153            .id_salt("cam-programs")
1154            .default_open(true)
1155            .show(ui, |ui| {
1156                if doc.nc_programs.is_empty() {
1157                    ui.label(egui::RichText::new("No NC programs \u{2014} add one, then Post\u{2026} it to an NC file").weak());
1158                }
1159                for program in &doc.nc_programs {
1160                    let text = format!("{}  \u{00b7} {} \u{00b7} {} op{}", program.name, program.post, program.operations.len(), if program.operations.len() == 1 { "" } else { "s" });
1161                    ui.horizontal(|ui| {
1162                        let b = ui.small_button("Post\u{2026}").on_hover_text("Post through the program's post: preview the NC text and save it");
1163                        self.hits.insert(format!("cam:post:program:{}", program.id), b.rect);
1164                        if b.clicked() {
1165                            post_program = Some(program.id.clone());
1166                        }
1167                        row(ui, &mut self.hits, &program.id, text, self.selected.as_deref() == Some(program.id.as_str()), &mut clicked, &mut remove);
1168                    });
1169                }
1170            });
1171        self.hits.insert(SECTION_PROGRAMS.into(), programs.header_response.rect);
1172        if let Some(id) = post_program {
1173            let _ = self.post.open(state, &doc, &id);
1174        }
1175
1176        let mut open_tool: Option<String> = None;
1177        let mut duplicate: Option<String> = None;
1178        let mut to_library: Option<String> = None;
1179        let mut from_library: Option<String> = None;
1180        let mut import = false;
1181        let mut export: Option<&'static str> = None;
1182        let tools = egui::CollapsingHeader::new(format!("Tool Library ({})", doc.tools.len()))
1183            .id_salt("cam-tools")
1184            .default_open(true)
1185            .show(ui, |ui| {
1186                ui.horizontal_wrapped(|ui| {
1187                    let add = ui.menu_button("Add from user library", |ui| {
1188                        if self.user_library.is_none() {
1189                            self.refresh_user_library(store);
1190                        }
1191                        match self.user_library.as_ref() {
1192                            Some(Ok(list)) if list.is_empty() => {
1193                                ui.label(egui::RichText::new("The user library is empty \u{2014} save a tool to it from its row menu").weak());
1194                            }
1195                            Some(Ok(list)) => {
1196                                for (id, name, number) in list.clone() {
1197                                    let entry = ui.button(format!("T{number}  {name}"));
1198                                    self.hits.insert(format!("cam:library:{id}"), entry.rect);
1199                                    if entry.clicked() {
1200                                        from_library = Some(id);
1201                                        ui.close();
1202                                    }
1203                                }
1204                            }
1205                            Some(Err(why)) => {
1206                                ui.label(egui::RichText::new(why).color(egui::Color32::from_rgb(0xf8, 0x51, 0x49)));
1207                            }
1208                            None => {}
1209                        }
1210                    });
1211                    self.hits.insert("cam:tools:library".into(), add.response.rect);
1212                    if add.response.clicked() {
1213                        self.refresh_user_library(store);
1214                    }
1215                    let import_button = ui.button("Import\u{2026}").on_hover_text("Add the tools of a Fusion 360 export (.json / .tools), a .csv sheet or a library file");
1216                    self.hits.insert("cam:tools:import".into(), import_button.rect);
1217                    import = import_button.clicked();
1218                    let export_doc = ui.button("Export").on_hover_text("Save the document's tools as a library file");
1219                    self.hits.insert("cam:tools:export".into(), export_doc.rect);
1220                    if export_doc.clicked() {
1221                        export = Some("document");
1222                    }
1223                    let export_user = ui.button("Export user library").on_hover_text("Save the user library as a library file");
1224                    self.hits.insert("cam:tools:export-user".into(), export_user.rect);
1225                    if export_user.clicked() {
1226                        export = Some("user");
1227                    }
1228                });
1229                if let Some((ids, issues)) = &self.import_report {
1230                    for issue in issues {
1231                        ui.label(egui::RichText::new(format!("skipped {issue}")).weak().color(egui::Color32::from_rgb(0xd2, 0x99, 0x22)));
1232                    }
1233                    let _ = ids;
1234                }
1235                if doc.tools.is_empty() {
1236                    ui.label(egui::RichText::new("No tools \u{2014} press New tool").weak());
1237                }
1238                for tool in &doc.tools {
1239                    let id = tool.id.as_str();
1240                    let text = format!("T{}  {} \u{00b7} {} \u{2300}{}", tool.number, tool.name, tool.kind.as_str(), tool.geometry.diameter);
1241                    ui.horizontal(|ui| {
1242                        let label = ui.selectable_label(self.selected.as_deref() == Some(id), text);
1243                        self.hits.insert(format!("cam:row:{id}"), label.rect);
1244                        if label.double_clicked() {
1245                            open_tool = Some(id.to_string());
1246                        } else if label.clicked() {
1247                            clicked = Some(id.to_string());
1248                        }
1249                        label.context_menu(|ui| {
1250                            let edit = ui.button("Edit tool\u{2026}");
1251                            self.hits.insert(format!("cam:menuitem:{id}:edit"), edit.rect);
1252                            if edit.clicked() {
1253                                open_tool = Some(id.to_string());
1254                                ui.close();
1255                            }
1256                            let dup = ui.button("Duplicate");
1257                            self.hits.insert(format!("cam:menuitem:{id}:duplicate"), dup.rect);
1258                            if dup.clicked() {
1259                                duplicate = Some(id.to_string());
1260                                ui.close();
1261                            }
1262                            let save = ui.button("Save to user library");
1263                            self.hits.insert(format!("cam:menuitem:{id}:library"), save.rect);
1264                            if save.clicked() {
1265                                to_library = Some(id.to_string());
1266                                ui.close();
1267                            }
1268                            let delete = ui.button("Delete");
1269                            self.hits.insert(format!("cam:menuitem:{id}:delete"), delete.rect);
1270                            if delete.clicked() {
1271                                remove = Some(id.to_string());
1272                                ui.close();
1273                            }
1274                        });
1275                    });
1276                }
1277            });
1278        self.hits.insert(SECTION_TOOLS.into(), tools.header_response.rect);
1279
1280        // --- Machines: the document's own machines, each a copy of a built-in --
1281        let mut open_machine: Option<String> = None;
1282        let mut new_machine: Option<String> = None;
1283        let machines = egui::CollapsingHeader::new(format!("Machines ({})", doc.machines.len()))
1284            .id_salt("cam-machines")
1285            .default_open(true)
1286            .show(ui, |ui| {
1287                let add = ui.menu_button("New machine", |ui| {
1288                    for base in brep_cam::kinematics::library::builtin_machines() {
1289                        let id = base.id.as_str().to_string();
1290                        let entry = ui.button(format!("{}  \u{00b7} {}", base.name, cam_machine::kind_tag(&base.kind)));
1291                        self.hits.insert(format!("cam:machines:new:{id}"), entry.rect);
1292                        if entry.clicked() {
1293                            new_machine = Some(id);
1294                            ui.close();
1295                        }
1296                    }
1297                });
1298                self.hits.insert("cam:machines:new".into(), add.response.rect);
1299                if doc.machines.is_empty() {
1300                    ui.label(egui::RichText::new("No machines of this document \u{2014} a setup can name a built-in directly").weak());
1301                }
1302                for machine in &doc.machines {
1303                    let id = machine.id.as_str();
1304                    let used = doc.setups.iter().filter(|s| s.machine.as_ref().is_some_and(|m| m.as_str() == id)).count();
1305                    let text = format!(
1306                        "{}  \u{00b7} {}{}{}",
1307                        machine.name,
1308                        cam_machine::kind_tag(&machine.kind),
1309                        machine.post.as_deref().map(|p| format!(" \u{00b7} {p}")).unwrap_or_default(),
1310                        if used > 0 { format!(" \u{00b7} {used} setup{}", if used == 1 { "" } else { "s" }) } else { String::new() }
1311                    );
1312                    let label = ui.selectable_label(self.selected.as_deref() == Some(id), text);
1313                    self.hits.insert(format!("cam:row:{id}"), label.rect);
1314                    if label.double_clicked() {
1315                        open_machine = Some(id.to_string());
1316                    } else if label.clicked() {
1317                        clicked = Some(id.to_string());
1318                    }
1319                    label.context_menu(|ui| {
1320                        let edit = ui.button("Edit machine\u{2026}");
1321                        self.hits.insert(format!("cam:menuitem:{id}:edit"), edit.rect);
1322                        if edit.clicked() {
1323                            open_machine = Some(id.to_string());
1324                            ui.close();
1325                        }
1326                        let delete = ui.button("Delete");
1327                        self.hits.insert(format!("cam:menuitem:{id}:delete"), delete.rect);
1328                        if delete.clicked() {
1329                            remove = Some(id.to_string());
1330                            ui.close();
1331                        }
1332                    });
1333                }
1334            });
1335        self.hits.insert(SECTION_MACHINES.into(), machines.header_response.rect);
1336        if let Some(base) = new_machine {
1337            match add_machine(state, &base, None) {
1338                Ok(id) => {
1339                    self.notice = None;
1340                    self.selected = Some(id);
1341                }
1342                Err(why) => self.refuse(state, why),
1343            }
1344        }
1345        if let Some(id) = open_machine {
1346            self.selected = Some(id.clone());
1347            self.open = Some(id);
1348        }
1349        if let Some(id) = open_tool {
1350            self.selected = Some(id.clone());
1351            self.open = Some(id);
1352        }
1353        if let Some(id) = duplicate {
1354            match duplicate_tool(state, &id) {
1355                Ok(new_id) => self.selected = Some(new_id),
1356                Err(why) => self.refuse(state, why),
1357            }
1358        }
1359        if let Some(id) = to_library {
1360            let outcome = doc.tool(&id).ok_or_else(|| format!("no tool `{id}`")).and_then(|tool| cam_library::save_tool(store, tool));
1361            match outcome {
1362                Ok(()) => {
1363                    self.notice = Some(format!("{id} saved to the user library"));
1364                    self.user_library = None;
1365                }
1366                Err(why) => self.refuse(state, why),
1367            }
1368        }
1369        if let Some(library_id) = from_library {
1370            match add_tool_from_library(state, store, &library_id) {
1371                Ok(new_id) => self.selected = Some(new_id),
1372                Err(why) => self.refuse(state, why),
1373            }
1374        }
1375        if import {
1376            self.shell.pick_file = Some((IMPORT_TOOLS_TAG.to_string(), "Import tools".to_string()));
1377        }
1378        if let Some(which) = export {
1379            let outcome = if which == "user" { cam_library::export_text(store) } else { export_document_tools(state) };
1380            match outcome {
1381                Ok(text) => self.shell.save_files.push(SaveRequest {
1382                    title: if which == "user" { "Export the user tool library".into() } else { "Export the document's tools".into() },
1383                    file_name: if which == "user" { "user-tools.json".into() } else { "tools.json".into() },
1384                    body: SaveBody::Text(text),
1385                }),
1386                Err(why) => self.refuse(state, why),
1387            }
1388        }
1389
1390        // --- deferred mutations -----------------------------------------------
1391        if let Some(id) = open_operation {
1392            self.selected = Some(id.clone());
1393            self.open = Some(id);
1394        }
1395        if let Some(id) = clicked {
1396            self.selected = if self.selected.as_deref() == Some(id.as_str()) { None } else { Some(id) };
1397        }
1398        if let Some((id, hidden)) = toggle_toolpath {
1399            self.set_toolpath_visible(&id, hidden);
1400        }
1401        if let Some(id) = remove {
1402            match remove_record(state, &id) {
1403                Ok(()) => {
1404                    if self.selected.as_deref() == Some(id.as_str()) {
1405                        self.selected = None;
1406                    }
1407                }
1408                Err(why) => self.refuse(state, why),
1409            }
1410        }
1411        if let Some(request) = request {
1412            self.perform(request, state);
1413        }
1414        // --- the simulation player (lane C17) ---------------------------------
1415        if error.is_none() {
1416            ui.separator();
1417            let setup_hint = self.selected.as_deref().and_then(|id| {
1418                doc.setup(id).map(|_| id.to_string()).or_else(|| doc.operation(id).map(|o| o.setup.to_string()))
1419            });
1420            self.sim.show(ui, state, &doc, setup_hint.as_deref());
1421        }
1422        // --- the NC program post preview (contract C19-27) ---------------------
1423        if let Some((id, patch)) = self.post.show(ui, &doc) {
1424            match set_program_properties(state, &id, &patch) {
1425                Ok(()) => match cam_document(state) {
1426                    Ok(doc) => {
1427                        let _ = self.post.open(state, &doc, &id);
1428                    }
1429                    Err(e) => self.post.error = Some(e),
1430                },
1431                Err(e) => self.post.error = Some(e),
1432            }
1433        }
1434    }
1435
1436    /// Draw setup `id`'s dialog: the shared form over its params, its picks
1437    /// through the engine's CAM picker, the frame it resolves to written back,
1438    /// and the viewport overlay for this setup.
1439    fn show_setup_form(&mut self, ui: &mut egui::Ui, state: &mut EngineState, doc: &CamDocument, id: &str) {
1440        let Some(setup) = doc.setup(id) else { return };
1441        let orientation: SetupOrientation = doc.orientation(id);
1442        let fields = cam_setup::setup_form_fields();
1443        let mut params = cam_setup::setup_form_params(setup, &orientation);
1444        cam_setup::merge_machine_params(&mut params, doc, setup);
1445        let hidden = cam_setup::hidden_fields(&params);
1446        let title = format!("Setup {id}");
1447        let frame_error = self.geometry.get(id).and_then(|g| g.frame.clone().err());
1448        let banner = match (&frame_error, &self.notice) {
1449            (Some(why), _) => Some((why.as_str(), egui::Color32::from_rgb(0xf8, 0x51, 0x49))),
1450            (None, Some(notice)) => Some((notice.as_str(), egui::Color32::from_rgb(0xd2, 0x99, 0x22))),
1451            (None, None) => None,
1452        };
1453        let picking = state
1454            .ref_select_field()
1455            .filter(|f| f.target == RefSelectTarget::Cam && f.subject == id)
1456            .and_then(|f| self.picking_field.clone().map(|key| (vec![key], f.names)));
1457        let active = picking.as_ref().map(|(path, names)| crate::form::ActivePick { path, names });
1458        if active.is_some() {
1459            state.ref_select_claim();
1460        }
1461        let spec = FormViewSpec {
1462            title: &title,
1463            subtitle: Some(setup.name.as_str()),
1464            fields: &fields,
1465            hidden: Some(&hidden),
1466            banner,
1467            trailing: None,
1468            exit_label: "Return to tree",
1469            extra: None,
1470            rollback: false,
1471            hits_prefix: "cam:",
1472            read_only: None,
1473            picking: active,
1474        };
1475        let was_picking = self.picking_field.is_some();
1476        let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
1477        let anchor = self.hits.get("cam:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
1478        self.hits.insert(format!("cam:form:setup:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
1479        self.push_overlay(state, doc, Some(id.to_string()));
1480        if let Some(activate) = out.ref_activate {
1481            let key = activate.path.first().cloned().unwrap_or_default();
1482            match begin_setup_pick(state, id, &key) {
1483                Ok(()) => {
1484                    self.picking_field = Some(key);
1485                    // The shell finishes an in-form pick nobody claimed this
1486                    // frame; the form draws it focused from the next one.
1487                    state.ref_select_claim();
1488                }
1489                Err(why) => self.refuse(state, why),
1490            }
1491        }
1492        if let Some(index) = out.pick_remove {
1493            state.ref_select_remove(index);
1494        }
1495        if out.pick_done {
1496            state.finish_ref_select();
1497            self.picking_field = None;
1498            if let Some(why) = sync_setup_frame(state, id) {
1499                self.notice = Some(why);
1500            }
1501        }
1502        if out.changed {
1503            self.notice = None;
1504            let coalesce = format!("setup:{id}:form");
1505            if let Err(why) = update_setup(state, id, &params, Some(&coalesce)) {
1506                self.refuse(state, why);
1507            }
1508        }
1509        if out.exit_clicked {
1510            if state.ref_select_active() {
1511                state.finish_ref_select();
1512            }
1513            self.picking_field = None;
1514            self.open = None;
1515        }
1516        if was_picking && !state.ref_select_active() && self.picking_field.is_some() {
1517            // The pick ended outside the form (a single field finishes on its
1518            // first pick, by the shell): the frame follows it here.
1519            self.picking_field = None;
1520            if let Some(why) = sync_setup_frame(state, id) {
1521                self.notice = Some(why);
1522            }
1523        }
1524        if !state.ref_select_active() {
1525            self.picking_field = None;
1526        }
1527    }
1528
1529    /// Draw operation `id`'s dialog: the generic form over its strategy and
1530    /// linking parameters plus the fixed groups; picks through the engine's
1531    /// CAM picker with the owning solid of a picked face filled from the
1532    /// scene; every edit one coalesced undo step.
1533    fn show_operation_form(&mut self, ui: &mut egui::Ui, state: &mut EngineState, doc: &CamDocument, id: &str) {
1534        let Some(operation) = doc.operation(id) else { return };
1535        let mut fields = cam_operation::operation_form_fields(operation, &doc.tools);
1536        fields.extend(crate::panels::cam_multiaxis::form_fields(doc, operation));
1537        let mut params = cam_operation::operation_form_params(operation);
1538        crate::panels::cam_multiaxis::merge_params(doc, id, &mut params);
1539        let hidden = cam_operation::hidden_fields(&params);
1540        let title = format!("Operation {id}");
1541        let subtitle = format!("{} \u{00b7} {} \u{00b7} {}", operation.strategy.kind_name(), operation.setup, operation.tool);
1542        let problem = cam_operation::strategy_problem(operation);
1543        let banner = match (&self.notice, &problem) {
1544            (Some(n), _) => Some((n.as_str(), egui::Color32::from_rgb(0xd2, 0x99, 0x22))),
1545            (None, Some(p)) => Some((p.as_str(), egui::Color32::from_rgb(0xd2, 0x99, 0x22))),
1546            (None, None) => None,
1547        };
1548        let picking = state
1549            .ref_select_field()
1550            .filter(|f| f.target == RefSelectTarget::Cam && f.subject == id)
1551            .and_then(|f| self.picking_field.clone().map(|key| (vec!["targets".to_string(), key], f.names)));
1552        let active = picking.as_ref().map(|(path, names)| crate::form::ActivePick { path, names });
1553        if active.is_some() {
1554            state.ref_select_claim();
1555        }
1556        let spec = FormViewSpec {
1557            title: &title,
1558            subtitle: Some(subtitle.as_str()),
1559            fields: &fields,
1560            hidden: Some(&hidden),
1561            banner,
1562            trailing: None,
1563            exit_label: "Return to tree",
1564            extra: None,
1565            rollback: false,
1566            hits_prefix: "cam:",
1567            read_only: None,
1568            picking: active,
1569        };
1570        let was_picking = self.picking_field.is_some();
1571        let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
1572        let anchor = self.hits.get("cam:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
1573        self.hits.insert(format!("cam:form:operation:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
1574        self.push_overlay(state, doc, Some(operation.setup.as_str().to_string()));
1575        if let Some(activate) = out.ref_activate {
1576            let key = activate.path.last().cloned().unwrap_or_default();
1577            match begin_operation_pick(state, id, &key) {
1578                Ok(()) => {
1579                    self.picking_field = Some(key);
1580                    state.ref_select_claim();
1581                }
1582                Err(why) => self.refuse(state, why),
1583            }
1584        }
1585        if let Some(index) = out.pick_remove {
1586            state.ref_select_remove(index);
1587        }
1588        if out.pick_done {
1589            state.finish_ref_select();
1590            self.picking_field = None;
1591            if let Err(why) = sync_operation_faces(state, id) {
1592                self.refuse(state, why);
1593            }
1594        }
1595        if out.changed {
1596            self.notice = None;
1597            let coalesce = format!("operation:{id}:form");
1598            if let Err(why) = update_operation(state, id, &params, Some(&coalesce)) {
1599                self.refuse(state, why);
1600            }
1601        }
1602        if let Some(index) = out.button_clicked.as_deref().and_then(|key| key.strip_prefix("removeChain")) {
1603            let patch = json!({ "targets": { "chains": { index: { "remove": true } } } });
1604            if let Err(why) = update_operation(state, id, &patch, None) {
1605                self.refuse(state, why);
1606            }
1607        }
1608        if out.exit_clicked {
1609            if state.ref_select_active() {
1610                state.finish_ref_select();
1611            }
1612            self.picking_field = None;
1613            self.open = None;
1614        }
1615        if was_picking && !state.ref_select_active() && self.picking_field.is_some() {
1616            self.picking_field = None;
1617            if let Err(why) = sync_operation_faces(state, id) {
1618                self.refuse(state, why);
1619            }
1620        }
1621        if !state.ref_select_active() {
1622            self.picking_field = None;
1623        }
1624    }
1625
1626    /// Draw tool `id`'s dialog: the shared form over its params; Calculate
1627    /// feeds through C12's calculator; every edit one coalesced undo step.
1628    /// Draw the document machine `id`'s editor: the shared form over the
1629    /// model's machine; every edit one coalesced undo step, refused by field
1630    /// with nothing applied.
1631    fn show_machine_form(&mut self, ui: &mut egui::Ui, state: &mut EngineState, doc: &CamDocument, id: &str) {
1632        let Some(machine) = doc.machines.iter().find(|m| m.id.as_str() == id) else { return };
1633        let fields = cam_machine::machine_form_fields(machine);
1634        let mut params = cam_machine::machine_form_params(machine);
1635        let hidden = cam_machine::hidden_fields(&params);
1636        let title = format!("Machine {id}");
1637        let banner = self.notice.as_ref().map(|n| (n.as_str(), egui::Color32::from_rgb(0xd2, 0x99, 0x22)));
1638        let spec = FormViewSpec {
1639            title: &title,
1640            subtitle: Some(machine.name.as_str()),
1641            fields: &fields,
1642            hidden: Some(&hidden),
1643            banner,
1644            trailing: None,
1645            exit_label: "Return to tree",
1646            extra: None,
1647            rollback: false,
1648            hits_prefix: "cam:",
1649            read_only: None,
1650            picking: None,
1651        };
1652        let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
1653        let anchor = self.hits.get("cam:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
1654        self.hits.insert(format!("cam:form:machine:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
1655        self.push_overlay(state, doc, None);
1656        if out.changed {
1657            self.notice = None;
1658            let coalesce = format!("machine:{id}:form");
1659            if let Err(why) = update_machine(state, id, &params, Some(&coalesce)) {
1660                self.refuse(state, why);
1661            }
1662        }
1663        if out.exit_clicked {
1664            self.open = None;
1665        }
1666    }
1667
1668    fn show_tool_form(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore, doc: &CamDocument, id: &str) {
1669        let Some(tool) = doc.tool(id) else { return };
1670        let fields = cam_tool::tool_form_fields();
1671        let mut params = cam_tool::tool_form_params(tool);
1672        if let Some(material) = &self.feeds_material {
1673            params["feedsMaterial"] = json!(material);
1674        }
1675        let hidden = cam_tool::hidden_fields(&params);
1676        let title = format!("Tool {id}");
1677        let banner = self.notice.as_ref().map(|n| (n.as_str(), egui::Color32::from_rgb(0xd2, 0x99, 0x22)));
1678        let spec = FormViewSpec {
1679            title: &title,
1680            subtitle: Some(tool.name.as_str()),
1681            fields: &fields,
1682            hidden: Some(&hidden),
1683            banner,
1684            trailing: None,
1685            exit_label: "Return to tree",
1686            extra: None,
1687            rollback: false,
1688            hits_prefix: "cam:",
1689            read_only: None,
1690            picking: None,
1691        };
1692        let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
1693        let anchor = self.hits.get("cam:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
1694        self.hits.insert(format!("cam:form:tool:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
1695        self.push_overlay(state, doc, None);
1696        if out.changed {
1697            self.notice = None;
1698            // The work material is the calculator's input, not the tool's.
1699            self.feeds_material = params.get("feedsMaterial").and_then(Value::as_str).map(str::to_string);
1700            let coalesce = format!("tool:{id}:form");
1701            if let Err(why) = update_tool(state, id, &params, Some(&coalesce)) {
1702                self.refuse(state, why);
1703            }
1704        }
1705        if out.button_clicked.as_deref() == Some("calculateFeeds") {
1706            let material = params.get("feedsMaterial").and_then(Value::as_str).unwrap_or("aluminium").to_string();
1707            match calculate_tool_feeds(state, id, &material) {
1708                Ok(adjustments) => {
1709                    self.notice = Some(if adjustments.is_empty() { format!("feeds calculated for {material}") } else { format!("feeds calculated for {material}: {}", adjustments.join("; ")) });
1710                }
1711                Err(why) => self.refuse(state, why),
1712            }
1713        }
1714        if out.exit_clicked {
1715            self.open = None;
1716            self.user_library = None;
1717        }
1718        let _ = store;
1719    }
1720
1721    /// Push (or clear) the viewport overlay for setup `id`: its stock box and
1722    /// its frame's triad. Unchanged geometry is not re-sent.
1723    fn push_overlay(&mut self, state: &mut EngineState, doc: &CamDocument, id: Option<String>) {
1724        self.refresh_geometry(state);
1725        let group = id
1726            .as_deref()
1727            .filter(|id| doc.setup(id).is_some())
1728            .and_then(|id| self.geometry.get(id))
1729            .map(|geometry| {
1730                let frame = geometry.frame.clone().unwrap_or_default();
1731                cam_setup::overlay_group(&frame, geometry.stock)
1732            })
1733            .unwrap_or_else(|| json!({ "name": "cam-setup" }));
1734        let key = (group.to_string(), state.cam.results_stamp, self.selected.clone(), self.scrub.map(f64::to_bits), self.hidden_stamp);
1735        if self.overlay_key.as_ref() == Some(&key) {
1736            return;
1737        }
1738        self.overlay_key = Some(key);
1739        // Every generated toolpath, the selected operation's at full strength
1740        // with the scrub marker; a result that is gone takes its group away.
1741        let mut groups = vec![group];
1742        let mut live: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1743        for (op, result) in &state.cam.results {
1744            if self.hidden_toolpaths.contains(op) {
1745                continue;
1746            }
1747            if let Some(toolpath) = &result.toolpath {
1748                let selected = self.selected.as_deref() == Some(op.as_str());
1749                let operation = doc.operation(op);
1750                let to_model = operation.and_then(|o| doc.setup(o.setup.as_str())).map(|s| s.machine_to_model()).unwrap_or_default();
1751                let profile = if selected && self.scrub.is_some() {
1752                    operation.and_then(|o| doc.tool(o.tool.as_str())).and_then(cam_generate::tool_profile)
1753                } else {
1754                    None
1755                };
1756                groups.push(cam_generate::toolpath_group(op, Some(toolpath), selected, if selected { self.scrub } else { None }, &to_model, profile.as_ref()));
1757                live.insert(op.clone());
1758            }
1759        }
1760        for gone in self.overlay_ops.difference(&live) {
1761            groups.push(cam_generate::toolpath_group(gone, None, false, None, &RigidTransform::default(), None));
1762        }
1763        self.overlay_ops = live;
1764        let json = json!({ "groups": groups }).to_string();
1765        if json != self.overlay {
1766            let _ = state.widgets.set_overlay_json(&json);
1767            state.dirty = true;
1768            self.overlay = json;
1769        }
1770    }
1771
1772    /// The per-frame widget rects for the headed verifier.
1773    pub fn hits_json(&self) -> String {
1774        let mut all = self.hits.clone();
1775        all.extend(self.sim.hit_rects().iter().map(|(k, r)| (k.clone(), *r)));
1776        all.extend(self.post.hit_rects().iter().map(|(k, r)| (k.clone(), *r)));
1777        crate::automation::hit_rects::hits_json(&all)
1778    }
1779
1780    /// Drop the published rects: the dock calls this for a pane it did not
1781    /// draw this frame, whose widgets are not on screen.
1782    pub fn clear_hits(&mut self) {
1783        self.hits.clear();
1784        self.sim.clear_hits();
1785    }
1786}
1787
1788pub static HIT_KEYS: &[HitKeyDoc] = &[
1789    HitKeyDoc { panel: "cam", prefix: "cam:auto:", meaning: "automatic programming preview, feature checkboxes, create and close", command: Some("cam_autoprogram") },
1790    HitKeyDoc { panel: "cam", prefix: "panel:clip", meaning: "the visible region of the pane (scroll_into_view scrolls a cam:* widget inside it)", command: None },
1791    HitKeyDoc { panel: "cam", prefix: "cam:new:setup", meaning: "add a setup (the toolbar's New setup)", command: Some("cam_add_setup") },
1792    HitKeyDoc { panel: "cam", prefix: "cam:new:tool", meaning: "add a tool to the document's library (the toolbar's New tool)", command: Some("cam_add_tool") },
1793    HitKeyDoc { panel: "cam", prefix: "cam:new:operation", meaning: "add an operation to the selected setup with the selected tool (the toolbar's New operation)", command: Some("cam_add_operation") },
1794    HitKeyDoc { panel: "cam", prefix: "cam:generate", meaning: "generate the toolpaths of every operation that needs it (the toolbar's Generate)", command: Some("cam_generate") },
1795    HitKeyDoc { panel: "cam", prefix: "cam:progress", meaning: "the generation line under the header: the operation generating, its phase and count, the queue; after a cancel, what was cancelled", command: None },
1796    HitKeyDoc { panel: "cam", prefix: "cam:cancel", meaning: "cancel the generation: the queue is dropped at once, the operation in flight stops at its next chunk where the runner can", command: Some("cam_cancel") },
1797    HitKeyDoc { panel: "cam", prefix: "cam:error", meaning: "why the document's `cam` block cannot be read (every edit is refused until it is repaired)", command: None },
1798    HitKeyDoc { panel: "cam", prefix: "cam:notice", meaning: "what the last action said that was not a record (a refusal, a count)", command: None },
1799    HitKeyDoc { panel: "cam", prefix: "cam:section:", meaning: "a section header (cam:section:setups, cam:section:programs, cam:section:tools, cam:section:machines) \u{2014} click folds it", command: None },
1800    HitKeyDoc { panel: "cam", prefix: "cam:machines:", meaning: "the Machines section's New machine menu (cam:machines:new) and, while it is open, one entry per built-in machine to copy (cam:machines:new:<built-in id>)", command: Some("cam_add_machine") },
1801    HitKeyDoc { panel: "cam", prefix: "cam:row:", meaning: "a setup, operation, NC program, tool or document machine row (cam:row:<id>) \u{2014} a click selects it (again to clear); right-click for its menu", command: Some("cam_select") },
1802    HitKeyDoc { panel: "cam", prefix: "cam:menuitem:", meaning: "one entry of the OPEN row menu (cam:menuitem:<id>:delete; a setup's or an operation's :edit; an operation's :toolpath = Hide / Show toolpath; a machine's :edit; a tool's :edit, :duplicate and :library) \u{2014} published only while the menu is up", command: Some("cam_remove") },
1803    HitKeyDoc { panel: "cam", prefix: "cam:form:", meaning: "the open setup, tool, operation or machine dialog (cam:form:setup:<id>, cam:form:tool:<id>, cam:form:operation:<id>, cam:form:machine:<id>, cam:form:feature, cam:form:return)", command: Some("cam_open") },
1804    HitKeyDoc { panel: "cam", prefix: "cam:field:", meaning: "one control of the open dialog (cam:field:<path>, nested paths joined by `.`, plus #activate on a reference field): a setup's name, wcsNumber, zRef, xRef, originRef, stockKind and its parameters, clearance, retract, feed, targets, protected; a tool's name, number, kind, description, material, the geometry, holderFamily/holderBore/holderHeight, spindleRpm, spindleDirection, cuttingFeed, plungeFeed, feedsMaterial, calculateFeeds, coolant; an operation's name, enabled, strategyKind, tool, feedsOverride, feeds.*, targets.targetSolids / driveFaces / avoidFaces / protectedSolids, targets.chainEdges / chainProfile (pickers that add a chain), the chain list targets.chains.<i>.source.kind / summary / closed / reversed / side / offset / regions / startSet / start and its targets.removeChain<i> button, heightsOverride, heights.*, tolerance, stockToLeave.*, strategy.* (its strategy's parameters) and linking.*; a document machine's name, kind, post, toolChangeSeconds, spindle.*, feed.*, accelerationSet, travelSet, travel.min / max, kinematics, yAxis, axes.<i>.name / direction / origin / limitsSet / limitsMin / limitsMax and collision (read only)", command: Some("cam_update_setup") },
1805    HitKeyDoc { panel: "cam", prefix: "cam:tools:", meaning: "the Tool Library section's actions: cam:tools:library (the Add-from-user-library menu), cam:tools:import (the file chooser), cam:tools:export (the document's tools as a library file), cam:tools:export-user", command: Some("cam_library") },
1806    HitKeyDoc { panel: "cam", prefix: "cam:library:", meaning: "one entry of the OPEN Add-from-user-library menu (cam:library:<library tool id>) \u{2014} published only while the menu is up", command: Some("cam_library") },
1807];
1808