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