Skip to main content

brep_cam/program/
mod.rs

1//! Program assembly (lane C4, row 4): one NC program from ordered operations
2//! across setups.
3//!
4//! The [`Program`] is the single stream simulation and the post-processors
5//! read: every motion keeps its operation identity, and the [`Section`] list
6//! tells where each operation starts, which tool, work offset, spindle and
7//! coolant it runs with, and what its report said. Assembly inserts the tool
8//! changes, spindle and coolant switches and operation comments between the
9//! operations' own streams, and polishes each operation's motions exactly once
10//! on the way in ([`polish`]): the line filter within
11//! `LinkingParams::smoothing_tolerance` and arc fitting when
12//! `LinkingParams::fit_arcs`. Strategies hand over raw linked paths (their
13//! `link::finish` runs with filter 0 and no arcs); previews and simulation read
14//! the raw `OperationInput::toolpath`, the post reads `Program::motions`.
15
16use crate::mesh::MeshSnapshot;
17use crate::model::{
18    Coolant, Feed, Heights, Machine, Motion, MotionKind, Operation, OperationId, OperationReport, OperationStatus,
19    ProgramMotion, Setup, SetupId, SpindleDirection, StockToLeave, Strategy, Tool, ToolId, Toolpath, WorkOffset,
20};
21use crate::CamError;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24
25pub mod sheet;
26
27/// Length unit of the program's coordinates and feeds.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Units {
31    #[default]
32    Mm,
33    In,
34}
35
36impl Units {
37    pub fn as_str(&self) -> &'static str {
38        match self {
39            Units::Mm => "mm",
40            Units::In => "in",
41        }
42    }
43}
44
45/// One operation's toolpath with the definitions it was generated from.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct OperationInput {
49    pub operation: Operation,
50    pub tool: Tool,
51    pub toolpath: Toolpath,
52}
53
54/// One setup and its operations in machining order.
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct SetupInput {
58    pub setup: Setup,
59    pub operations: Vec<OperationInput>,
60}
61
62/// What the document hands to assembly.
63#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ProgramInput {
66    /// Program name (file stem, header comment).
67    #[serde(default)]
68    pub name: String,
69    /// Program number (`O1001`); the first setup's program info is the fallback.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub number: Option<u32>,
72    #[serde(default)]
73    pub units: Units,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub machine: Option<Machine>,
76    #[serde(default)]
77    pub setups: Vec<SetupInput>,
78    /// Seconds charged per tool change in the time estimate.
79    #[serde(default = "default_tool_change_seconds")]
80    pub tool_change_seconds: f64,
81    /// The complete protected mesh of each setup (part, fixtures, clamps) in
82    /// that setup's linked-path coordinates: what lane C18's polish hook
83    /// certifies smoothed arcs against. Only read for operations whose
84    /// `Operation::polish` enables something; such an operation in a setup
85    /// without a mesh is refused.
86    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87    pub protected: BTreeMap<SetupId, MeshSnapshot>,
88}
89
90fn default_tool_change_seconds() -> f64 {
91    5.0
92}
93
94/// What assembly did to one operation's motions on the way into the stream.
95#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Polish {
98    /// Chord tolerance of the line filter (`0`: filter off).
99    pub smoothing_tolerance: f64,
100    /// Deviation allowed between fitted arcs and the chords (`0`: arc fitting off).
101    pub arc_tolerance: f64,
102    /// Points the line filter removed.
103    pub points_removed: usize,
104    /// Arcs fitted (each replaces a run of straight cuts).
105    pub arcs_fitted: usize,
106    /// Lane C18's feed optimisation ran (corner and small-radius windows at
107    /// the reduced ratio) before the filter and the fitter.
108    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109    pub feed_optimization: bool,
110    /// Lane C18's certified smoothing ran at this tolerance; it is the last
111    /// geometry-changing step, so the line filter and the arc fitter above
112    /// were skipped (`smoothingTolerance` and `arcTolerance` stay 0).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub certified_smoothing: Option<f64>,
115}
116
117/// Where one operation sits in the program stream and how it runs.
118#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Section {
121    /// Zero-based section number.
122    pub index: usize,
123    pub operation: OperationId,
124    pub name: String,
125    /// Strategy name (the serde tag of the operation's strategy).
126    pub strategy: String,
127    pub setup: SetupId,
128    pub setup_name: String,
129    pub wcs: WorkOffset,
130    pub tool: ToolId,
131    pub tool_number: u32,
132    /// Length offset register (`H`).
133    pub length_offset: u32,
134    /// Diameter offset register (`D`).
135    pub diameter_offset: u32,
136    /// The tool differs from the previous section's (always true for the first).
137    pub tool_change: bool,
138    /// Spindle speed (the rpm limit when `css` is on).
139    pub spindle_rpm: f64,
140    pub spindle_direction: SpindleDirection,
141    /// Constant surface speed (turning): `surface_speed` in program units per minute.
142    #[serde(default)]
143    pub css: bool,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub surface_speed: Option<f64>,
146    /// The operation's cutting and plunge feeds (its override or the tool's).
147    #[serde(default)]
148    pub cutting_feed: f64,
149    #[serde(default)]
150    pub plunge_feed: f64,
151    pub coolant: Coolant,
152    pub heights: Heights,
153    pub tolerance: f64,
154    pub stock_to_leave: StockToLeave,
155    /// Index of the section's first motion in `Program::motions` (the comment).
156    pub first_motion: usize,
157    /// Number of motions in the section, including the inserted ones.
158    pub motion_count: usize,
159    pub cutting_length: f64,
160    /// Rough time estimate in seconds (lengths over feeds; no acceleration).
161    pub estimated_seconds: f64,
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub post_properties: BTreeMap<String, String>,
164    /// Filtering and arc fitting applied to this section's motions.
165    #[serde(default)]
166    pub polish: Polish,
167    pub report: OperationReport,
168}
169
170/// Totals over the program.
171#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct ProgramSummary {
174    pub motion_count: usize,
175    pub cutting_length: f64,
176    pub estimated_seconds: f64,
177    pub tool_changes: usize,
178    pub warning_count: usize,
179    /// Points removed by the line filter over all sections.
180    #[serde(default)]
181    pub points_removed: usize,
182    /// Arcs fitted over all sections.
183    #[serde(default)]
184    pub arcs_fitted: usize,
185}
186
187/// The assembled program: one motion stream plus its sections.
188#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct Program {
191    pub name: String,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub number: Option<u32>,
194    #[serde(default)]
195    pub comment: String,
196    pub units: Units,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub machine: Option<Machine>,
199    /// Setups in program order.
200    pub setups: Vec<Setup>,
201    /// Tools in order of first use, each once.
202    pub tools: Vec<Tool>,
203    pub sections: Vec<Section>,
204    pub motions: Vec<ProgramMotion>,
205    pub summary: ProgramSummary,
206}
207
208impl Program {
209    /// The motions of one section.
210    pub fn section_motions(&self, section: &Section) -> &[ProgramMotion] {
211        &self.motions[section.first_motion..section.first_motion + section.motion_count]
212    }
213}
214
215fn invalid(m: String) -> CamError {
216    CamError::InvalidInput(m)
217}
218
219/// The spindle as the stream last set it.
220#[derive(Clone, Copy, Debug, PartialEq)]
221enum SpindleState {
222    Rpm(f64, SpindleDirection),
223    /// Surface speed (program units per minute), rpm limit, direction.
224    Css(f64, f64, SpindleDirection),
225}
226
227/// Straight-line length of a motion from `from` (arcs by their true length).
228pub fn motion_length(from: Option<crate::model::Point3>, m: &Motion) -> f64 {
229    let Some(to) = m.end() else { return 0.0 };
230    let Some(from) = from else { return 0.0 };
231    match &m.kind {
232        MotionKind::ArcCw { center, plane, pitch, .. } | MotionKind::ArcCcw { center, plane, pitch, .. } => {
233            let cw = matches!(m.kind, MotionKind::ArcCw { .. });
234            match crate::arcfit::arc_sweep(from, to, *center, *plane, cw) {
235                Some((sweep, radius)) => {
236                    let arc = sweep * radius;
237                    let rise = pitch * sweep / std::f64::consts::TAU;
238                    arc.hypot(rise)
239                }
240                None => from.distance(to),
241            }
242        }
243        MotionKind::Cycle { .. } => from.distance_xy(to),
244        _ => from.distance(to),
245    }
246}
247
248/// Polish one operation's motions for the program stream: the line filter
249/// within `linking.smoothing_tolerance` (off at `0`), then arc fitting when
250/// `linking.fit_arcs`, within the smoothing tolerance when it is set and the
251/// operation's machining tolerance otherwise. Fitting touches only runs of
252/// straight cuts, so a stream that was polished before passes through
253/// unchanged with zero counts; arcs a strategy emitted (helical ramps) stay.
254///
255/// The deviation budget is honest but unproven here: a fitted arc stays
256/// within `arc_tolerance` of chords that the strategy proved within the
257/// operation tolerance of the surface; assembly has no mesh to re-prove the
258/// arcs against.
259pub fn polish(op: &Operation, motions: &[Motion]) -> Result<(Vec<Motion>, Polish), CamError> {
260    let linking = &op.linking;
261    let wrap = |e: CamError| invalid(format!("program: operation {}: {e}", op.id));
262    let mut out = motions.to_vec();
263    let mut polish = Polish::default();
264    if linking.smoothing_tolerance > 0.0 {
265        let (filtered, removed) = crate::filter::filter_motions(&out, linking.smoothing_tolerance).map_err(wrap)?;
266        out = filtered;
267        polish.smoothing_tolerance = linking.smoothing_tolerance;
268        polish.points_removed = removed;
269    }
270    if linking.fit_arcs {
271        let tolerance = if linking.smoothing_tolerance > 0.0 { linking.smoothing_tolerance } else { op.tolerance };
272        let opts = crate::arcfit::ArcFitOptions::with_tolerance(tolerance);
273        let (fitted, count) = crate::arcfit::fit_motions(&out, &opts).map_err(wrap)?;
274        out = fitted;
275        polish.arc_tolerance = tolerance;
276        polish.arcs_fitted = count;
277    }
278    Ok((out, polish))
279}
280
281/// Assemble the program. Disabled operations are skipped; an operation whose
282/// toolpath is not usable (pending, failed or cancelled) is an error, never
283/// silently omitted. Tools are keyed by id; two tools with one id must be
284/// identical.
285pub fn assemble(input: &ProgramInput) -> Result<Program, CamError> {
286    let mut sink = crate::NullSink;
287    assemble_with(input, &mut crate::Runner::detached(&mut sink))
288}
289
290/// `assemble` with the caller's runner: lane C18's polish hook (feed
291/// optimisation, certified smoothing) reports progress and honours
292/// cancellation through it; nothing else in assembly does work worth a token.
293pub fn assemble_with(input: &ProgramInput, runner: &mut crate::Runner<'_>) -> Result<Program, CamError> {
294    if !input.tool_change_seconds.is_finite() || input.tool_change_seconds < 0.0 {
295        return Err(invalid("program: tool change seconds must be finite and non-negative".into()));
296    }
297    if let Some(m) = &input.machine {
298        m.validate().map_err(|e| invalid(format!("program: machine: {e}")))?;
299    }
300    let rapid_rate = input.machine.as_ref().map_or(30_000.0, |m| m.feed.rapid);
301    let mut program = Program {
302        name: input.name.clone(),
303        number: input.number.or_else(|| input.setups.first().and_then(|s| s.setup.program.as_ref()).and_then(|p| p.number)),
304        comment: input.setups.first().and_then(|s| s.setup.program.as_ref()).map(|p| p.comment.clone()).unwrap_or_default(),
305        units: input.units,
306        machine: input.machine.clone(),
307        ..Default::default()
308    };
309    let mut tools: BTreeMap<ToolId, Tool> = BTreeMap::new();
310    let mut tool_order: Vec<ToolId> = Vec::new();
311    let mut current_tool: Option<ToolId> = None;
312    let mut current_spindle: Option<SpindleState> = None;
313    let mut current_coolant = Coolant::Off;
314    let mut seen_ops: Vec<OperationId> = Vec::new();
315    for setup_input in &input.setups {
316        let setup = &setup_input.setup;
317        setup.heights.validate().map_err(|e| invalid(format!("program: setup {}: {e}", setup.id)))?;
318        if program.setups.iter().any(|s| s.id == setup.id) {
319            return Err(invalid(format!("program: setup {} appears twice", setup.id)));
320        }
321        program.setups.push(setup.clone());
322        for op_input in &setup_input.operations {
323            let op = &op_input.operation;
324            if !op.enabled {
325                continue;
326            }
327            if op.setup != setup.id {
328                return Err(invalid(format!("program: operation {} belongs to setup {}, not {}", op.id, op.setup, setup.id)));
329            }
330            if seen_ops.contains(&op.id) {
331                return Err(invalid(format!("program: operation {} appears twice", op.id)));
332            }
333            seen_ops.push(op.id.clone());
334            let tool = &op_input.tool;
335            if tool.id != op.tool || op_input.toolpath.tool != tool.id || op_input.toolpath.operation != op.id {
336                return Err(invalid(format!("program: operation {} and its tool/toolpath identities disagree", op.id)));
337            }
338            match tools.get(&tool.id) {
339                Some(existing) if existing != tool => {
340                    return Err(invalid(format!("program: tool {} is defined twice with different definitions", tool.id)));
341                }
342                Some(_) => {}
343                None => {
344                    tools.insert(tool.id.clone(), tool.clone());
345                    tool_order.push(tool.id.clone());
346                }
347            }
348            let path = &op_input.toolpath;
349            if !path.report.is_usable() {
350                return Err(invalid(format!("program: operation {} has no usable toolpath ({:?})", op.id, path.report.status)));
351            }
352            if let Some(i) = path.first_non_finite() {
353                return Err(invalid(format!("program: operation {} motion {i} is not finite", op.id)));
354            }
355            let feeds = op.feeds.as_ref().unwrap_or(&tool.feeds);
356            if matches!(op.strategy, Strategy::ManualNc(_)) {
357                // Lane C15's manual NC entries: exactly the toolpath's own
358                // entries, in order. No section comment, no implicit tool,
359                // spindle or coolant state, no polish. An explicit tool change
360                // is tracked like a machine would see it (it stops the
361                // spindle and the coolant); a stop does the same on most
362                // controls (Fanuc/Haas M00, an active M01), so the next
363                // machining operation restarts them.
364                if path.motions.is_empty() {
365                    return Err(invalid(format!("program: manual NC operation {} has no entries", op.id)));
366                }
367                let first_motion = program.motions.len();
368                let mut tool_change = false;
369                let mut seconds = 0.0;
370                for m in &path.motions {
371                    match &m.kind {
372                        MotionKind::Comment { .. } | MotionKind::PassThrough { .. } => {}
373                        MotionKind::Stop { .. } => {
374                            current_spindle = None;
375                            current_coolant = Coolant::Off;
376                        }
377                        MotionKind::Dwell { seconds: s } => seconds += s,
378                        MotionKind::ToolChange { tool: t } => {
379                            if *t != tool.id {
380                                return Err(invalid(format!(
381                                    "program: manual NC operation {}: the tool change names {t}, not the operation's tool {} (the program carries only that definition)",
382                                    op.id, tool.id
383                                )));
384                            }
385                            tool_change = true;
386                            seconds += input.tool_change_seconds;
387                            current_tool = Some(t.clone());
388                            current_spindle = None;
389                            current_coolant = Coolant::Off;
390                        }
391                        other => {
392                            return Err(invalid(format!(
393                                "program: manual NC operation {} carries a {} motion; manual entries are stop, dwell, comment, pass-through and tool change",
394                                op.id,
395                                other.kind_name()
396                            )));
397                        }
398                    }
399                    program.motions.push(ProgramMotion { operation: op.id.clone(), motion: m.clone() });
400                }
401                let (spindle_rpm, spindle_direction) = match current_spindle {
402                    Some(SpindleState::Rpm(rpm, direction)) => (rpm, direction),
403                    Some(SpindleState::Css(_, max, direction)) => (max, direction),
404                    None => (0.0, SpindleDirection::default()),
405                };
406                program.sections.push(Section {
407                    index: program.sections.len(),
408                    operation: op.id.clone(),
409                    name: op.name.clone(),
410                    strategy: strategy_name(op),
411                    setup: setup.id.clone(),
412                    setup_name: setup.name.clone(),
413                    wcs: setup.wcs.clone(),
414                    tool: tool.id.clone(),
415                    tool_number: tool.number,
416                    length_offset: tool.length_offset.unwrap_or(tool.number),
417                    diameter_offset: tool.diameter_offset.unwrap_or(tool.number),
418                    tool_change,
419                    spindle_rpm,
420                    spindle_direction,
421                    css: matches!(current_spindle, Some(SpindleState::Css(..))),
422                    surface_speed: match current_spindle {
423                        Some(SpindleState::Css(v, _, _)) => Some(v),
424                        _ => None,
425                    },
426                    cutting_feed: feeds.cutting_feed,
427                    plunge_feed: feeds.plunge_feed,
428                    coolant: current_coolant,
429                    heights: op.heights.clone().unwrap_or_else(|| setup.heights.clone()),
430                    tolerance: op.tolerance,
431                    stock_to_leave: op.stock_to_leave.clone(),
432                    first_motion,
433                    motion_count: program.motions.len() - first_motion,
434                    cutting_length: 0.0,
435                    estimated_seconds: seconds,
436                    post_properties: op.post_properties.clone(),
437                    polish: Polish::default(),
438                    report: path.report.clone(),
439                });
440                continue;
441            }
442            feeds.validate().map_err(|e| invalid(format!("program: operation {}: {e}", op.id)))?;
443            op.linking.validate().map_err(|e| invalid(format!("program: operation {}: linking: {e}", op.id)))?;
444            if !op.tolerance.is_finite() || op.tolerance <= 0.0 {
445                return Err(invalid(format!("program: operation {}: tolerance must be finite and positive", op.id)));
446            }
447            // Lane C18's hook first (feed optimisation, then certified
448            // smoothing), on the relinked path, once; then this crate's
449            // line filter and arc fitter, unless certified smoothing ran:
450            // it is the last geometry-changing step and nothing unchecked
451            // may follow it.
452            let (polished, polish) = if op.polish.feed_optimization.enabled || op.polish.smoothing.enabled {
453                op.polish.validate().map_err(|e| invalid(format!("program: operation {}: polish: {e}", op.id)))?;
454                // Only certified smoothing reads the protected mesh; feed
455                // optimisation changes feeds, not geometry, and needs none.
456                let empty = MeshSnapshot::empty();
457                let mesh = match input.protected.get(&setup.id) {
458                    Some(mesh) => mesh,
459                    None if !op.polish.smoothing.enabled => &empty,
460                    None => {
461                        return Err(invalid(format!(
462                            "program: operation {}: certified smoothing needs the complete protected mesh of setup {} (ProgramInput::protected)",
463                            op.id, setup.id
464                        )));
465                    }
466                };
467                let applied = crate::polish::apply(path, op, tool, mesh, crate::Tolerance::default(), runner)
468                    .map_err(|e| invalid(format!("program: operation {}: {e}", op.id)))?;
469                if op.polish.smoothing.enabled {
470                    let polish = Polish { feed_optimization: op.polish.feed_optimization.enabled, certified_smoothing: Some(op.polish.smoothing.tolerance), ..Default::default() };
471                    (applied.motions, polish)
472                } else {
473                    let (out, mut polish) = polish(op, &applied.motions)?;
474                    polish.feed_optimization = true;
475                    (out, polish)
476                }
477            } else {
478                polish(op, &path.motions)?
479            };
480            let first_motion = program.motions.len();
481            let mut push = |m: Motion| program.motions.push(ProgramMotion { operation: op.id.clone(), motion: m });
482            push(Motion::new(MotionKind::Comment { text: op.name.clone() }, Feed::RAPID));
483            let tool_change = current_tool.as_ref() != Some(&tool.id);
484            if tool_change {
485                push(Motion::new(MotionKind::ToolChange { tool: tool.id.clone() }, Feed::RAPID));
486                current_tool = Some(tool.id.clone());
487                // A tool change stops the spindle and coolant on the machine.
488                current_spindle = None;
489                current_coolant = Coolant::Off;
490            }
491            // The spindle the operation wants: rpm, or constant surface speed
492            // with its rpm limit (turning). A toolpath that sets the spindle
493            // itself before its first move (turning streams do) is left alone.
494            let wanted = if feeds.css {
495                let (Some(v), Some(max)) = (feeds.surface_speed, feeds.surface_speed_limit_rpm) else {
496                    return Err(invalid(format!("program: operation {}: constant surface speed needs a surface speed and an rpm limit", op.id)));
497                };
498                SpindleState::Css(v, max, feeds.spindle_direction)
499            } else {
500                SpindleState::Rpm(feeds.spindle_rpm, feeds.spindle_direction)
501            };
502            let prefix: Vec<&Motion> = path.motions.iter().take_while(|m| m.end().is_none()).collect();
503            let own = prefix.iter().rev().find_map(|m| match &m.kind {
504                MotionKind::SpindleOn { rpm, direction } => Some(SpindleState::Rpm(*rpm, *direction)),
505                MotionKind::SpindleSurfaceSpeed { surface_speed, max_rpm, direction } => Some(SpindleState::Css(*surface_speed, *max_rpm, *direction)),
506                _ => None,
507            });
508            match own {
509                Some(state) => current_spindle = Some(state),
510                None => {
511                    if current_spindle != Some(wanted) {
512                        push(match wanted {
513                            SpindleState::Rpm(rpm, direction) => Motion::new(MotionKind::SpindleOn { rpm, direction }, Feed::RAPID),
514                            SpindleState::Css(surface_speed, max_rpm, direction) => Motion::new(MotionKind::SpindleSurfaceSpeed { surface_speed, max_rpm, direction }, Feed::RAPID),
515                        });
516                        current_spindle = Some(wanted);
517                    }
518                }
519            }
520            let spindle = match current_spindle.expect("set above") {
521                SpindleState::Rpm(rpm, direction) => (rpm, direction),
522                SpindleState::Css(_, max, direction) => (max, direction),
523            };
524            if current_coolant != tool.coolant {
525                push(Motion::new(MotionKind::Coolant { mode: tool.coolant }, Feed::RAPID));
526                current_coolant = tool.coolant;
527            }
528            // Time and length over the operation's own motions.
529            let mut at = None;
530            let mut seconds = if tool_change { input.tool_change_seconds } else { 0.0 };
531            let mut cutting = 0.0;
532            for m in &polished {
533                // A canned turning thread: every pass along the thread at the
534                // synchronized feed, the clearance travel at rapid.
535                let thread = match &m.kind {
536                    MotionKind::TurnThreadCycle { cycle } => {
537                        let passes = (cycle.depths.len() + cycle.spring_passes as usize) as f64;
538                        let along = (cycle.start.z - cycle.end.z).abs() * passes;
539                        let x = cycle.start.x + if cycle.internal { 1.0 } else { -1.0 } * cycle.depths.last().copied().unwrap_or(0.0);
540                        let across = 2.0 * (cycle.clearance_x - x).abs() * passes;
541                        Some((along, across))
542                    }
543                    _ => None,
544                };
545                let len = match thread {
546                    Some((along, _)) => along,
547                    None => motion_length(at, m),
548                };
549                if m.kind.is_cutting() {
550                    cutting += len;
551                }
552                if let Some((_, across)) = thread {
553                    seconds += across / rapid_rate * 60.0;
554                }
555                let rate = match m.feed {
556                    Feed::Rapid(_) => rapid_rate,
557                    Feed::Rate(r) => r.max(1e-9),
558                };
559                seconds += len / rate * 60.0;
560                match &m.kind {
561                    MotionKind::Dwell { seconds: s } => seconds += s,
562                    MotionKind::Cycle { at: p, cycle } => {
563                        // Feed down and back up at the operation's plunge feed.
564                        let depth = (p.z - cycle.depth_z()).abs() + (cycle.retract_z() - cycle.depth_z()).abs();
565                        seconds += depth / feeds.plunge_feed.max(1e-9) * 60.0;
566                    }
567                    _ => {}
568                }
569                if let Some(p) = m.end() {
570                    at = Some(p);
571                }
572                push(m.clone());
573            }
574            let count = program.motions.len() - first_motion;
575            program.sections.push(Section {
576                index: program.sections.len(),
577                operation: op.id.clone(),
578                name: op.name.clone(),
579                strategy: strategy_name(op),
580                setup: setup.id.clone(),
581                setup_name: setup.name.clone(),
582                wcs: setup.wcs.clone(),
583                tool: tool.id.clone(),
584                tool_number: tool.number,
585                length_offset: tool.length_offset.unwrap_or(tool.number),
586                diameter_offset: tool.diameter_offset.unwrap_or(tool.number),
587                tool_change,
588                spindle_rpm: spindle.0,
589                spindle_direction: spindle.1,
590                css: matches!(current_spindle, Some(SpindleState::Css(..))),
591                surface_speed: match current_spindle {
592                    Some(SpindleState::Css(v, _, _)) => Some(v),
593                    _ => None,
594                },
595                cutting_feed: feeds.cutting_feed,
596                plunge_feed: feeds.plunge_feed,
597                coolant: tool.coolant,
598                heights: op.heights.clone().unwrap_or_else(|| setup.heights.clone()),
599                tolerance: op.tolerance,
600                stock_to_leave: op.stock_to_leave.clone(),
601                first_motion,
602                motion_count: count,
603                cutting_length: cutting,
604                estimated_seconds: seconds,
605                post_properties: op.post_properties.clone(),
606                polish,
607                report: path.report.clone(),
608            });
609        }
610    }
611    if program.sections.is_empty() {
612        return Err(invalid("program: no enabled operations with a toolpath".into()));
613    }
614    // Close: the last section ends spindle and coolant.
615    let last_op = program.sections.last().map(|s| s.operation.clone()).expect("a section exists");
616    if current_coolant != Coolant::Off {
617        program.motions.push(ProgramMotion { operation: last_op.clone(), motion: Motion::new(MotionKind::Coolant { mode: Coolant::Off }, Feed::RAPID) });
618    }
619    program.motions.push(ProgramMotion { operation: last_op, motion: Motion::new(MotionKind::SpindleOff, Feed::RAPID) });
620    if let Some(last) = program.sections.last_mut() {
621        last.motion_count = program.motions.len() - last.first_motion;
622    }
623    program.tools = tool_order.iter().map(|id| tools[id].clone()).collect();
624    program.summary = ProgramSummary {
625        motion_count: program.motions.len(),
626        cutting_length: program.sections.iter().map(|s| s.cutting_length).sum(),
627        estimated_seconds: program.sections.iter().map(|s| s.estimated_seconds).sum(),
628        tool_changes: program.sections.iter().filter(|s| s.tool_change).count(),
629        warning_count: program.sections.iter().map(|s| s.report.warnings.len()).sum(),
630        points_removed: program.sections.iter().map(|s| s.polish.points_removed).sum(),
631        arcs_fitted: program.sections.iter().map(|s| s.polish.arcs_fitted).sum(),
632    };
633    let _ = OperationStatus::Ok;
634    Ok(program)
635}
636
637/// The strategy's serde tag, by serializing just the tag.
638pub fn strategy_name(op: &Operation) -> String {
639    #[derive(Deserialize)]
640    struct Tag {
641        kind: String,
642    }
643    serde_json::to_value(&op.strategy)
644        .ok()
645        .and_then(|v| serde_json::from_value::<Tag>(v).ok())
646        .map(|t| t.kind)
647        .unwrap_or_else(|| "unknown".into())
648}
649