Skip to main content

brep_app/document/
cam.rs

1//! The document's `cam` block on the shared CAM model (`brep_cam::model`):
2//! the user's setups, tools, operations and NC programs, read from and
3//! written to the raw JSON block the history keeps
4//! ([`brep_render::history::History::cam_block`]).
5//!
6//! This is the ONE place the block's shape is spelled. The model types are
7//! lane C0's and serialize as the persistence contract says (camelCase,
8//! `kind`-tagged enums, `serde(default)` on every newer field); this module
9//! owns only the container around them and the document-level identities —
10//! the id counter and the NC program records, which are user input the post
11//! lane (C4) assembles into a `brep_cam::program::ProgramInput` at post time.
12//!
13//! Reading is version-tolerant two ways: fields the model gained later
14//! default, and a block written before the model was imported (lane C1 row 1:
15//! an operation's `strategy` as a bare kind name, a tool with no geometry) is
16//! upgraded on read. A block that still does not parse is reported, never
17//! replaced: [`load`] fails, the pane shows why, and every mutation is
18//! refused until the document is repaired, so no save can silently drop what
19//! a user wrote.
20
21use brep_cam::model::{ChainRef, ChainSource, EdgeRef, Machine, Operation, OperationId, Point3, RigidTransform, Setup, Strategy, TargetRef, Tool, ToolGeometry, ToolKind};
22use serde::{Deserialize, Serialize};
23use serde_json::{json, Value};
24use std::collections::BTreeMap;
25
26/// An NC program: an ordered group of operations posted through one
27/// post-processor to one file. User input; the motion stream it posts is
28/// derived at post time (lane C4).
29#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", default)]
31pub struct NcProgram {
32    pub id: String,
33    pub name: String,
34    /// The program number the post writes (`O1234`), when the post has one.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub number: Option<u32>,
37    /// The post-processor's id (a built-in name or a plugin's script id).
38    pub post: String,
39    /// The operations, in posting order.
40    pub operations: Vec<OperationId>,
41    /// The post's property values by name (`programNumber`, a mill-turn
42    /// post's builder codes, ...); absent ones take the post's defaults.
43    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
44    pub properties: BTreeMap<String, Value>,
45}
46
47/// How a setup's machine frame was PICKED: the model entities whose
48/// geometry gives the frame's Z axis, X axis and origin. Kept beside the
49/// setup (keyed by its id) rather than in it, because the shared model
50/// carries the resolved `modelToMachine` transform only; the references are
51/// what let the frame follow the model when it is rebuilt, and what the
52/// dialog shows in its picker fields. Entity names are the persistent names
53/// the selection uses (`Box_PZ`, a `{solid}@x,y,z` vertex).
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase", default)]
56pub struct SetupOrientation {
57    /// Machine +Z: a planar FACE (its outward normal), a cylindrical FACE
58    /// (its axis) or a straight EDGE (its direction, first point to last).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub z_ref: Option<String>,
61    /// Machine +Z as a model direction instead of a pick: an axis name
62    /// (`+X`, `-Y`, ...) or three numbers `x,y,z` (normalized). A tilted
63    /// (3+2) setup that no face or edge of the model points along takes
64    /// this. The form sets one and clears the other; a pick committed
65    /// directly may leave both, and then `z_ref` wins.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub z_dir: Option<String>,
68    /// Reverse the Z the reference gives (an axis or an edge has no
69    /// preferred sense; a face's normal points out of the material).
70    #[serde(skip_serializing_if = "std::ops::Not::not")]
71    pub flip_z: bool,
72    /// A straight EDGE whose direction is machine +X (re-orthogonalized
73    /// against Z); absent, the model's X projected onto the Z plane.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub x_ref: Option<String>,
76    /// A VERTEX (or a FACE, its plane point) that becomes the machine origin;
77    /// absent, the Z face's plane point, else the model origin.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub origin_ref: Option<String>,
80}
81
82impl SetupOrientation {
83    pub fn is_empty(&self) -> bool {
84        self.z_ref.is_none() && self.z_dir.is_none() && self.x_ref.is_none() && self.origin_ref.is_none() && !self.flip_z
85    }
86}
87
88/// What a picked face is, for orientation: a plane (a point on it and its
89/// outward normal) or a cylinder (a point on its axis and the axis).
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub enum FaceShape {
92    Planar { point: Point3, normal: Point3 },
93    Cylindrical { point: Point3, axis: Point3 },
94}
95
96/// The symmetric 3x3 `m`'s eigenvalues (descending) with unit eigenvectors,
97/// by Jacobi rotations (exact enough for a 3x3 normal covariance).
98fn eigen3(mut m: [[f64; 3]; 3]) -> [(f64, [f64; 3]); 3] {
99    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
100    for _ in 0..50 {
101        let (mut p, mut q, mut largest) = (0, 1, 0.0f64);
102        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
103            if m[i][j].abs() > largest {
104                largest = m[i][j].abs();
105                p = i;
106                q = j;
107            }
108        }
109        if largest < 1e-15 {
110            break;
111        }
112        let theta = 0.5 * (2.0 * m[p][q]).atan2(m[q][q] - m[p][p]);
113        let (c, s) = (theta.cos(), theta.sin());
114        let mut r = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
115        r[p][p] = c;
116        r[q][q] = c;
117        r[p][q] = s;
118        r[q][p] = -s;
119        // m = rᵀ m r; v = v r
120        let mul = |a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]| {
121            let mut o = [[0.0; 3]; 3];
122            for i in 0..3 {
123                for j in 0..3 {
124                    o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
125                }
126            }
127            o
128        };
129        let rt = [[r[0][0], r[1][0], r[2][0]], [r[0][1], r[1][1], r[2][1]], [r[0][2], r[1][2], r[2][2]]];
130        m = mul(&mul(&rt, &m), &r);
131        v = mul(&v, &r);
132    }
133    let mut out = [0, 1, 2].map(|k| (m[k][k], [v[0][k], v[1][k], v[2][k]]));
134    out.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
135    out
136}
137
138/// Classify a face from its triangles: PLANAR when every triangle normal
139/// agrees (the normal covariance has one eigenvalue), CYLINDRICAL when every
140/// normal lies perpendicular to one direction (the covariance's smallest
141/// eigenvalue vanishes while the other two do not) — that direction is the
142/// axis. Anything else (a cone, a sphere, a freeform face) is `None`. The
143/// point is the area-weighted centroid, which for a whole cylinder lies on
144/// its axis.
145pub fn classify_face(triangles: &[[[f64; 3]; 3]]) -> Option<FaceShape> {
146    let (mut area, mut centroid, mut mean, mut cov) = (0.0f64, [0.0f64; 3], [0.0f64; 3], [[0.0f64; 3]; 3]);
147    for [a, b, c] in triangles {
148        let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
149        let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
150        let n = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]];
151        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
152        if len <= 1e-18 {
153            continue;
154        }
155        let w = 0.5 * len;
156        let u = [n[0] / len, n[1] / len, n[2] / len];
157        for i in 0..3 {
158            centroid[i] += w * (a[i] + b[i] + c[i]) / 3.0;
159            mean[i] += w * u[i];
160            for j in 0..3 {
161                cov[i][j] += w * u[i] * u[j];
162            }
163        }
164        area += w;
165    }
166    if area <= 1e-18 {
167        return None;
168    }
169    for row in cov.iter_mut() {
170        for x in row.iter_mut() {
171            *x /= area;
172        }
173    }
174    let point = Point3::new(centroid[0] / area, centroid[1] / area, centroid[2] / area);
175    let [(l1, e1), (l2, _), (l3, e3)] = eigen3(cov);
176    const FLAT: f64 = 1e-6;
177    if l2 + l3 < FLAT && l1 > 1.0 - 1e-3 {
178        // The mean normal gives the sense (out of the material).
179        let s = if e1[0] * mean[0] + e1[1] * mean[1] + e1[2] * mean[2] < 0.0 { -1.0 } else { 1.0 };
180        return Some(FaceShape::Planar { point, normal: Point3::new(s * e1[0], s * e1[1], s * e1[2]) });
181    }
182    if l3 < FLAT && l2 > FLAT {
183        // An axis has no sense of its own: the one with a non-negative
184        // largest component, so a vertical cylinder gives +Z (Flip Z reverses it).
185        let k = (0..3).max_by(|&i, &j| e3[i].abs().partial_cmp(&e3[j].abs()).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or(2);
186        let s = if e3[k] < 0.0 { -1.0 } else { 1.0 };
187        return Some(FaceShape::Cylindrical { point, axis: Point3::new(s * e3[0], s * e3[1], s * e3[2]) });
188    }
189    None
190}
191
192/// The machine frame a setup's orientation resolves to, in model
193/// coordinates: what the viewport draws as the WCS triad and what
194/// `Setup::model_to_machine` is built from.
195#[derive(Clone, Copy, Debug, PartialEq)]
196pub struct SetupFrame {
197    pub origin: Point3,
198    pub x_axis: Point3,
199    pub z_axis: Point3,
200}
201
202impl Default for SetupFrame {
203    fn default() -> Self {
204        Self { origin: Point3::ZERO, x_axis: Point3::new(1.0, 0.0, 0.0), z_axis: Point3::new(0.0, 0.0, 1.0) }
205    }
206}
207
208impl SetupFrame {
209    /// The frame's transform: the one place a frame becomes the model's
210    /// `modelToMachine`. An unusable frame (X along Z) is an error.
211    pub fn transform(&self) -> Result<RigidTransform, String> {
212        RigidTransform::from_frame(self.origin, self.x_axis, self.z_axis)
213            .map_err(|e| format!("the setup's frame is not usable: {e:?}"))
214    }
215}
216
217/// Resolve an orientation's references against the model: `face_plane`
218/// answers a face name with (a point on it, its outward normal) and
219/// `edge_polyline` an edge name with its world polyline (a feature's axis
220/// as a straight two-point line along it). A reference that
221/// does not resolve is an error naming it, so a frame never silently falls
222/// back to the model's axes once a pick was made.
223pub fn resolve_frame(
224    orientation: &SetupOrientation,
225    face_shape: &dyn Fn(&str) -> Option<FaceShape>,
226    edge_polyline: &dyn Fn(&str) -> Option<Vec<[f64; 3]>>,
227) -> Result<SetupFrame, String> {
228    let mut frame = SetupFrame::default();
229    let mut z_plane_point = None;
230    if let Some(name) = &orientation.z_ref {
231        let (z, point) = match face_shape(name) {
232            Some(FaceShape::Planar { point, normal }) => (normal, point),
233            Some(FaceShape::Cylindrical { point, axis }) => (axis, point),
234            None => match edge_polyline(name) {
235                Some(line) => {
236                    let (Some(&a), Some(&b)) = (line.first(), line.last()) else {
237                        return Err(format!("Z edge `{name}` has no length"));
238                    };
239                    let chord = Point3::new(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
240                    let length = chord.length();
241                    let straight = length > 1e-12
242                        && line.iter().all(|p| {
243                            let d = Point3::new(p[0] - a[0], p[1] - a[1], p[2] - a[2]);
244                            let t = d.dot(chord) / (length * length);
245                            let off = Point3::new(d.x - chord.x * t, d.y - chord.y * t, d.z - chord.z * t);
246                            off.length() <= 1e-6 * length.max(1.0)
247                        });
248                    if !straight {
249                        return Err(format!("Z edge `{name}` is not straight: pick a straight edge, a planar face or a cylindrical face"));
250                    }
251                    (chord, Point3::new(a[0], a[1], a[2]))
252                }
253                None => return Err(format!("Z reference `{name}` is not a planar face, a cylindrical face, an edge or a feature's axis of the model")),
254            },
255        };
256        frame.z_axis = if orientation.flip_z { Point3::new(-z.x, -z.y, -z.z) } else { z };
257        z_plane_point = Some(point);
258    } else if let Some(dir) = &orientation.z_dir {
259        let z = parse_direction(dir)?;
260        frame.z_axis = if orientation.flip_z { Point3::new(-z.x, -z.y, -z.z) } else { z };
261    } else if orientation.flip_z {
262        frame.z_axis = Point3::new(0.0, 0.0, -1.0);
263    }
264    if let Some(edge) = &orientation.x_ref {
265        let line = edge_polyline(edge).ok_or_else(|| format!("X edge `{edge}` is not an edge of the model"))?;
266        let (Some(first), Some(last)) = (line.first(), line.last()) else {
267            return Err(format!("X edge `{edge}` has no length"));
268        };
269        frame.x_axis = Point3::new(last[0] - first[0], last[1] - first[1], last[2] - first[2]);
270    } else {
271        // The model's X, unless Z already lies along it: then the model's Y.
272        let z = frame.z_axis.normalized().unwrap_or(Point3::new(0.0, 0.0, 1.0));
273        frame.x_axis = if z.x.abs() > 0.9 { Point3::new(0.0, 1.0, 0.0) } else { Point3::new(1.0, 0.0, 0.0) };
274    }
275    frame.origin = match &orientation.origin_ref {
276        Some(name) => match vertex_position(name) {
277            Some(position) => position,
278            None => match face_shape(name) {
279                Some(FaceShape::Planar { point, .. } | FaceShape::Cylindrical { point, .. }) => point,
280                None => return Err(format!("origin `{name}` is neither a vertex nor a planar or cylindrical face of the model")),
281            },
282        },
283        None => z_plane_point.unwrap_or(Point3::ZERO),
284    };
285    frame.transform()?;
286    Ok(frame)
287}
288
289/// A setup's Z direction: `+X`/`-X`/`+Y`/`-Y`/`+Z`/`-Z` (the sign may be
290/// omitted for +, case ignored) or three numbers `x,y,z`, normalized. A zero
291/// or malformed direction is an error naming it.
292pub fn parse_direction(text: &str) -> Result<Point3, String> {
293    let t = text.trim();
294    let axis = |sign: f64, c: &str| -> Option<Point3> {
295        match c {
296            "x" => Some(Point3::new(sign, 0.0, 0.0)),
297            "y" => Some(Point3::new(0.0, sign, 0.0)),
298            "z" => Some(Point3::new(0.0, 0.0, sign)),
299            _ => None,
300        }
301    };
302    let lower = t.to_ascii_lowercase();
303    let named = match lower.as_str() {
304        s if s.len() == 2 && s.starts_with('-') => axis(-1.0, &s[1..]),
305        s if s.len() == 2 && s.starts_with('+') => axis(1.0, &s[1..]),
306        s if s.len() == 1 => axis(1.0, s),
307        _ => None,
308    };
309    if let Some(d) = named {
310        return Ok(d);
311    }
312    let parts: Vec<Option<f64>> = t.split(',').map(|p| p.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
313    if let [Some(x), Some(y), Some(z)] = parts[..] {
314        if let Some(d) = Point3::new(x, y, z).normalized() {
315            return Ok(d);
316        }
317    }
318    Err(format!("Z direction `{t}` is neither an axis (+X, -Y, ...) nor three numbers x,y,z of a non-zero vector"))
319}
320
321/// A vertex reference's world position: `{solid}@x,y,z`.
322pub fn vertex_position(name: &str) -> Option<Point3> {
323    let (_, coords) = name.rsplit_once('@')?;
324    let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
325    let position = Point3::new(parts.next()??, parts.next()??, parts.next()??);
326    if parts.next().is_some() {
327        return None;
328    }
329    Some(position)
330}
331
332/// The `cam` block.
333#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase", default)]
335pub struct CamDocument {
336    pub setups: Vec<Setup>,
337    pub tools: Vec<Tool>,
338    pub operations: Vec<Operation>,
339    /// Exact automatically programmed feature inputs, regenerated associatively on model edits.
340    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
341    pub autoprogram_inputs: BTreeMap<String, brep_cam::autoprogram::binding::Binding>,
342    pub nc_programs: Vec<NcProgram>,
343    /// How each setup's frame was picked, by setup id (see [`SetupOrientation`]).
344    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
345    pub orientations: BTreeMap<String, SetupOrientation>,
346    /// The document's own (custom) machines; a setup's `machine` id names
347    /// one of these or a built-in of `brep_cam::kinematics::library`.
348    #[serde(skip_serializing_if = "Vec::is_empty")]
349    pub machines: Vec<Machine>,
350    /// Each multi-axis finish's three-axis source operation, by the finish's
351    /// id (see `panels::cam_multiaxis`): the generator tilts that path and
352    /// never guesses one.
353    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
354    pub multi_axis_sources: BTreeMap<String, String>,
355    /// One counter mints every id (`SETUP1`, `T2`, `OP3`, `NC4`): monotonic,
356    /// never reused — deleting a record does not free its number — and
357    /// rewound by undo with the block it lives in.
358    pub id_counter: u64,
359}
360
361/// The strategy kinds, in the model's order: the serde tag of each
362/// [`Strategy`] variant, READ FROM THE MODEL rather than pinned here — serde
363/// names every variant when it refuses an unknown tag, and that list is the
364/// enum's declaration order — so a kind lane C0 adds reaches the pane, the
365/// automation and the operation dialog without an edit in the app. Computed
366/// once; `strategy_kinds_are_the_models` checks that each one builds.
367pub fn strategy_kinds() -> &'static [&'static str] {
368    Strategy::KINDS
369}
370
371/// The strategy of `kind` with the model's defaults, or why there is none.
372pub fn strategy_of_kind(kind: &str) -> Result<Strategy, String> {
373    let kind = kind.trim();
374    if kind.is_empty() {
375        return Err("an operation needs a strategy".to_string());
376    }
377    Strategy::default_of(kind).ok_or_else(|| format!("no strategy `{kind}` (one of: {})", strategy_kinds().join(", ")))
378}
379
380impl CamDocument {
381    /// Whether the block holds nothing — then it is omitted from the file.
382    pub fn is_empty(&self) -> bool {
383        self.setups.is_empty() && self.tools.is_empty() && self.operations.is_empty() && self.nc_programs.is_empty() && self.machines.is_empty()
384    }
385
386    /// A setup's orientation picks (empty when none were made).
387    pub fn orientation(&self, setup_id: &str) -> SetupOrientation {
388        self.orientations.get(setup_id).cloned().unwrap_or_default()
389    }
390
391    /// Replace a setup's orientation picks; an empty one is dropped.
392    pub fn set_orientation(&mut self, setup_id: &str, orientation: SetupOrientation) {
393        if orientation.is_empty() {
394            self.orientations.remove(setup_id);
395        } else {
396            self.orientations.insert(setup_id.to_string(), orientation);
397        }
398    }
399
400    /// Mint the next id with `prefix`.
401    pub fn next_id(&mut self, prefix: &str) -> String {
402        self.id_counter += 1;
403        format!("{prefix}{}", self.id_counter)
404    }
405
406    pub fn setup(&self, id: &str) -> Option<&Setup> {
407        self.setups.iter().find(|s| s.id.as_str() == id)
408    }
409
410    pub fn setup_mut(&mut self, id: &str) -> Option<&mut Setup> {
411        self.setups.iter_mut().find(|s| s.id.as_str() == id)
412    }
413
414    pub fn tool(&self, id: &str) -> Option<&Tool> {
415        self.tools.iter().find(|t| t.id.as_str() == id)
416    }
417
418    pub fn tool_mut(&mut self, id: &str) -> Option<&mut Tool> {
419        self.tools.iter_mut().find(|t| t.id.as_str() == id)
420    }
421
422    pub fn operation(&self, id: &str) -> Option<&Operation> {
423        self.operations.iter().find(|op| op.id.as_str() == id)
424    }
425
426    pub fn operation_mut(&mut self, id: &str) -> Option<&mut Operation> {
427        self.operations.iter_mut().find(|op| op.id.as_str() == id)
428    }
429
430    pub fn program(&self, id: &str) -> Option<&NcProgram> {
431        self.nc_programs.iter().find(|p| p.id == id)
432    }
433
434    /// The operations of setup `setup_id`, in block order.
435    pub fn operations_of<'a>(&'a self, setup_id: &'a str) -> impl Iterator<Item = &'a Operation> + 'a {
436        self.operations.iter().filter(move |op| op.setup.as_str() == setup_id)
437    }
438
439    /// Whether any record carries `id`.
440    pub fn has(&self, id: &str) -> bool {
441        self.setup(id).is_some()
442            || self.tool(id).is_some()
443            || self.operation(id).is_some()
444            || self.program(id).is_some()
445            || self.machines.iter().any(|m| m.id.as_str() == id)
446    }
447
448    /// Add a setup named `name` (`Setup N` when absent). Returns the id.
449    pub fn add_setup(&mut self, name: Option<&str>) -> String {
450        let id = self.next_id("SETUP");
451        let name = name.map(str::to_string).unwrap_or_else(|| format!("Setup {}", self.setups.len() + 1));
452        self.setups.push(Setup::new(id.as_str(), name));
453        id
454    }
455
456    /// Add a flat end mill of the default geometry, named `name` (`Tool N`
457    /// when absent), with the next free tool number. Returns the id.
458    /// The machine `id` names: the document's own first, then the built-in library.
459    pub fn machine(&self, id: &str) -> Option<Machine> {
460        self.machines.iter().find(|m| m.id.as_str() == id).cloned().or_else(|| brep_cam::kinematics::library::builtin(id))
461    }
462
463    /// The resolved machine of setup `id`, when it names one.
464    pub fn setup_machine(&self, id: &str) -> Option<Machine> {
465        self.setup(id)?.machine.as_ref().and_then(|m| self.machine(m.as_str()))
466    }
467
468    /// Add a custom machine copied from `base` under a fresh id (`M5`). Returns the id.
469    pub fn add_machine(&mut self, mut base: Machine, name: Option<&str>) -> String {
470        let id = self.next_id("M");
471        base.id = id.as_str().into();
472        base.name = name.map(str::to_string).unwrap_or_else(|| format!("{} (custom)", base.name));
473        self.machines.push(base);
474        id
475    }
476
477    pub fn machine_mut(&mut self, id: &str) -> Option<&mut Machine> {
478        self.machines.iter_mut().find(|m| m.id.as_str() == id)
479    }
480
481    pub fn add_tool(&mut self, name: Option<&str>) -> String {
482        let id = self.next_id("T");
483        let number = self.tools.iter().map(|t| t.number).max().unwrap_or(0) + 1;
484        let mut tool = Tool::new(id.as_str(), number, ToolKind::Flat, ToolGeometry::default());
485        tool.name = name.map(str::to_string).unwrap_or_else(|| format!("Tool {number}"));
486        self.tools.push(tool);
487        id
488    }
489
490    /// Add an operation of strategy kind `kind` to `setup` with `tool`, every
491    /// parameter at its default; refused, naming what is missing, when the
492    /// setup, the tool or the kind does not exist. Returns the id.
493    pub fn add_operation(&mut self, setup: &str, tool: &str, kind: &str, name: Option<&str>) -> Result<String, String> {
494        if self.setup(setup).is_none() {
495            return Err(if self.setups.is_empty() {
496                "no setup yet: press New setup first".to_string()
497            } else {
498                format!("no setup `{setup}`")
499            });
500        }
501        if self.tool(tool).is_none() {
502            return Err(if self.tools.is_empty() {
503                "no tool yet: press New tool first".to_string()
504            } else {
505                format!("no tool `{tool}`")
506            });
507        }
508        let strategy = strategy_of_kind(kind)?;
509        let id = self.next_id("OP");
510        let name = name.map(str::to_string).unwrap_or_else(|| format!("{} {}", strategy.kind_name(), self.operations.len() + 1));
511        self.operations.push(Operation::new(id.as_str(), name, setup, tool, strategy));
512        Ok(id)
513    }
514
515    /// Add an NC program named `name` (`Program N` when absent) over
516    /// `operations`, each of which must exist. Returns the id.
517    pub fn add_program(&mut self, name: Option<&str>, post: &str, operations: &[String]) -> Result<String, String> {
518        for op in operations {
519            if self.operation(op).is_none() {
520                return Err(format!("no operation `{op}`"));
521            }
522        }
523        // An empty post takes the default of the first operation's setup machine.
524        let post = if post.trim().is_empty() {
525            operations
526                .first()
527                .and_then(|op| self.operation(op))
528                .and_then(|op| self.setup_machine(op.setup.as_str()))
529                .and_then(|m| m.post)
530                .unwrap_or_default()
531        } else {
532            post.to_string()
533        };
534        let post = post.as_str();
535        let id = self.next_id("NC");
536        let name = name.map(str::to_string).unwrap_or_else(|| format!("Program {}", self.nc_programs.len() + 1));
537        self.nc_programs.push(NcProgram {
538            id: id.clone(),
539            name,
540            number: None,
541            post: post.to_string(),
542            operations: operations.iter().map(|op| OperationId::new(op.as_str())).collect(),
543            properties: BTreeMap::new(),
544        });
545        Ok(id)
546    }
547
548    /// Set (or, with a `null` value, clear) post properties of NC program
549    /// `id`. A built-in post's names and enum values are checked against its
550    /// own property list, so a misspelt or invalid one is refused by name
551    /// here rather than at post time; a plugin post's are checked when it runs.
552    pub fn set_program_properties(&mut self, id: &str, patch: &BTreeMap<String, Value>) -> Result<(), String> {
553        let program = self.nc_programs.iter_mut().find(|p| p.id == id).ok_or_else(|| format!("no NC program `{id}`"))?;
554        if let Some(post) = brep_cam::post::builtin(&program.post) {
555            let definition = brep_cam::post::describe(post.source, post.id).map_err(|e| format!("post `{}`: {}", post.id, e.message))?;
556            for (name, value) in patch {
557                let Some(spec) = definition.properties.get(name) else {
558                    return Err(format!("post `{}` has no property `{name}` (it has: {})", post.id, definition.properties.keys().cloned().collect::<Vec<_>>().join(", ")));
559                };
560                if let (Some(values), Some(v)) = (&spec.values, value.as_str()) {
561                    if !values.iter().any(|x| x.as_str() == Some(v)) {
562                        return Err(format!("post `{}` property `{name}` must be one of {}, not `{v}`", post.id, values.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::<Vec<_>>().join(", ")));
563                    }
564                }
565            }
566        }
567        for (name, value) in patch {
568            if value.is_null() {
569                program.properties.remove(name);
570            } else {
571                program.properties.insert(name.clone(), value.clone());
572            }
573        }
574        Ok(())
575    }
576
577    /// The designated three-axis source of multi-axis finish `op_id`.
578    pub fn multi_axis_source(&self, op_id: &str) -> Option<&str> {
579        self.multi_axis_sources.get(op_id).map(String::as_str)
580    }
581
582    /// Designate (or with `None` clear) the source of `op_id`; the caller
583    /// checks eligibility (`panels::cam_multiaxis::apply_source`).
584    pub fn set_multi_axis_source(&mut self, op_id: &str, source: Option<String>) {
585        match source {
586            Some(s) => {
587                self.multi_axis_sources.insert(op_id.to_string(), s);
588            }
589            None => {
590                self.multi_axis_sources.remove(op_id);
591            }
592        }
593    }
594
595    /// Remove the record with `id`: a setup takes its operations with it; a
596    /// tool in use by an operation is refused; an operation leaves every NC
597    /// program that listed it.
598    pub fn remove(&mut self, id: &str) -> Result<(), String> {
599        if self.tool(id).is_some() && self.operations.iter().any(|op| op.tool.as_str() == id) {
600            return Err(format!("tool `{id}` is used by an operation; remove the operation first"));
601        }
602        if self.machines.iter().any(|m| m.id.as_str() == id) {
603            if let Some(setup) = self.setups.iter().find(|s| s.machine.as_ref().is_some_and(|m| m.as_str() == id)) {
604                return Err(format!("machine `{id}` is used by setup `{}`; choose another machine there first", setup.id));
605            }
606        }
607        if !self.has(id) {
608            return Err(format!("no CAM record `{id}`"));
609        }
610        let gone: Vec<String> = if self.setup(id).is_some() {
611            self.operations_of(id).map(|op| op.id.as_str().to_string()).chain(std::iter::once(id.to_string())).collect()
612        } else {
613            vec![id.to_string()]
614        };
615        self.setups.retain(|s| s.id.as_str() != id);
616        self.orientations.remove(id);
617        self.multi_axis_sources.retain(|finish, source| !gone.contains(finish) && !gone.contains(source));
618        self.tools.retain(|t| t.id.as_str() != id);
619        self.machines.retain(|m| m.id.as_str() != id);
620        self.operations.retain(|op| !gone.contains(&op.id.as_str().to_string()));
621        self.autoprogram_inputs.retain(|id, _| !gone.contains(id));
622        self.nc_programs.retain(|p| p.id != id);
623        for program in &mut self.nc_programs {
624            program.operations.retain(|op| !gone.contains(&op.as_str().to_string()));
625        }
626        Ok(())
627    }
628}
629
630/// Upgrade a block written by lane C1 row 1, before the model was imported:
631/// an operation's `strategy` was its kind NAME and a tool carried only `id`,
632/// `number` and `name`. The model's defaults cover the tool; the strategy
633/// becomes the tagged object the model reads.
634fn upgrade_row1(value: &mut Value) {
635    if let Some(ops) = value.get_mut("operations").and_then(Value::as_array_mut) {
636        for op in ops {
637            if let Some(kind) = op.get("strategy").and_then(Value::as_str).map(str::to_string) {
638                op["strategy"] = json!({ "kind": kind });
639            }
640        }
641    }
642}
643
644/// A reference pick commits NAMES (`["Box"]`) at a setup's `targets` /
645/// `protected` and an operation's `targets.driveFaces` / `avoidFaces`,
646/// which the model holds as `TargetRef` objects; the names become
647/// references on read (a body name a whole-body reference, a face name a
648/// face reference whose owning solid the pane fills from the scene), so the
649/// block stays the model's.
650/// The key a chain pick commits its names under, inside an operation's
651/// `targets`; the load folds it into `chains` (see [`chain_from_names`]).
652pub const CHAIN_PICK_KEY: &str = "chainPick";
653
654/// The key a machining-boundary pick commits its names under, inside an
655/// operation's `targets`; the pane samples the picked sketch profile into the
656/// strategy's containment boundary ([`crate::panels::cam::sync_operation_faces`])
657/// and the rewritten block no longer carries it.
658pub const BOUNDARY_PICK_KEY: &str = "boundaryPick";
659
660/// The names a pick committed: a list, or one name for a single-valued field.
661pub fn pick_names(value: &Value) -> Vec<String> {
662    match value {
663        Value::String(s) => vec![s.clone()],
664        Value::Array(list) => list.iter().filter_map(Value::as_str).map(str::to_string).collect(),
665        _ => Vec::new(),
666    }
667}
668
669/// Whether the stored block still holds a committed chain pick the model has
670/// not absorbed (the pane rewrites the block once, without an undo step).
671pub fn has_pending_chain_pick(block: &Value) -> bool {
672    block
673        .get("operations")
674        .and_then(Value::as_array)
675        .is_some_and(|ops| ops.iter().any(|op| op.get("targets").and_then(|t| t.get(CHAIN_PICK_KEY)).is_some()))
676}
677
678/// The names a machining-boundary pick committed for operation `id` that the
679/// model has not absorbed yet.
680pub fn pending_boundary_pick(block: &Value, id: &str) -> Option<Vec<String>> {
681    block
682        .get("operations")?
683        .as_array()?
684        .iter()
685        .find(|op| op.get("id").and_then(Value::as_str) == Some(id))?
686        .get("targets")?
687        .get(BOUNDARY_PICK_KEY)
688        .map(pick_names)
689}
690
691/// The chain the picked entity `names` describe, in pick order: one sketch
692/// profile face (`{sketch}:PROFILE` or `{sketch}:FACE`) is that sketch's
693/// profile, every region; names that are all segments of ONE sketch
694/// (`{sketch}:G{id}`) are its curves by bare id; anything else is a BREP
695/// edge chain whose owning bodies the pane fills from the scene. `None` for
696/// no names. Duplicates are dropped, keeping the first.
697pub fn chain_from_names(names: &[String]) -> Option<ChainRef> {
698    let names: Vec<&str> = names.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
699    if names.is_empty() {
700        return None;
701    }
702    let profile = |n: &str| n.strip_suffix(":PROFILE").or_else(|| n.strip_suffix(":FACE")).filter(|s| !s.is_empty()).map(str::to_string);
703    if names.len() == 1 {
704        if let Some(sketch) = profile(names[0]) {
705            return Some(ChainRef { source: ChainSource::SketchProfile { sketch, regions: Vec::new() }, closed: true, ..Default::default() });
706        }
707    }
708    let curve = |n: &str| -> Option<(String, String)> {
709        let (sketch, id) = n.rsplit_once(":G")?;
710        (!sketch.is_empty() && !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())).then(|| (sketch.to_string(), id.to_string()))
711    };
712    if let Some(parsed) = names.iter().map(|n| curve(n)).collect::<Option<Vec<_>>>() {
713        let sketch = parsed[0].0.clone();
714        if parsed.iter().all(|(s, _)| *s == sketch) {
715            let mut curves: Vec<String> = Vec::new();
716            for (_, id) in parsed {
717                if !curves.contains(&id) {
718                    curves.push(id);
719                }
720            }
721            return Some(ChainRef { source: ChainSource::SketchCurves { sketch, curves }, ..Default::default() });
722        }
723    }
724    let mut edges: Vec<EdgeRef> = Vec::new();
725    for name in names {
726        if !edges.iter().any(|e| e.edge == name) {
727            edges.push(EdgeRef::new("", name));
728        }
729    }
730    Some(ChainRef { source: ChainSource::Edges { edges }, ..Default::default() })
731}
732
733fn normalise_target_refs(value: &mut Value) {
734    if let Some(setups) = value.get_mut("setups").and_then(Value::as_array_mut) {
735        for setup in setups {
736            for key in ["targets", "protected"] {
737                if let Some(list) = setup.get_mut(key).and_then(Value::as_array_mut) {
738                    for entry in list.iter_mut() {
739                        if let Some(name) = entry.as_str().map(str::to_string) {
740                            *entry = serde_json::to_value(TargetRef::solid(name)).unwrap_or(Value::Null);
741                        }
742                    }
743                }
744            }
745        }
746    }
747    if let Some(operations) = value.get_mut("operations").and_then(Value::as_array_mut) {
748        for operation in operations {
749            // A committed chain pick (the picker writes the names it collected
750            // under `targets.chainPick`) becomes one more chain.
751            if let Some(targets) = operation.get_mut("targets").and_then(Value::as_object_mut) {
752                if let Some(pick) = targets.remove(CHAIN_PICK_KEY) {
753                    if let Some(chain) = chain_from_names(&pick_names(&pick)) {
754                        let entry = serde_json::to_value(chain).unwrap_or(Value::Null);
755                        match targets.get_mut("chains").and_then(Value::as_array_mut) {
756                            Some(list) => list.push(entry),
757                            None => {
758                                targets.insert("chains".to_string(), json!([entry]));
759                            }
760                        }
761                    }
762                }
763            }
764            for key in ["driveFaces", "avoidFaces"] {
765                if let Some(list) = operation.get_mut("targets").and_then(|t| t.get_mut(key)).and_then(Value::as_array_mut) {
766                    for entry in list.iter_mut() {
767                        if let Some(name) = entry.as_str().map(str::to_string) {
768                            *entry = json!({ "solid": "", "face": name });
769                        }
770                    }
771                }
772            }
773        }
774    }
775}
776
777/// A single-valued pick with nothing picked commits an EMPTY name: an
778/// orientation reference that is empty reads as no reference, so clearing a
779/// field in the picker is the same as never having picked it.
780fn normalise_orientations(value: &mut Value) {
781    let Some(orientations) = value.get_mut("orientations").and_then(Value::as_object_mut) else {
782        return;
783    };
784    for orientation in orientations.values_mut() {
785        if let Some(fields) = orientation.as_object_mut() {
786            fields.retain(|_, v| !matches!(v, Value::String(s) if s.trim().is_empty()));
787        }
788    }
789}
790
791/// Read the block (`None` = no block = empty). A block that does not parse
792/// is an error naming why; the caller leaves it as it is.
793pub fn load(block: Option<&Value>) -> Result<CamDocument, String> {
794    let Some(block) = block else {
795        return Ok(CamDocument::default());
796    };
797    let mut value = block.clone();
798    upgrade_row1(&mut value);
799    normalise_target_refs(&mut value);
800    normalise_orientations(&mut value);
801    serde_json::from_value(value).map_err(|e| format!("the document's `cam` block cannot be read: {e}"))
802}
803
804/// The block as the document stores it, or `None` when empty.
805pub fn store(doc: &CamDocument) -> Option<Value> {
806    if doc.is_empty() {
807        return None;
808    }
809    serde_json::to_value(doc).ok()
810}
811