Skip to main content

brep_cam/model/
toolpath.rs

1//! The toolpath: an ordered stream of typed motions plus the operation report.
2//!
3//! Every strategy produces this stream; simulation, preview and the post read
4//! the same stream, so they cannot disagree about rapid/plunge/cut order. Each
5//! motion carries its own feed; the enclosing [`Toolpath`] carries the
6//! operation and tool identity, and a program-level stream carries one
7//! [`ProgramMotion`] per motion so identity survives concatenation.
8
9use super::{Coolant, OperationId, Point3, SpindleDirection, ToolId};
10use serde::{Deserialize, Serialize};
11
12/// Feed for one motion. `Rapid` posts as G0.
13#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum Feed {
16    /// Rapid traverse (serialized as the string `"rapid"`).
17    Rapid(RapidTag),
18    /// Feed rate in model units per minute.
19    Rate(f64),
20}
21
22/// The unit payload of [`Feed::Rapid`]; serializes as the literal `"rapid"`.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum RapidTag {
26    #[default]
27    Rapid,
28}
29
30impl Feed {
31    pub const RAPID: Feed = Feed::Rapid(RapidTag::Rapid);
32
33    pub fn rate(rate: f64) -> Feed {
34        Feed::Rate(rate)
35    }
36
37    pub fn is_rapid(&self) -> bool {
38        matches!(self, Feed::Rapid(_))
39    }
40
41    /// Feed rate, `None` for rapids.
42    pub fn value(&self) -> Option<f64> {
43        match self {
44            Feed::Rapid(_) => None,
45            Feed::Rate(r) => Some(*r),
46        }
47    }
48}
49
50impl Default for Feed {
51    fn default() -> Self {
52        Feed::RAPID
53    }
54}
55
56/// Plane of an arc motion.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum ArcPlane {
60    #[default]
61    Xy,
62    Xz,
63    Yz,
64}
65
66/// One typed motion. Positions are the TOOL REFERENCE POINT in machine
67/// coordinates; every variant that moves the tool ends at `to`. The start of a
68/// motion is the end of the previous one.
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
71#[non_exhaustive]
72pub enum MotionKind {
73    /// An expanded probe trigger move (lane C14); never ordinary cutting.
74    Probe { cycle: super::ProbeCycle },
75    /// Rapid traverse clear of material.
76    Rapid { to: Point3 },
77    /// Vertical (or near-vertical) descent into material at plunge feed.
78    Plunge { to: Point3 },
79    /// Cutting move.
80    Cut { to: Point3 },
81    /// Clockwise arc (looking down the plane's normal) about `center`.
82    /// `pitch` is the axial advance per full turn for helices, `0` for planar arcs.
83    ArcCw {
84        to: Point3,
85        center: Point3,
86        plane: ArcPlane,
87        #[serde(default)]
88        pitch: f64,
89    },
90    /// Counter-clockwise arc, same conventions as `ArcCw`.
91    ArcCcw {
92        to: Point3,
93        center: Point3,
94        plane: ArcPlane,
95        #[serde(default)]
96        pitch: f64,
97    },
98    /// Move up and away from material.
99    Retract { to: Point3 },
100    /// Approach into the cut.
101    LeadIn { to: Point3 },
102    /// Departure out of the cut.
103    LeadOut { to: Point3 },
104    /// Non-cutting connection between passes that stays near the surface.
105    Link { to: Point3 },
106    /// Pause for `seconds`.
107    Dwell { seconds: f64 },
108    /// Tool change to `tool`.
109    ToolChange { tool: ToolId },
110    /// A canned hole cycle executed at `at` (XY; Z is the retract plane).
111    Cycle { at: Point3, cycle: Cycle },
112    /// Spindle on at `rpm`.
113    SpindleOn { rpm: f64, direction: SpindleDirection },
114    SpindleOff,
115    /// Orient the stopped spindle to `angle_deg` (fine boring, back boring: M19).
116    SpindleOrient { angle_deg: f64 },
117    /// Rigid tapping mode on/off (spindle synchronized with the feed: M29).
118    SpindleSync { enabled: bool },
119    /// Coolant mode change.
120    Coolant { mode: Coolant },
121    /// A comment passed to the post verbatim (operation start, notes).
122    Comment { text: String },
123    /// Tilted work plane on (G68.2 / CYCLE800 / PLANE SPATIAL) for a 3+2
124    /// indexed section. `origin`, `x_axis` and `z_axis` are in setup
125    /// coordinates; positions in the stream STAY in setup coordinates (the
126    /// post derives the local coordinates); `z_axis` is the section's tool
127    /// axis, and canned-cycle depths inside the section run along it.
128    WorkPlane { origin: Point3, x_axis: Point3, z_axis: Point3 },
129    /// Tilted work plane off (G69 / CYCLE800() / PLANE RESET).
130    WorkPlaneOff,
131    /// Position the rotary axes with the tool clear of the part (3+2
132    /// indexing); angles in degrees in the machine's axis order.
133    RotaryPosition { axes: Vec<RotaryWord> },
134    /// Rotary tool-centre-point control on/off (G43.4 / TRAORI / M128).
135    TcpMode { enabled: bool },
136    /// Enter G18 XZ turning; internal X coordinates remain radius. Diameter
137    /// mode is post conversion only.
138    LatheMode { diameter_mode: bool },
139    /// G96 constant surface speed with a G50 maximum rpm; units/min
140    /// consistent with model units.
141    SpindleSurfaceSpeed { surface_speed: f64, max_rpm: f64, direction: SpindleDirection },
142    /// G33/G32 synchronized constant-pitch thread cut; the start is the
143    /// previous position. `pitch` is the authoritative synchronisation: never
144    /// convert it to an ordinary feed move silently.
145    ThreadCut { to: Point3, pitch: f64 },
146    /// Canned turning thread schedule; the expanded equivalent is one
147    /// `ThreadCut` per pass.
148    TurnThreadCycle { cycle: TurnThreadCycle },
149    /// Polar interpolation on/off (G12.1 / TRANSMIT): XY programmed as X and C (lane C11b).
150    PolarInterpolation { enabled: bool },
151    /// Cylindrical interpolation (G7.1 / TRACYL) on a cylinder of `radius`;
152    /// `None` cancels it.
153    CylindricalInterpolation { radius: Option<f64> },
154    /// Synchronized linear and rotary feed move: the linear axes to `to` while
155    /// the rotary axes reach `axes` (lane C11b).
156    LinearRotary { to: Point3, axes: Vec<RotaryWord> },
157    /// Program stop (M0) or optional stop (M1) (lane C15).
158    Stop { optional: bool },
159    /// Explicit user text passed to the post byte for byte, in order; never
160    /// interpolated or generated (lane C15).
161    PassThrough { text: String },
162}
163
164/// A canned turning thread schedule (G76-class). X is radius.
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct TurnThreadCycle {
168    pub start: Point3,
169    pub end: Point3,
170    pub pitch: f64,
171    pub depths: Vec<f64>,
172    pub infeed_offsets: Vec<f64>,
173    pub clearance_x: f64,
174    pub internal: bool,
175    pub spring_passes: u32,
176}
177
178impl TurnThreadCycle {
179    pub fn is_finite(&self) -> bool {
180        self.start.is_finite()
181            && self.end.is_finite()
182            && self.pitch.is_finite()
183            && self.clearance_x.is_finite()
184            && self.depths.iter().all(|d| d.is_finite())
185            && self.infeed_offsets.iter().all(|d| d.is_finite())
186    }
187}
188
189/// One rotary axis word of a [`MotionKind::RotaryPosition`].
190#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct RotaryWord {
193    /// Axis letter (`A`, `B`, `C`).
194    pub name: String,
195    pub angle_deg: f64,
196}
197
198impl MotionKind {
199    /// Destination of a motion that moves the tool.
200    pub fn end(&self) -> Option<Point3> {
201        match self {
202            MotionKind::Rapid { to }
203            | MotionKind::Plunge { to }
204            | MotionKind::Cut { to }
205            | MotionKind::ArcCw { to, .. }
206            | MotionKind::ArcCcw { to, .. }
207            | MotionKind::Retract { to }
208            | MotionKind::LeadIn { to }
209            | MotionKind::LeadOut { to }
210            | MotionKind::Link { to }
211            | MotionKind::ThreadCut { to, .. }
212            | MotionKind::LinearRotary { to, .. } => Some(*to),
213            MotionKind::Probe { cycle } => Some(cycle.target),
214            MotionKind::Cycle { at, .. } => Some(*at),
215            // The canned thread returns to the clearance X on the start's Z.
216            MotionKind::TurnThreadCycle { cycle } => Some(Point3::new(cycle.clearance_x, 0.0, cycle.start.z)),
217            MotionKind::Dwell { .. }
218            | MotionKind::ToolChange { .. }
219            | MotionKind::SpindleOn { .. }
220            | MotionKind::SpindleOff
221            | MotionKind::SpindleOrient { .. }
222            | MotionKind::SpindleSync { .. }
223            | MotionKind::Coolant { .. }
224            | MotionKind::Comment { .. }
225            | MotionKind::WorkPlane { .. }
226            | MotionKind::WorkPlaneOff
227            | MotionKind::RotaryPosition { .. }
228            | MotionKind::TcpMode { .. }
229            | MotionKind::LatheMode { .. }
230            | MotionKind::SpindleSurfaceSpeed { .. }
231            | MotionKind::PolarInterpolation { .. }
232            | MotionKind::CylindricalInterpolation { .. }
233            | MotionKind::Stop { .. }
234            | MotionKind::PassThrough { .. } => None,
235        }
236    }
237
238    /// Motions that remove material (cuts, arcs, leads, cycles, plunges).
239    pub fn is_cutting(&self) -> bool {
240        matches!(
241            self,
242            MotionKind::Plunge { .. }
243                | MotionKind::Cut { .. }
244                | MotionKind::ArcCw { .. }
245                | MotionKind::ArcCcw { .. }
246                | MotionKind::LeadIn { .. }
247                | MotionKind::LeadOut { .. }
248                | MotionKind::Cycle { .. }
249                | MotionKind::ThreadCut { .. }
250                | MotionKind::TurnThreadCycle { .. }
251                | MotionKind::LinearRotary { .. }
252        )
253    }
254
255    /// Stable lowercase name (the serde tag).
256    pub fn kind_name(&self) -> &'static str {
257        match self {
258            MotionKind::Probe { .. } => "probe",
259            MotionKind::Rapid { .. } => "rapid",
260            MotionKind::Plunge { .. } => "plunge",
261            MotionKind::Cut { .. } => "cut",
262            MotionKind::ArcCw { .. } => "arcCw",
263            MotionKind::ArcCcw { .. } => "arcCcw",
264            MotionKind::Retract { .. } => "retract",
265            MotionKind::LeadIn { .. } => "leadIn",
266            MotionKind::LeadOut { .. } => "leadOut",
267            MotionKind::Link { .. } => "link",
268            MotionKind::Dwell { .. } => "dwell",
269            MotionKind::ToolChange { .. } => "toolChange",
270            MotionKind::Cycle { .. } => "cycle",
271            MotionKind::SpindleOn { .. } => "spindleOn",
272            MotionKind::SpindleOff => "spindleOff",
273            MotionKind::SpindleOrient { .. } => "spindleOrient",
274            MotionKind::SpindleSync { .. } => "spindleSync",
275            MotionKind::Coolant { .. } => "coolant",
276            MotionKind::Comment { .. } => "comment",
277            MotionKind::WorkPlane { .. } => "workPlane",
278            MotionKind::WorkPlaneOff => "workPlaneOff",
279            MotionKind::RotaryPosition { .. } => "rotaryPosition",
280            MotionKind::TcpMode { .. } => "tcpMode",
281            MotionKind::LatheMode { .. } => "latheMode",
282            MotionKind::SpindleSurfaceSpeed { .. } => "spindleSurfaceSpeed",
283            MotionKind::ThreadCut { .. } => "threadCut",
284            MotionKind::TurnThreadCycle { .. } => "turnThreadCycle",
285            MotionKind::PolarInterpolation { .. } => "polarInterpolation",
286            MotionKind::CylindricalInterpolation { .. } => "cylindricalInterpolation",
287            MotionKind::LinearRotary { .. } => "linearRotary",
288            MotionKind::Stop { .. } => "stop",
289            MotionKind::PassThrough { .. } => "passThrough",
290        }
291    }
292
293    /// Every coordinate finite.
294    pub fn is_finite(&self) -> bool {
295        match self {
296            MotionKind::ArcCw { to, center, pitch, .. } | MotionKind::ArcCcw { to, center, pitch, .. } => {
297                to.is_finite() && center.is_finite() && pitch.is_finite()
298            }
299            MotionKind::Probe { cycle } => cycle.is_finite(),
300            MotionKind::Dwell { seconds } => seconds.is_finite(),
301            MotionKind::SpindleOn { rpm, .. } => rpm.is_finite(),
302            MotionKind::SpindleOrient { angle_deg } => angle_deg.is_finite(),
303            MotionKind::Cycle { at, cycle } => at.is_finite() && cycle.is_finite(),
304            MotionKind::WorkPlane { origin, x_axis, z_axis } => {
305                origin.is_finite() && x_axis.is_finite() && z_axis.is_finite()
306            }
307            MotionKind::RotaryPosition { axes } => axes.iter().all(|a| a.angle_deg.is_finite()),
308            MotionKind::LatheMode { .. } => true,
309            MotionKind::SpindleSurfaceSpeed { surface_speed, max_rpm, .. } => surface_speed.is_finite() && max_rpm.is_finite(),
310            MotionKind::ThreadCut { to, pitch } => to.is_finite() && pitch.is_finite(),
311            MotionKind::TurnThreadCycle { cycle } => cycle.is_finite(),
312            MotionKind::CylindricalInterpolation { radius } => radius.map_or(true, |r| r.is_finite() && r > 0.0),
313            MotionKind::LinearRotary { to, axes } => to.is_finite() && axes.iter().all(|a| a.angle_deg.is_finite()),
314            other => other.end().map_or(true, |p| p.is_finite()),
315        }
316    }
317}
318
319/// A canned hole-making cycle. Depths are absolute MACHINE Z of the hole
320/// bottom; `retract_z` is the plane the cycle retracts to.
321#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
322#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
323#[non_exhaustive]
324pub enum Cycle {
325    /// G81 / G82 (with dwell).
326    Drill {
327        depth_z: f64,
328        retract_z: f64,
329        #[serde(default)]
330        dwell_seconds: f64,
331    },
332    /// G83 deep drilling with full retract between pecks.
333    Peck { depth_z: f64, retract_z: f64, peck: f64 },
334    /// G73 chip breaking with a short retract between pecks.
335    ChipBreak {
336        depth_z: f64,
337        retract_z: f64,
338        peck: f64,
339        #[serde(default)]
340        back_off: f64,
341    },
342    /// G84 tapping. `rigid` requests synchronous (rigid) tapping.
343    Tap {
344        depth_z: f64,
345        retract_z: f64,
346        pitch: f64,
347        rigid: bool,
348        #[serde(default)]
349        left_hand: bool,
350    },
351    /// G85 reaming (feed in, feed out).
352    Ream { depth_z: f64, retract_z: f64 },
353    /// G76/G86/G89 boring. `shift` is the fine-boring tool shift, `dwell` at bottom.
354    Bore {
355        depth_z: f64,
356        retract_z: f64,
357        #[serde(default)]
358        dwell_seconds: f64,
359        #[serde(default)]
360        shift: f64,
361        #[serde(default)]
362        feed_out: bool,
363    },
364    /// G87 back boring: `depth_z` is the finished (upper) face of the back cut;
365    /// `entry_z` the axial entry position below it.
366    BackBore {
367        depth_z: f64,
368        retract_z: f64,
369        shift: f64,
370        #[serde(default)]
371        entry_z: f64,
372    },
373}
374
375impl Cycle {
376    pub fn depth_z(&self) -> f64 {
377        match self {
378            Cycle::Drill { depth_z, .. }
379            | Cycle::Peck { depth_z, .. }
380            | Cycle::ChipBreak { depth_z, .. }
381            | Cycle::Tap { depth_z, .. }
382            | Cycle::Ream { depth_z, .. }
383            | Cycle::Bore { depth_z, .. }
384            | Cycle::BackBore { depth_z, .. } => *depth_z,
385        }
386    }
387
388    pub fn retract_z(&self) -> f64 {
389        match self {
390            Cycle::Drill { retract_z, .. }
391            | Cycle::Peck { retract_z, .. }
392            | Cycle::ChipBreak { retract_z, .. }
393            | Cycle::Tap { retract_z, .. }
394            | Cycle::Ream { retract_z, .. }
395            | Cycle::Bore { retract_z, .. }
396            | Cycle::BackBore { retract_z, .. } => *retract_z,
397        }
398    }
399
400    pub fn is_finite(&self) -> bool {
401        let extra = match self {
402            Cycle::Drill { dwell_seconds, .. } => dwell_seconds.is_finite(),
403            Cycle::Peck { peck, .. } => peck.is_finite(),
404            Cycle::ChipBreak { peck, back_off, .. } => peck.is_finite() && back_off.is_finite(),
405            Cycle::Tap { pitch, .. } => pitch.is_finite(),
406            Cycle::Ream { .. } => true,
407            Cycle::Bore { dwell_seconds, shift, .. } => dwell_seconds.is_finite() && shift.is_finite(),
408            Cycle::BackBore { shift, entry_z, .. } => shift.is_finite() && entry_z.is_finite(),
409        };
410        extra && self.depth_z().is_finite() && self.retract_z().is_finite()
411    }
412}
413
414/// One motion with its feed and optional multi-axis tool orientation.
415#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct Motion {
418    #[serde(flatten)]
419    pub kind: MotionKind,
420    #[serde(default)]
421    pub feed: Feed,
422    /// Tool axis direction at the end of the motion (`None`: machine +Z).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub tool_axis: Option<Point3>,
425    /// Id of the source path/pass within the operation, for preview grouping
426    /// and slider snap points.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub path_id: Option<u32>,
429}
430
431impl Motion {
432    pub fn new(kind: MotionKind, feed: Feed) -> Self {
433        Self { kind, feed, tool_axis: None, path_id: None }
434    }
435
436    pub fn rapid(to: Point3) -> Self {
437        Self::new(MotionKind::Rapid { to }, Feed::RAPID)
438    }
439
440    pub fn plunge(to: Point3, feed: f64) -> Self {
441        Self::new(MotionKind::Plunge { to }, Feed::Rate(feed))
442    }
443
444    pub fn cut(to: Point3, feed: f64) -> Self {
445        Self::new(MotionKind::Cut { to }, Feed::Rate(feed))
446    }
447
448    pub fn retract(to: Point3, feed: Feed) -> Self {
449        Self::new(MotionKind::Retract { to }, feed)
450    }
451
452    pub fn link(to: Point3, feed: f64) -> Self {
453        Self::new(MotionKind::Link { to }, Feed::Rate(feed))
454    }
455
456    pub fn with_path(mut self, path_id: u32) -> Self {
457        self.path_id = Some(path_id);
458        self
459    }
460
461    pub fn end(&self) -> Option<Point3> {
462        self.kind.end()
463    }
464}
465
466/// The result of generating one operation.
467#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
468#[serde(rename_all = "camelCase")]
469pub struct Toolpath {
470    pub operation: OperationId,
471    pub tool: ToolId,
472    #[serde(default)]
473    pub motions: Vec<Motion>,
474    #[serde(default)]
475    pub report: OperationReport,
476}
477
478impl Toolpath {
479    pub fn new(operation: impl Into<OperationId>, tool: impl Into<ToolId>) -> Self {
480        Self { operation: operation.into(), tool: tool.into(), motions: Vec::new(), report: OperationReport::default() }
481    }
482
483    /// Total length of cutting motions (straight-line approximation for arcs).
484    pub fn cutting_length(&self) -> f64 {
485        let mut total = 0.0;
486        let mut at: Option<Point3> = None;
487        for m in &self.motions {
488            if let Some(end) = m.end() {
489                if let Some(start) = at {
490                    if m.kind.is_cutting() {
491                        total += start.distance(end);
492                    }
493                }
494                at = Some(end);
495            }
496        }
497        total
498    }
499
500    /// Every motion coordinate finite; the first offender's index otherwise.
501    pub fn first_non_finite(&self) -> Option<usize> {
502        self.motions.iter().position(|m| {
503            !m.kind.is_finite()
504                || m.feed.value().map_or(false, |f| !f.is_finite())
505                || m.tool_axis.map_or(false, |a| !a.is_finite())
506        })
507    }
508}
509
510/// A motion with the operation it came from, for program-level streams.
511#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
512#[serde(rename_all = "camelCase")]
513pub struct ProgramMotion {
514    pub operation: OperationId,
515    pub motion: Motion,
516}
517
518/// Outcome of generating an operation.
519#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
520#[serde(rename_all = "kebab-case")]
521pub enum OperationStatus {
522    /// Not generated yet.
523    #[default]
524    Pending,
525    /// Generated without warnings.
526    Ok,
527    /// Generated; the warnings describe degradations.
528    Warning,
529    /// Generation stopped; the errors say why. No usable path.
530    Error,
531    /// Cancelled; the previous accepted result stands.
532    Cancelled,
533}
534
535/// A warning or error with a stable code.
536#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
537#[serde(rename_all = "camelCase")]
538pub struct Diagnostic {
539    /// Stable machine-readable code, kebab-case (`empty-after-clipping`, `gouge-detected`).
540    pub code: String,
541    /// User-facing message.
542    pub message: String,
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub detail: Option<String>,
545    /// Where it happened, in machine coordinates.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub location: Option<Point3>,
548}
549
550impl Diagnostic {
551    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
552        Self { code: code.into(), message: message.into(), detail: None, location: None }
553    }
554
555    pub fn at(mut self, location: Point3) -> Self {
556        self.location = Some(location);
557        self
558    }
559
560    pub fn detail(mut self, detail: impl Into<String>) -> Self {
561        self.detail = Some(detail.into());
562        self
563    }
564}
565
566/// A region the operation requested but could not machine.
567#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
568#[serde(rename_all = "camelCase")]
569pub struct EmptyRegion {
570    /// Why: `no-triangles`, `clipped-away`, `unreachable`, `below-floor`, ...
571    pub reason: String,
572    /// Drive face or boundary that named the region, when there is one.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub target: Option<super::TargetRef>,
575}
576
577/// Where the generated path knowingly deviates from the exact surface.
578#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
579#[serde(rename_all = "camelCase")]
580pub struct ApproximationLimit {
581    /// `chord-tolerance`, `arc-discretized`, `sampling-cap`, `solver-tolerance`, ...
582    pub kind: String,
583    /// Deviation bound in model units when known.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub bound: Option<f64>,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub detail: Option<String>,
588}
589
590/// Work counters a generation reports.
591#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "camelCase")]
593#[serde(default)]
594pub struct WorkCounts {
595    pub triangles: u64,
596    pub candidates: u64,
597    pub samples: u64,
598    pub contacts: u64,
599    pub passes: u64,
600    pub motions: u64,
601}
602
603/// Status, diagnostics and counters for one operation.
604#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub struct OperationReport {
607    #[serde(default)]
608    pub status: OperationStatus,
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub errors: Vec<Diagnostic>,
611    #[serde(default, skip_serializing_if = "Vec::is_empty")]
612    pub warnings: Vec<Diagnostic>,
613    #[serde(default, skip_serializing_if = "Vec::is_empty")]
614    pub empty_regions: Vec<EmptyRegion>,
615    #[serde(default, skip_serializing_if = "Vec::is_empty")]
616    pub approximations: Vec<ApproximationLimit>,
617    #[serde(default)]
618    pub counts: WorkCounts,
619    /// Estimated machining time in seconds, when the strategy computes one.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub estimated_seconds: Option<f64>,
622}
623
624impl OperationReport {
625    pub fn warn(&mut self, d: Diagnostic) {
626        self.warnings.push(d);
627        if self.status == OperationStatus::Ok || self.status == OperationStatus::Pending {
628            self.status = OperationStatus::Warning;
629        }
630    }
631
632    pub fn fail(&mut self, d: Diagnostic) {
633        self.errors.push(d);
634        self.status = OperationStatus::Error;
635    }
636
637    /// Mark generated: `Ok` unless warnings or errors were recorded.
638    pub fn finish(&mut self) {
639        self.status = if !self.errors.is_empty() {
640            OperationStatus::Error
641        } else if !self.warnings.is_empty() {
642            OperationStatus::Warning
643        } else {
644            OperationStatus::Ok
645        };
646    }
647
648    pub fn is_usable(&self) -> bool {
649        matches!(self.status, OperationStatus::Ok | OperationStatus::Warning)
650    }
651}