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