Skip to main content

brep_cam/model/
operation.rs

1//! Operations: one typed parameter set per strategy.
2//!
3//! `Strategy` is `#[non_exhaustive]`: consumers outside this crate match with
4//! a wildcard arm. Inside the crate every match is exhaustive, so adding a
5//! variant is a deliberate change reviewed with the lanes that match on it.
6//! Every parameter struct derives `Default` and tags every field
7//! `#[serde(default)]` so documents saved before a field existed still load.
8
9use super::{FeedsSpeeds, Heights, OperationId, Point3, SetupId, TargetSelection, ToolId, WorkOffset};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13/// One machining operation inside a setup.
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct Operation {
18    pub id: OperationId,
19    #[serde(default)]
20    pub name: String,
21    pub setup: SetupId,
22    pub tool: ToolId,
23    /// Disabled operations are kept but neither generated nor posted.
24    #[serde(default = "default_true")]
25    pub enabled: bool,
26    #[serde(default)]
27    pub targets: TargetSelection,
28    pub strategy: Strategy,
29    /// Machining tolerance (chordal deviation of the path from the exact surface).
30    #[serde(default = "default_tolerance")]
31    pub tolerance: f64,
32    #[serde(default)]
33    pub stock_to_leave: StockToLeave,
34    /// Overrides the tool's defaults when set.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub feeds: Option<FeedsSpeeds>,
37    /// Overrides the setup's heights when set.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub heights: Option<Heights>,
40    #[serde(default)]
41    pub linking: LinkingParams,
42    /// Fixed tool orientation for 3+2 work, in SETUP machine coordinates (`None`: +Z).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub tool_axis: Option<Point3>,
45    /// None selects live tooling automatically for milling on a mill-turn machine.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub live_tool: Option<bool>,
48    /// Post-processor properties specific to this operation (free-form, ordered).
49    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
50    pub post_properties: BTreeMap<String, String>,
51    /// Feed optimisation and smoothing applied after generation (lane C18); off by default.
52    #[serde(default)]
53    pub polish: PolishParams,
54}
55
56fn default_true() -> bool {
57    true
58}
59
60fn default_tolerance() -> f64 {
61    0.01
62}
63
64impl Default for Operation {
65    fn default() -> Self {
66        Self::new(OperationId::default(), "", SetupId::default(), ToolId::default(), Strategy::default())
67    }
68}
69
70impl Operation {
71    pub fn new(
72        id: impl Into<OperationId>,
73        name: impl Into<String>,
74        setup: impl Into<SetupId>,
75        tool: impl Into<ToolId>,
76        strategy: Strategy,
77    ) -> Self {
78        Self {
79            id: id.into(),
80            name: name.into(),
81            setup: setup.into(),
82            tool: tool.into(),
83            enabled: true,
84            targets: TargetSelection::default(),
85            strategy,
86            tolerance: default_tolerance(),
87            stock_to_leave: StockToLeave::default(),
88            feeds: None,
89            heights: None,
90            linking: LinkingParams::default(),
91            tool_axis: None,
92            live_tool: None,
93            post_properties: BTreeMap::new(),
94            polish: PolishParams::default(),
95        }
96    }
97
98    pub fn validate(&self) -> Result<(), String> {
99        if !self.tolerance.is_finite() || self.tolerance <= 0.0 {
100            return Err("machining tolerance must be finite and positive".into());
101        }
102        self.targets.validate()?;
103        if self.enabled
104            && self.targets.target_solids.is_empty()
105            && self.targets.chains.is_empty()
106            && !self.strategy.is_turning()
107            && !self.strategy.is_targetless()
108        {
109            return Err("an enabled operation needs at least one target body".into());
110        }
111        if let Some(axis) = self.tool_axis {
112            if axis.normalized().is_none() {
113                return Err("tool axis must be a non-zero vector".into());
114            }
115        }
116        if let Some(f) = &self.feeds {
117            f.validate()?;
118        }
119        if let Some(h) = &self.heights {
120            h.validate()?;
121        }
122        self.stock_to_leave.validate()?;
123        self.linking.validate()?;
124        self.polish.validate()?;
125        self.strategy.validate()
126    }
127}
128
129/// One enum-valued JSON field: its dotted path and the names it may take.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub struct EnumChoice {
132    pub path: &'static str,
133    pub variants: &'static [&'static str],
134}
135
136impl Operation {
137    /// Enum-valued fields of an operation's JSON outside `strategy`.
138    pub const ENUM_CHOICES: &'static [EnumChoice] = &[
139        EnumChoice { path: "linking.retractPolicy", variants: RetractPolicy::VARIANTS },
140        EnumChoice { path: "linking.leadIn.kind", variants: LeadKind::VARIANTS },
141        EnumChoice { path: "linking.leadOut.kind", variants: LeadKind::VARIANTS },
142        EnumChoice { path: "linking.ramp.kind", variants: RampKind::VARIANTS },
143        EnumChoice { path: "feeds.spindleDirection", variants: super::SpindleDirection::VARIANTS },
144        EnumChoice { path: "targets.chains[].source.kind", variants: super::ChainSource::VARIANTS },
145        EnumChoice { path: "targets.chains[].side", variants: super::ChainSide::VARIANTS },
146    ];
147}
148
149/// Feed optimisation and smoothing of a generated toolpath (lane C18).
150/// Disabled settings are inert: their values are not validated.
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154#[serde(default)]
155pub struct PolishParams {
156    pub feed_optimization: FeedOptimizationParams,
157    pub smoothing: SmoothingParams,
158}
159
160impl PolishParams {
161    pub fn validate(&self) -> Result<(), String> {
162        self.feed_optimization.validate()?;
163        self.smoothing.validate()
164    }
165}
166
167/// Slow the feed at corners (travel direction change above `max_angle_deg`,
168/// either turn sense) and through arcs of radius up to `max_radius`, over
169/// `distance` of path length on either side, to `reduced_feed_ratio` of the
170/// cutting feed.
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174#[serde(default)]
175pub struct FeedOptimizationParams {
176    pub enabled: bool,
177    pub max_angle_deg: f64,
178    pub max_radius: f64,
179    pub reduced_feed_ratio: f64,
180    pub distance: f64,
181}
182
183impl Default for FeedOptimizationParams {
184    fn default() -> Self {
185        Self { enabled: false, max_angle_deg: 30.0, max_radius: 2.0, reduced_feed_ratio: 0.5, distance: 2.0 }
186    }
187}
188
189impl FeedOptimizationParams {
190    pub fn validate(&self) -> Result<(), String> {
191        if !self.enabled {
192            return Ok(());
193        }
194        if !self.max_angle_deg.is_finite() || self.max_angle_deg < 0.0 || self.max_angle_deg > 180.0 {
195            return Err("feed optimisation angle must be finite and within [0, 180] degrees".into());
196        }
197        positive_or_zero("feed optimisation radius", self.max_radius)?;
198        if !self.reduced_feed_ratio.is_finite() || self.reduced_feed_ratio <= 0.0 || self.reduced_feed_ratio > 1.0 {
199            return Err("reduced feed ratio must be within (0, 1]".into());
200        }
201        positive_or_zero("feed optimisation distance", self.distance)
202    }
203}
204
205/// Smooth the path within `tolerance`.
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209#[serde(default)]
210pub struct SmoothingParams {
211    pub enabled: bool,
212    pub tolerance: f64,
213}
214
215impl Default for SmoothingParams {
216    fn default() -> Self {
217        Self { enabled: false, tolerance: 0.01 }
218    }
219}
220
221impl SmoothingParams {
222    pub fn validate(&self) -> Result<(), String> {
223        if self.enabled {
224            positive("smoothing tolerance", self.tolerance)?;
225        }
226        Ok(())
227    }
228}
229
230/// Material intentionally left for a later operation.
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234#[serde(default)]
235pub struct StockToLeave {
236    pub radial: f64,
237    pub axial: f64,
238}
239
240impl StockToLeave {
241    pub fn validate(&self) -> Result<(), String> {
242        if !self.radial.is_finite() || !self.axial.is_finite() {
243            return Err("stock to leave is not finite".into());
244        }
245        Ok(())
246    }
247}
248
249/// How passes are connected and how the tool enters and leaves material.
250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase")]
253#[serde(default)]
254pub struct LinkingParams {
255    pub retract_policy: RetractPolicy,
256    /// Links shorter than this stay down at feed instead of retracting.
257    pub keep_down_distance: f64,
258    pub lead_in: Lead,
259    pub lead_out: Lead,
260    pub ramp: Ramp,
261    /// Fit arcs (G2/G3) to the output where the path allows.
262    pub fit_arcs: bool,
263    /// Smooth the path within this deviation (`0`: off).
264    pub smoothing_tolerance: f64,
265    /// Minimum distance kept above checked material by minimum retracts and feed heights.
266    pub safe_distance: f64,
267}
268
269impl Default for LinkingParams {
270    fn default() -> Self {
271        Self {
272            retract_policy: RetractPolicy::MinimumRetract,
273            keep_down_distance: 0.0,
274            lead_in: Lead::default(),
275            lead_out: Lead::default(),
276            ramp: Ramp::default(),
277            fit_arcs: true,
278            smoothing_tolerance: 0.0,
279            safe_distance: 2.0,
280        }
281    }
282}
283
284impl LinkingParams {
285    pub fn validate(&self) -> Result<(), String> {
286        if !self.keep_down_distance.is_finite() || self.keep_down_distance < 0.0 {
287            return Err("keep-down distance must be finite and non-negative".into());
288        }
289        if !self.smoothing_tolerance.is_finite() || self.smoothing_tolerance < 0.0 {
290            return Err("smoothing tolerance must be finite and non-negative".into());
291        }
292        if !self.safe_distance.is_finite() || self.safe_distance < 0.0 {
293            return Err("safe distance must be finite and non-negative".into());
294        }
295        self.lead_in.validate()?;
296        self.lead_out.validate()?;
297        self.ramp.validate()
298    }
299}
300
301/// Where the tool retracts to between passes.
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
304#[serde(rename_all = "kebab-case")]
305pub enum RetractPolicy {
306    /// Always up to the clearance height.
307    FullRetract,
308    /// Up to the retract height.
309    RetractHeight,
310    /// Only as high as needed to clear checked material (plus feed height).
311    #[default]
312    MinimumRetract,
313}
314
315impl RetractPolicy {
316    /// Serde names of every variant, in declaration order.
317    pub const VARIANTS: &'static [&'static str] = &["full-retract", "retract-height", "minimum-retract"];
318}
319
320/// Lead-in / lead-out geometry.
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324#[serde(default)]
325pub struct Lead {
326    pub kind: LeadKind,
327    /// Arc radius or linear length.
328    pub radius: f64,
329    /// Arc sweep in degrees (arc leads).
330    pub sweep_deg: f64,
331    /// Straight extension before the arc.
332    pub linear_length: f64,
333    /// Vertical component of the lead.
334    pub vertical_radius: f64,
335}
336
337impl Default for Lead {
338    fn default() -> Self {
339        Self { kind: LeadKind::None, radius: 0.0, sweep_deg: 90.0, linear_length: 0.0, vertical_radius: 0.0 }
340    }
341}
342
343impl Lead {
344    pub fn validate(&self) -> Result<(), String> {
345        for (name, v) in [
346            ("lead radius", self.radius),
347            ("lead sweep", self.sweep_deg),
348            ("lead linear length", self.linear_length),
349            ("lead vertical radius", self.vertical_radius),
350        ] {
351            if !v.is_finite() || v < 0.0 {
352                return Err(format!("{name} must be finite and non-negative"));
353            }
354        }
355        Ok(())
356    }
357}
358
359#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
360#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
361#[serde(rename_all = "kebab-case")]
362pub enum LeadKind {
363    #[default]
364    None,
365    Arc,
366    Linear,
367    Vertical,
368}
369
370impl LeadKind {
371    /// Serde names of every variant, in declaration order.
372    pub const VARIANTS: &'static [&'static str] = &["none", "arc", "linear", "vertical"];
373}
374
375/// How the tool descends into material.
376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
377#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379#[serde(default)]
380pub struct Ramp {
381    pub kind: RampKind,
382    /// Maximum ramp angle in degrees.
383    pub angle_deg: f64,
384    /// Helix diameter (helix ramps).
385    pub helix_diameter: f64,
386    /// Clearance above the floor where the ramp levels out.
387    pub clearance: f64,
388}
389
390impl Default for Ramp {
391    fn default() -> Self {
392        Self { kind: RampKind::Plunge, angle_deg: 2.0, helix_diameter: 0.0, clearance: 0.0 }
393    }
394}
395
396impl Ramp {
397    pub fn validate(&self) -> Result<(), String> {
398        if !self.angle_deg.is_finite() || self.angle_deg < 0.0 || self.angle_deg > 90.0 {
399            return Err("ramp angle must be between 0 and 90 degrees".into());
400        }
401        if !self.helix_diameter.is_finite() || self.helix_diameter < 0.0 {
402            return Err("helix diameter must be finite and non-negative".into());
403        }
404        if !self.clearance.is_finite() || self.clearance < 0.0 {
405            return Err("ramp clearance must be finite and non-negative".into());
406        }
407        Ok(())
408    }
409}
410
411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
412#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
413#[serde(rename_all = "kebab-case")]
414pub enum RampKind {
415    #[default]
416    Plunge,
417    Helix,
418    Profile,
419    ZigZag,
420    Smooth,
421}
422
423impl RampKind {
424    /// Serde names of every variant, in declaration order.
425    pub const VARIANTS: &'static [&'static str] = &["plunge", "helix", "profile", "zig-zag", "smooth"];
426}
427
428/// Depth control shared by 2.5D and roughing strategies. Heights are MACHINE Z
429/// unless `relative_to_stock` is set.
430#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
431#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433#[serde(default)]
434pub struct Depths {
435    /// Top of cut (`None`: stock top).
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub top: Option<f64>,
438    /// Bottom of cut (`None`: model bottom / selected floor).
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub bottom: Option<f64>,
441    /// Maximum roughing step-down (`0`: one depth).
442    pub step_down: f64,
443    /// Finishing step-down applied to the last `finishing_passes` depths.
444    pub finishing_step_down: f64,
445    pub finishing_passes: u32,
446    /// Order: every depth of one region before moving on (`true`) or every
447    /// region at one depth before the next depth (`false`).
448    pub depth_first: bool,
449}
450
451impl Default for Depths {
452    fn default() -> Self {
453        Self {
454            top: None,
455            bottom: None,
456            step_down: 0.0,
457            finishing_step_down: 0.0,
458            finishing_passes: 0,
459            depth_first: true,
460        }
461    }
462}
463
464impl Depths {
465    pub fn validate(&self) -> Result<(), String> {
466        for (name, v) in [("step down", self.step_down), ("finishing step down", self.finishing_step_down)] {
467            if !v.is_finite() || v < 0.0 {
468                return Err(format!("{name} must be finite and non-negative"));
469            }
470        }
471        if let (Some(t), Some(b)) = (self.top, self.bottom) {
472            if !t.is_finite() || !b.is_finite() || t < b {
473                return Err("top of cut must be above bottom of cut".into());
474            }
475        }
476        Ok(())
477    }
478}
479
480/// Radial stepover between adjacent passes.
481#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
482#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484#[serde(default)]
485pub struct Stepover {
486    /// Distance between passes in model units.
487    pub distance: f64,
488    /// Climb (default) or conventional milling.
489    pub direction: CutDirection,
490}
491
492impl Default for Stepover {
493    fn default() -> Self {
494        Self { distance: 1.0, direction: CutDirection::Climb }
495    }
496}
497
498impl Stepover {
499    pub fn validate(&self) -> Result<(), String> {
500        if !self.distance.is_finite() || self.distance <= 0.0 {
501            return Err("stepover must be finite and positive".into());
502        }
503        Ok(())
504    }
505}
506
507#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
508#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
509#[serde(rename_all = "kebab-case")]
510pub enum CutDirection {
511    #[default]
512    Climb,
513    Conventional,
514    /// Alternate every pass (zig-zag).
515    Either,
516}
517
518impl CutDirection {
519    /// Serde names of every variant, in declaration order.
520    pub const VARIANTS: &'static [&'static str] = &["climb", "conventional", "either"];
521}
522
523/// Which way a boundary limits the toolpath.
524#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
525#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
526#[serde(rename_all = "kebab-case")]
527pub enum ContainmentMode {
528    /// Tool centre stays inside the boundary.
529    #[default]
530    ToolCenterInside,
531    /// Tool stays fully inside the boundary.
532    ToolInside,
533    /// Tool may reach outside until its far side touches the boundary.
534    ToolOutside,
535}
536
537impl ContainmentMode {
538    /// Serde names of every variant, in declaration order.
539    pub const VARIANTS: &'static [&'static str] = &["tool-center-inside", "tool-inside", "tool-outside"];
540}
541
542/// Region limits for 3D strategies.
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
545#[serde(rename_all = "camelCase")]
546#[serde(default)]
547pub struct Containment {
548    /// Closed 2D boundary loops in machine XY (`empty`: the target silhouette).
549    #[serde(skip_serializing_if = "Vec::is_empty")]
550    pub boundary: Vec<Vec<super::Point2>>,
551    pub mode: ContainmentMode,
552    /// Extra offset applied to the boundary (positive grows it).
553    pub offset: f64,
554    /// Only machine surface slopes within this range, degrees from horizontal.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub slope_deg: Option<[f64; 2]>,
557    /// Only machine between these machine Z levels.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub z_range: Option<[f64; 2]>,
560}
561
562impl Containment {
563    pub fn validate(&self) -> Result<(), String> {
564        if !self.offset.is_finite() {
565            return Err("containment offset is not finite".into());
566        }
567        if let Some([lo, hi]) = self.slope_deg {
568            if !lo.is_finite() || !hi.is_finite() || lo < 0.0 || hi > 90.0 || lo > hi {
569                return Err("slope range must lie within 0..90 degrees".into());
570            }
571        }
572        if let Some([lo, hi]) = self.z_range {
573            if !lo.is_finite() || !hi.is_finite() || lo > hi {
574                return Err("z range must have min <= max".into());
575            }
576        }
577        for loop_ in &self.boundary {
578            if loop_.len() < 3 {
579                return Err("a containment boundary loop needs at least three points".into());
580            }
581            if loop_.iter().any(|p| !p.is_finite()) {
582                return Err("a containment boundary point is not finite".into());
583            }
584        }
585        Ok(())
586    }
587}
588
589/// Drilling cycle selection.
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
592#[serde(rename_all = "kebab-case")]
593pub enum DrillCycle {
594    /// Simple drilling (G81).
595    #[default]
596    Drill,
597    /// Drilling with dwell at depth (G82).
598    DrillDwell,
599    /// Deep drilling with full retract (G83).
600    Peck,
601    /// Chip breaking with short retract (G73).
602    ChipBreak,
603    /// Rigid tapping (G84 with synchronous feed).
604    TapRigid,
605    /// Floating-holder tapping (G84).
606    TapFloat,
607    /// Reaming: feed in, feed out (G85).
608    Ream,
609    /// Boring with spindle stop and shift (G76).
610    BoreFine,
611    /// Boring, stop spindle at bottom, rapid out (G86).
612    Bore,
613    /// Back boring (G87).
614    BackBore,
615    /// Boring with dwell and feed out (G89).
616    BoreDwell,
617}
618
619impl DrillCycle {
620    /// Serde names of every variant, in declaration order.
621    pub const VARIANTS: &'static [&'static str] = &["drill", "drill-dwell", "peck", "chip-break", "tap-rigid", "tap-float", "ream", "bore-fine", "bore", "back-bore", "bore-dwell"];
622}
623
624/// The strategy and its parameters. Variants beyond the 3D-finishing core
625/// start with the parameters a first implementation needs; lanes extend them.
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
628#[serde(tag = "kind", rename_all = "camelCase")]
629#[non_exhaustive]
630pub enum Strategy {
631    // --- 2D / 2.5D ---
632    Face(FaceParams),
633    Pocket2D(Pocket2dParams),
634    Contour2D(Contour2dParams),
635    Adaptive2D(Adaptive2dParams),
636    Slot(SlotParams),
637    Trace(TraceParams),
638    Chamfer2D(Chamfer2dParams),
639    Engrave(EngraveParams),
640    Bore(BoreParams),
641    Circular(CircularParams),
642    ThreadMill(ThreadMillParams),
643    // --- Drilling ---
644    Drill(DrillParams),
645    // --- 3D roughing ---
646    Adaptive3D(Adaptive3dParams),
647    Pocket3D(Pocket3dParams),
648    // --- 3D finishing ---
649    Parallel(ParallelParams),
650    Contour3D(Contour3dParams),
651    Scallop(ScallopParams),
652    Pencil(PencilParams),
653    SteepShallow(SteepShallowParams),
654    Radial(RadialParams),
655    Spiral(SpiralParams),
656    MorphedSpiral(MorphedSpiralParams),
657    Horizontal(HorizontalParams),
658    Ramp(RampFinishParams),
659    Project(ProjectParams),
660    Flow(FlowParams),
661    Morph(MorphParams),
662    RestFinish(RestFinishParams),
663    /// Probing a work offset (lane C14).
664    ProbeWcs(super::ProbeWcsParams),
665    /// Probing geometry for inspection (lane C14).
666    ProbeGeometry(super::ProbeGeometryParams),
667    /// Pattern of other enabled operations: linear, circular, mirror or WCS offsets (lane C15).
668    Pattern(PatternParams),
669    /// A manual NC entry: stop, dwell, comment, pass-through text or tool change (lane C15).
670    ManualNc(ManualNcParams),
671    // --- Multi-axis ---
672    Swarf(SwarfParams),
673    MultiAxisContour(MultiAxisContourParams),
674    RotaryWrap(RotaryWrapParams),
675    /// Rotary finishing: the tool axis radial to a 4th axis, line or spiral passes (lane C10).
676    RotaryFinish(RotaryFinishParams),
677    /// Multi-axis finishing over an inner 3-axis finishing operation (lane C10b).
678    MultiAxisFinish(MultiAxisFinishParams),
679    // --- Turning ---
680    TurnFace(TurnFaceParams),
681    TurnProfile(TurnProfileParams),
682    TurnGroove(TurnGrooveParams),
683    TurnThread(TurnThreadParams),
684    TurnPartOff(TurnPartOffParams),
685}
686
687impl Default for Strategy {
688    fn default() -> Self {
689        Strategy::Parallel(ParallelParams::default())
690    }
691}
692
693impl Strategy {
694    /// Every strategy's kind name (the serde tag), in declaration order. The
695    /// contract the application lists strategies from; `kind_name` reads it.
696    pub const KINDS: &'static [&'static str] = &[
697        "face",
698        "pocket2D",
699        "contour2D",
700        "adaptive2D",
701        "slot",
702        "trace",
703        "chamfer2D",
704        "engrave",
705        "bore",
706        "circular",
707        "threadMill",
708        "drill",
709        "adaptive3D",
710        "pocket3D",
711        "parallel",
712        "contour3D",
713        "scallop",
714        "pencil",
715        "steepShallow",
716        "radial",
717        "spiral",
718        "morphedSpiral",
719        "horizontal",
720        "ramp",
721        "project",
722        "flow",
723        "morph",
724        "restFinish",
725        "probeWcs",
726        "probeGeometry",
727        "pattern",
728        "manualNc",
729        "swarf",
730        "multiAxisContour",
731        "rotaryWrap",
732        "rotaryFinish",
733        "multiAxisFinish",
734        "turnFace",
735        "turnProfile",
736        "turnGroove",
737        "turnThread",
738        "turnPartOff",
739    ];
740
741    /// Position of this variant in [`Strategy::KINDS`].
742    pub fn kind_index(&self) -> usize {
743        match self {
744            Strategy::Face(_) => 0,
745            Strategy::Pocket2D(_) => 1,
746            Strategy::Contour2D(_) => 2,
747            Strategy::Adaptive2D(_) => 3,
748            Strategy::Slot(_) => 4,
749            Strategy::Trace(_) => 5,
750            Strategy::Chamfer2D(_) => 6,
751            Strategy::Engrave(_) => 7,
752            Strategy::Bore(_) => 8,
753            Strategy::Circular(_) => 9,
754            Strategy::ThreadMill(_) => 10,
755            Strategy::Drill(_) => 11,
756            Strategy::Adaptive3D(_) => 12,
757            Strategy::Pocket3D(_) => 13,
758            Strategy::Parallel(_) => 14,
759            Strategy::Contour3D(_) => 15,
760            Strategy::Scallop(_) => 16,
761            Strategy::Pencil(_) => 17,
762            Strategy::SteepShallow(_) => 18,
763            Strategy::Radial(_) => 19,
764            Strategy::Spiral(_) => 20,
765            Strategy::MorphedSpiral(_) => 21,
766            Strategy::Horizontal(_) => 22,
767            Strategy::Ramp(_) => 23,
768            Strategy::Project(_) => 24,
769            Strategy::Flow(_) => 25,
770            Strategy::Morph(_) => 26,
771            Strategy::RestFinish(_) => 27,
772            Strategy::ProbeWcs(_) => 28,
773            Strategy::ProbeGeometry(_) => 29,
774            Strategy::Pattern(_) => 30,
775            Strategy::ManualNc(_) => 31,
776            Strategy::Swarf(_) => 32,
777            Strategy::MultiAxisContour(_) => 33,
778            Strategy::RotaryWrap(_) => 34,
779            Strategy::RotaryFinish(_) => 35,
780            Strategy::MultiAxisFinish(_) => 36,
781            Strategy::TurnFace(_) => 37,
782            Strategy::TurnProfile(_) => 38,
783            Strategy::TurnGroove(_) => 39,
784            Strategy::TurnThread(_) => 40,
785            Strategy::TurnPartOff(_) => 41,
786        }
787    }
788
789    /// Stable lowercase name of the strategy (the serde tag).
790    pub fn kind_name(&self) -> &'static str {
791        Self::KINDS[self.kind_index()]
792    }
793
794    /// Enum-valued fields of the strategy kind `kind`: the dotted JSON path of
795    /// every string-tagged field and the names it may take. Variant-dependent
796    /// payload fields are listed too where a nested tag selects them.
797    pub fn enum_choices(kind: &str) -> &'static [EnumChoice] {
798        const STEPOVER: EnumChoice = EnumChoice { path: "stepover.direction", variants: CutDirection::VARIANTS };
799        const DIRECTION: EnumChoice = EnumChoice { path: "direction", variants: CutDirection::VARIANTS };
800        const CONTAINMENT: EnumChoice = EnumChoice { path: "containment.mode", variants: ContainmentMode::VARIANTS };
801        match kind {
802            "face" | "pocket2D" | "circular" => &[STEPOVER],
803            "contour2D" => &[DIRECTION],
804            "adaptive2D" => &[DIRECTION, EnumChoice { path: "restSource", variants: RestSource::VARIANTS }],
805            "slot" | "trace" | "chamfer2D" | "engrave" | "bore" | "threadMill" | "swarf" | "turnFace" | "turnGroove"
806            | "turnThread" | "turnPartOff" | "probeGeometry" => &[],
807            "drill" => &[EnumChoice { path: "cycle", variants: DrillCycle::VARIANTS }],
808            "pencil" | "radial" | "project" | "multiAxisContour" => &[CONTAINMENT],
809            "adaptive3D" => &[CONTAINMENT, EnumChoice { path: "restSource", variants: RestSource::VARIANTS }],
810            "parallel" | "scallop" | "spiral" | "morphedSpiral" | "horizontal" | "flow" | "morph" => {
811                &[STEPOVER, CONTAINMENT]
812            }
813            "pocket3D" => &[STEPOVER, CONTAINMENT, EnumChoice { path: "restSource", variants: RestSource::VARIANTS }],
814            "restFinish" => &[STEPOVER, CONTAINMENT, EnumChoice { path: "source", variants: RestSource::VARIANTS }],
815            "contour3D" | "ramp" => &[DIRECTION, CONTAINMENT],
816            "steepShallow" => &[EnumChoice { path: "shallowStepover.direction", variants: CutDirection::VARIANTS }, CONTAINMENT],
817            "rotaryWrap" | "rotaryFinish" => &[STEPOVER],
818            "multiAxisFinish" => &[EnumChoice { path: "mode.kind", variants: AxisMode::VARIANTS }],
819            "probeWcs" => &[EnumChoice { path: "probeKind", variants: super::ProbeKind::VARIANTS }],
820            "pattern" => &[
821                EnumChoice { path: "patternKind.kind", variants: PatternKind::VARIANTS },
822                EnumChoice { path: "order", variants: PatternOrder::VARIANTS },
823            ],
824            "manualNc" => &[EnumChoice { path: "manualKind.kind", variants: ManualNcKind::VARIANTS }],
825            "turnProfile" => &[EnumChoice { path: "direction", variants: RoughingDirection::VARIANTS }],
826            _ => &[],
827        }
828    }
829
830    /// The default parameters of the strategy named `kind`, or `None` for an
831    /// unknown name. The returned value may still fail `validate` where a
832    /// strategy needs user input (morph curves, a reference tool).
833    pub fn default_of(kind: &str) -> Option<Strategy> {
834        Some(match kind {
835            "face" => Strategy::Face(Default::default()),
836            "pocket2D" => Strategy::Pocket2D(Default::default()),
837            "contour2D" => Strategy::Contour2D(Default::default()),
838            "adaptive2D" => Strategy::Adaptive2D(Default::default()),
839            "slot" => Strategy::Slot(Default::default()),
840            "trace" => Strategy::Trace(Default::default()),
841            "chamfer2D" => Strategy::Chamfer2D(Default::default()),
842            "engrave" => Strategy::Engrave(Default::default()),
843            "bore" => Strategy::Bore(Default::default()),
844            "circular" => Strategy::Circular(Default::default()),
845            "threadMill" => Strategy::ThreadMill(Default::default()),
846            "drill" => Strategy::Drill(Default::default()),
847            "adaptive3D" => Strategy::Adaptive3D(Default::default()),
848            "pocket3D" => Strategy::Pocket3D(Default::default()),
849            "parallel" => Strategy::Parallel(Default::default()),
850            "contour3D" => Strategy::Contour3D(Default::default()),
851            "scallop" => Strategy::Scallop(Default::default()),
852            "pencil" => Strategy::Pencil(Default::default()),
853            "steepShallow" => Strategy::SteepShallow(Default::default()),
854            "radial" => Strategy::Radial(Default::default()),
855            "spiral" => Strategy::Spiral(Default::default()),
856            "morphedSpiral" => Strategy::MorphedSpiral(Default::default()),
857            "horizontal" => Strategy::Horizontal(Default::default()),
858            "ramp" => Strategy::Ramp(Default::default()),
859            "project" => Strategy::Project(Default::default()),
860            "flow" => Strategy::Flow(Default::default()),
861            "morph" => Strategy::Morph(Default::default()),
862            "restFinish" => Strategy::RestFinish(Default::default()),
863            "probeWcs" => Strategy::ProbeWcs(Default::default()),
864            "probeGeometry" => Strategy::ProbeGeometry(Default::default()),
865            "pattern" => Strategy::Pattern(Default::default()),
866            "manualNc" => Strategy::ManualNc(Default::default()),
867            "swarf" => Strategy::Swarf(Default::default()),
868            "multiAxisContour" => Strategy::MultiAxisContour(Default::default()),
869            "rotaryWrap" => Strategy::RotaryWrap(Default::default()),
870            "rotaryFinish" => Strategy::RotaryFinish(Default::default()),
871            "multiAxisFinish" => Strategy::MultiAxisFinish(Default::default()),
872            "turnFace" => Strategy::TurnFace(Default::default()),
873            "turnProfile" => Strategy::TurnProfile(Default::default()),
874            "turnGroove" => Strategy::TurnGroove(Default::default()),
875            "turnThread" => Strategy::TurnThread(Default::default()),
876            "turnPartOff" => Strategy::TurnPartOff(Default::default()),
877            _ => return None,
878        })
879    }
880
881    /// Strategies that machine nothing themselves and need no target body:
882    /// patterns of other operations and manual NC entries.
883    pub fn is_targetless(&self) -> bool {
884        matches!(self, Strategy::Pattern(_) | Strategy::ManualNc(_))
885    }
886
887    pub fn is_turning(&self) -> bool {
888        matches!(
889            self,
890            Strategy::TurnFace(_)
891                | Strategy::TurnProfile(_)
892                | Strategy::TurnGroove(_)
893                | Strategy::TurnThread(_)
894                | Strategy::TurnPartOff(_)
895        )
896    }
897
898    pub fn validate(&self) -> Result<(), String> {
899        match self {
900            Strategy::Face(p) => {
901                p.depths.validate()?;
902                p.stepover.validate()
903            }
904            Strategy::Pocket2D(p) => {
905                p.depths.validate()?;
906                p.stepover.validate()
907            }
908            Strategy::Contour2D(p) => {
909                p.depths.validate()?;
910                positive_or_zero("finishing overlap", p.finishing_overlap)?;
911                positive_or_zero("roughing stepover", p.roughing_stepover)
912            }
913            Strategy::Adaptive2D(p) => {
914                p.depths.validate()?;
915                positive("optimal load", p.optimal_load)?;
916                positive_or_zero("minimum cutting radius", p.minimum_cutting_radius)
917            }
918            Strategy::Slot(p) => {
919                p.depths.validate()?;
920                positive("slot width", p.width)
921            }
922            Strategy::Trace(p) => p.depths.validate(),
923            Strategy::Chamfer2D(p) => {
924                positive("chamfer width", p.width)?;
925                positive_or_zero("chamfer tip offset", p.tip_offset)
926            }
927            Strategy::Engrave(p) => p.depths.validate(),
928            Strategy::Bore(p) => {
929                p.depths.validate()?;
930                positive_or_zero("bore pitch", p.pitch)
931            }
932            Strategy::Circular(p) => {
933                p.depths.validate()?;
934                p.stepover.validate()
935            }
936            Strategy::ThreadMill(p) => {
937                p.depths.validate()?;
938                positive("thread pitch", p.pitch)?;
939                positive("thread diameter", p.diameter)
940            }
941            Strategy::Drill(p) => {
942                positive_or_zero("peck depth", p.peck_depth)?;
943                positive_or_zero("dwell", p.dwell_seconds)?;
944                positive_or_zero("break-through depth", p.break_through)?;
945                positive_or_zero("chip-break retract", p.chip_break_retract)?;
946                positive_or_zero("bore shift", p.bore_shift)?;
947                positive_or_zero("back-bore clearance", p.back_bore_clearance)?;
948                if p.cycle == DrillCycle::ChipBreak {
949                    positive("chip-break retract", p.chip_break_retract)?;
950                }
951                if p.cycle == DrillCycle::BackBore {
952                    positive("back-bore clearance", p.back_bore_clearance)?;
953                }
954                Ok(())
955            }
956            Strategy::Adaptive3D(p) => {
957                p.depths.validate()?;
958                positive("optimal load", p.optimal_load)?;
959                positive_or_zero("fine step down", p.fine_step_down)
960            }
961            Strategy::Pocket3D(p) => {
962                p.depths.validate()?;
963                p.stepover.validate()
964            }
965            Strategy::Parallel(p) => {
966                p.stepover.validate()?;
967                p.containment.validate()?;
968                finite("pass angle", p.pass_angle_deg)
969            }
970            Strategy::Contour3D(p) => {
971                p.containment.validate()?;
972                positive("step down", p.step_down)
973            }
974            Strategy::Scallop(p) => {
975                p.stepover.validate()?;
976                if let Some(h) = p.cusp_height {
977                    positive("cusp height", h)?;
978                }
979                p.containment.validate()
980            }
981            Strategy::Pencil(p) => {
982                p.containment.validate()?;
983                positive_or_zero("pencil bitangency angle", p.bitangency_angle_deg)
984            }
985            Strategy::SteepShallow(p) => {
986                p.containment.validate()?;
987                positive("steep step down", p.steep_step_down)?;
988                p.shallow_stepover.validate()?;
989                positive_or_zero("steep/shallow overlap", p.overlap)?;
990                finite("threshold angle", p.threshold_angle_deg)
991            }
992            Strategy::Radial(p) => {
993                p.containment.validate()?;
994                positive("radial angular step", p.angular_step_deg)
995            }
996            Strategy::Spiral(p) => {
997                p.containment.validate()?;
998                p.stepover.validate()
999            }
1000            Strategy::MorphedSpiral(p) => {
1001                p.containment.validate()?;
1002                p.stepover.validate()
1003            }
1004            Strategy::Horizontal(p) => {
1005                p.containment.validate()?;
1006                p.stepover.validate()
1007            }
1008            Strategy::Ramp(p) => {
1009                p.containment.validate()?;
1010                positive("ramp step down", p.step_down)
1011            }
1012            Strategy::Project(p) => p.containment.validate(),
1013            Strategy::Flow(p) => {
1014                p.containment.validate()?;
1015                p.stepover.validate()
1016            }
1017            Strategy::Morph(p) => {
1018                p.containment.validate()?;
1019                p.stepover.validate()?;
1020                for curve in [&p.first_curve, &p.second_curve] {
1021                    if curve.len() < 2 || !curve.iter().all(super::Point2::is_finite) {
1022                        return Err("morph needs two finite boundary polylines".into());
1023                    }
1024                }
1025                Ok(())
1026            }
1027            Strategy::RestFinish(p) => {
1028                p.containment.validate()?;
1029                p.stepover.validate()?;
1030                positive_or_zero("rest threshold", p.minimum_rest)?;
1031                if !p.pass_angle_deg.is_finite() || (p.source == RestSource::ReferenceTool && p.reference_tool.0.is_empty()) {
1032                    return Err("rest finishing needs a reference tool and finite pass angle".into());
1033                }
1034                Ok(())
1035            }
1036            Strategy::ProbeWcs(p) => p.validate(),
1037            Strategy::ProbeGeometry(p) => p.validate(),
1038            Strategy::Pattern(p) => p.validate(),
1039            Strategy::ManualNc(p) => p.validate(),
1040            Strategy::Swarf(p) => positive_or_zero("swarf tilt", p.tilt_deg),
1041            Strategy::MultiAxisContour(p) => {
1042                p.containment.validate()?;
1043                positive_or_zero("lead angle", p.lead_deg)?;
1044                positive_or_zero("tilt angle", p.tilt_deg)
1045            }
1046            Strategy::RotaryWrap(p) => {
1047                p.stepover.validate()?;
1048                positive("wrap diameter", p.wrap_diameter)
1049            }
1050            Strategy::RotaryFinish(p) => p.validate(),
1051            Strategy::MultiAxisFinish(p) => p.validate(),
1052            Strategy::TurnFace(p) => {
1053                positive("step over", p.step_over)?;
1054                positive_or_zero("facing end x", p.end_x)?;
1055                positive_or_zero("finish allowance", p.finish_allowance)
1056            }
1057            Strategy::TurnProfile(p) => positive("depth of cut", p.depth_of_cut),
1058            Strategy::TurnGroove(p) => {
1059                positive("groove width", p.width)?;
1060                positive_or_zero("groove depth", p.depth)?;
1061                positive("groove step over", p.step_over)?;
1062                positive_or_zero("finish allowance", p.finish_allowance)
1063            }
1064            Strategy::TurnThread(p) => {
1065                positive("thread pitch", p.pitch)?;
1066                positive("thread depth", p.depth)?;
1067                if p.passes == 0 {
1068                    return Err("thread passes must be at least 1".into());
1069                }
1070                if !p.infeed_angle_deg.is_finite() || p.infeed_angle_deg < 0.0 || p.infeed_angle_deg >= 60.0 {
1071                    return Err("thread infeed angle must be finite and within [0, 60) degrees".into());
1072                }
1073                Ok(())
1074            }
1075            Strategy::TurnPartOff(p) => {
1076                positive_or_zero("part-off x", p.end_x)?;
1077                positive_or_zero("part-off chamfer", p.chamfer)?;
1078                positive_or_zero("peck depth", p.peck_depth)?;
1079                positive_or_zero("peck retract", p.peck_retract)?;
1080                positive_or_zero("reduced feed radius", p.reduced_feed_radius)?;
1081                if !p.reduced_feed_factor.is_finite() || p.reduced_feed_factor <= 0.0 || p.reduced_feed_factor > 1.0 {
1082                    return Err("reduced feed factor must be within (0, 1]".into());
1083                }
1084                Ok(())
1085            }
1086        }
1087    }
1088}
1089
1090fn finite(name: &str, v: f64) -> Result<(), String> {
1091    if v.is_finite() {
1092        Ok(())
1093    } else {
1094        Err(format!("{name} is not finite"))
1095    }
1096}
1097
1098fn positive(name: &str, v: f64) -> Result<(), String> {
1099    if v.is_finite() && v > 0.0 {
1100        Ok(())
1101    } else {
1102        Err(format!("{name} must be finite and positive"))
1103    }
1104}
1105
1106fn positive_or_zero(name: &str, v: f64) -> Result<(), String> {
1107    if v.is_finite() && v >= 0.0 {
1108        Ok(())
1109    } else {
1110        Err(format!("{name} must be finite and non-negative"))
1111    }
1112}
1113
1114// ---------------------------------------------------------------------------
1115// 2D / 2.5D
1116// ---------------------------------------------------------------------------
1117
1118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1119#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1120#[serde(rename_all = "camelCase")]
1121#[serde(default)]
1122pub struct FaceParams {
1123    pub depths: Depths,
1124    pub stepover: Stepover,
1125    /// Pass direction in degrees from machine +X.
1126    pub pass_angle_deg: f64,
1127    /// Extend passes past the stock edge by this much.
1128    pub pass_extension: f64,
1129    pub zig_zag: bool,
1130}
1131
1132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1133#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1134#[serde(rename_all = "camelCase")]
1135#[serde(default)]
1136pub struct Pocket2dParams {
1137    pub depths: Depths,
1138    pub stepover: Stepover,
1139    /// Offset pattern (true) or morphed spiral (false).
1140    pub offset_pattern: bool,
1141    /// Run a finishing wall pass at each depth.
1142    pub wall_finish: bool,
1143    /// Islands are kept (true) or cut through (false).
1144    pub keep_islands: bool,
1145}
1146
1147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1149#[serde(rename_all = "camelCase")]
1150#[serde(default)]
1151pub struct Contour2dParams {
1152    pub depths: Depths,
1153    /// Tool stays outside the chain (true) or inside (false).
1154    pub outside: bool,
1155    /// Number of radial roughing passes before the finishing pass.
1156    pub roughing_passes: u32,
1157    pub roughing_stepover: f64,
1158    /// Repeat the finishing pass this many times.
1159    pub finishing_passes: u32,
1160    /// Overlap at the start/end of a closed contour.
1161    pub finishing_overlap: f64,
1162    pub tabs: Vec<Tab>,
1163    pub direction: CutDirection,
1164}
1165
1166impl Default for Contour2dParams {
1167    fn default() -> Self {
1168        Self {
1169            depths: Depths::default(),
1170            outside: true,
1171            roughing_passes: 0,
1172            roughing_stepover: 0.0,
1173            finishing_passes: 1,
1174            finishing_overlap: 0.0,
1175            tabs: Vec::new(),
1176            direction: CutDirection::Climb,
1177        }
1178    }
1179}
1180
1181/// A holding tab left on a contour.
1182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1183#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
1184#[serde(rename_all = "camelCase")]
1185#[serde(default)]
1186pub struct Tab {
1187    /// Position along the chain as a fraction of its length.
1188    pub at: f64,
1189    pub width: f64,
1190    pub height: f64,
1191}
1192
1193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1195#[serde(rename_all = "camelCase")]
1196#[serde(default)]
1197pub struct Adaptive2dParams {
1198    pub depths: Depths,
1199    /// Target radial engagement (constant-engagement roughing).
1200    pub optimal_load: f64,
1201    pub minimum_cutting_radius: f64,
1202    /// Use the rest material of previous operations as the starting stock.
1203    pub rest_machining: bool,
1204    /// Where the rest material comes from when `rest_machining` is on.
1205    pub rest_source: RestSource,
1206    pub direction: CutDirection,
1207}
1208
1209impl Default for Adaptive2dParams {
1210    fn default() -> Self {
1211        Self {
1212            depths: Depths::default(),
1213            optimal_load: 1.0,
1214            minimum_cutting_radius: 0.0,
1215            rest_machining: false,
1216            rest_source: RestSource::default(),
1217            direction: CutDirection::Climb,
1218        }
1219    }
1220}
1221
1222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1224#[serde(rename_all = "camelCase")]
1225#[serde(default)]
1226pub struct SlotParams {
1227    pub depths: Depths,
1228    /// Slot width; the tool diameter when equal.
1229    pub width: f64,
1230    pub trochoidal: bool,
1231}
1232
1233impl Default for SlotParams {
1234    fn default() -> Self {
1235        Self { depths: Depths::default(), width: 6.0, trochoidal: false }
1236    }
1237}
1238
1239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1240#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1241#[serde(rename_all = "camelCase")]
1242#[serde(default)]
1243pub struct TraceParams {
1244    pub depths: Depths,
1245    /// Tool centre on the chain (0), left (-1) or right (+1) of it.
1246    pub side: i8,
1247}
1248
1249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1250#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1251#[serde(rename_all = "camelCase")]
1252#[serde(default)]
1253pub struct Chamfer2dParams {
1254    /// Chamfer leg length.
1255    pub width: f64,
1256    /// Keep the tool tip this far below the chamfer's lower edge.
1257    pub tip_offset: f64,
1258}
1259
1260impl Default for Chamfer2dParams {
1261    fn default() -> Self {
1262        Self { width: 1.0, tip_offset: 0.5 }
1263    }
1264}
1265
1266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1267#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1268#[serde(rename_all = "camelCase")]
1269#[serde(default)]
1270pub struct EngraveParams {
1271    pub depths: Depths,
1272    /// V-carve: depth follows the region width.
1273    pub v_carve: bool,
1274}
1275
1276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1277#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1278#[serde(rename_all = "camelCase")]
1279#[serde(default)]
1280pub struct BoreParams {
1281    pub depths: Depths,
1282    /// Helical pitch per revolution (`0`: circular passes at each depth).
1283    pub pitch: f64,
1284    pub finish_pass: bool,
1285}
1286
1287#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1288#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1289#[serde(rename_all = "camelCase")]
1290#[serde(default)]
1291pub struct CircularParams {
1292    pub depths: Depths,
1293    pub stepover: Stepover,
1294}
1295
1296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1297#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1298#[serde(rename_all = "camelCase")]
1299#[serde(default)]
1300pub struct ThreadMillParams {
1301    pub depths: Depths,
1302    pub pitch: f64,
1303    /// Major diameter of the thread.
1304    pub diameter: f64,
1305    pub internal: bool,
1306    pub left_hand: bool,
1307    pub passes: u32,
1308}
1309
1310impl Default for ThreadMillParams {
1311    fn default() -> Self {
1312        Self { depths: Depths::default(), pitch: 1.0, diameter: 10.0, internal: true, left_hand: false, passes: 1 }
1313    }
1314}
1315
1316// ---------------------------------------------------------------------------
1317// Drilling
1318// ---------------------------------------------------------------------------
1319
1320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1321#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1322#[serde(rename_all = "camelCase")]
1323#[serde(default)]
1324pub struct DrillParams {
1325    pub cycle: DrillCycle,
1326    /// Hole depth below the hole top (`None`: through the recognised hole).
1327    #[serde(skip_serializing_if = "Option::is_none")]
1328    pub depth: Option<f64>,
1329    pub peck_depth: f64,
1330    pub dwell_seconds: f64,
1331    /// Extra depth past the hole bottom for through holes.
1332    pub break_through: f64,
1333    /// Order holes to minimise travel.
1334    pub optimise_order: bool,
1335    /// Use the tool's tip (true) or shoulder (false) as the depth reference.
1336    pub tip_depth: bool,
1337    /// G73 partial retract distance; must be positive for chip-break.
1338    pub chip_break_retract: f64,
1339    /// G76/G87 lateral shift along machine +X.
1340    pub bore_shift: f64,
1341    /// G87 axial entry below finished back face (positive distance).
1342    pub back_bore_clearance: f64,
1343}
1344
1345// ---------------------------------------------------------------------------
1346// 3D roughing
1347// ---------------------------------------------------------------------------
1348
1349#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1350#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1351#[serde(rename_all = "camelCase")]
1352#[serde(default)]
1353pub struct Adaptive3dParams {
1354    pub depths: Depths,
1355    pub optimal_load: f64,
1356    /// Smaller step-down used to clear flat areas between the main depths.
1357    pub fine_step_down: f64,
1358    pub rest_machining: bool,
1359    /// Where the rest material comes from when `rest_machining` is on.
1360    pub rest_source: RestSource,
1361    pub containment: Containment,
1362}
1363
1364impl Default for Adaptive3dParams {
1365    fn default() -> Self {
1366        Self {
1367            depths: Depths::default(),
1368            optimal_load: 1.0,
1369            fine_step_down: 0.0,
1370            rest_machining: false,
1371            rest_source: RestSource::default(),
1372            containment: Containment::default(),
1373        }
1374    }
1375}
1376
1377#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1378#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1379#[serde(rename_all = "camelCase")]
1380#[serde(default)]
1381pub struct Pocket3dParams {
1382    pub depths: Depths,
1383    pub stepover: Stepover,
1384    pub rest_machining: bool,
1385    /// Where the rest material comes from when `rest_machining` is on.
1386    pub rest_source: RestSource,
1387    pub containment: Containment,
1388}
1389
1390// ---------------------------------------------------------------------------
1391// 3D finishing
1392// ---------------------------------------------------------------------------
1393
1394/// Parallel (raster) finishing: drop-cutter along parallel lines.
1395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1396#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1397#[serde(rename_all = "camelCase")]
1398#[serde(default)]
1399pub struct ParallelParams {
1400    pub stepover: Stepover,
1401    /// Pass direction in degrees from machine +X.
1402    pub pass_angle_deg: f64,
1403    /// Alternate pass direction (zig-zag) instead of unidirectional passes.
1404    pub zig_zag: bool,
1405    /// Spacing of samples along a pass before adaptive refinement.
1406    pub sample_step: f64,
1407    /// Refine samples until the chord error is within the operation tolerance.
1408    pub adaptive_sampling: bool,
1409    pub containment: Containment,
1410    /// Lowest Z the cutter may drop to when nothing is below it.
1411    #[serde(skip_serializing_if = "Option::is_none")]
1412    pub floor_z: Option<f64>,
1413}
1414
1415fn default_sample_step() -> f64 {
1416    0.5
1417}
1418
1419impl Default for ParallelParams {
1420    fn default() -> Self {
1421        Self {
1422            stepover: Stepover::default(),
1423            pass_angle_deg: 0.0,
1424            zig_zag: true,
1425            sample_step: default_sample_step(),
1426            adaptive_sampling: true,
1427            containment: Containment::default(),
1428            floor_z: None,
1429        }
1430    }
1431}
1432
1433/// Contour / waterline finishing: closed loops at constant Z.
1434#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1435#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1436#[serde(rename_all = "camelCase")]
1437#[serde(default)]
1438pub struct Contour3dParams {
1439    pub step_down: f64,
1440    /// Add intermediate levels where the slope leaves scallops above tolerance.
1441    /// Off by default until the waterline generator supports it (lane C3).
1442    pub adaptive_step_down: bool,
1443    /// Fiber spacing for the push-cutter sweep before adaptive refinement.
1444    pub fiber_step: f64,
1445    pub direction: CutDirection,
1446    pub containment: Containment,
1447}
1448
1449impl Default for Contour3dParams {
1450    fn default() -> Self {
1451        Self {
1452            step_down: 1.0,
1453            adaptive_step_down: false,
1454            fiber_step: default_sample_step(),
1455            direction: CutDirection::Climb,
1456            containment: Containment::default(),
1457        }
1458    }
1459}
1460
1461#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1462#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1463#[serde(rename_all = "camelCase")]
1464#[serde(default)]
1465pub struct ScallopParams {
1466    pub stepover: Stepover,
1467    pub containment: Containment,
1468    /// Keep passes inside-out (true) or outside-in (false).
1469    pub inside_out: bool,
1470    /// Maximum cusp (scallop) height in model units. When set it wins over
1471    /// `stepover`: the stepover is derived from the cutter and this height.
1472    #[serde(skip_serializing_if = "Option::is_none")]
1473    pub cusp_height: Option<f64>,
1474}
1475
1476#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1477#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1478#[serde(rename_all = "camelCase")]
1479#[serde(default)]
1480pub struct PencilParams {
1481    pub containment: Containment,
1482    /// Only trace corners whose bitangency angle is sharper than this.
1483    pub bitangency_angle_deg: f64,
1484    /// Extra parallel passes beside the pencil trace.
1485    pub extra_passes: u32,
1486}
1487
1488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1489#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1490#[serde(rename_all = "camelCase")]
1491#[serde(default)]
1492pub struct SteepShallowParams {
1493    pub containment: Containment,
1494    /// Slope that separates steep (waterline) from shallow (scallop) regions.
1495    pub threshold_angle_deg: f64,
1496    pub steep_step_down: f64,
1497    pub shallow_stepover: Stepover,
1498    /// Distance the steep and shallow regions overlap across the threshold
1499    /// slope (Fusion's steep/shallow overlap), in model units.
1500    pub overlap: f64,
1501}
1502
1503impl Default for SteepShallowParams {
1504    fn default() -> Self {
1505        Self {
1506            containment: Containment::default(),
1507            threshold_angle_deg: 45.0,
1508            steep_step_down: 1.0,
1509            shallow_stepover: Stepover::default(),
1510            overlap: 0.0,
1511        }
1512    }
1513}
1514
1515#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1516#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1517#[serde(rename_all = "camelCase")]
1518#[serde(default)]
1519pub struct RadialParams {
1520    pub containment: Containment,
1521    /// Centre of the radial pattern in machine XY.
1522    pub center: super::Point2,
1523    pub angular_step_deg: f64,
1524    pub inner_radius: f64,
1525    #[serde(skip_serializing_if = "Option::is_none")]
1526    pub outer_radius: Option<f64>,
1527}
1528
1529impl Default for RadialParams {
1530    fn default() -> Self {
1531        Self {
1532            containment: Containment::default(),
1533            center: super::Point2::default(),
1534            angular_step_deg: 1.0,
1535            inner_radius: 0.0,
1536            outer_radius: None,
1537        }
1538    }
1539}
1540
1541#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1542#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1543#[serde(rename_all = "camelCase")]
1544#[serde(default)]
1545pub struct SpiralParams {
1546    pub containment: Containment,
1547    pub center: super::Point2,
1548    pub stepover: Stepover,
1549    pub inside_out: bool,
1550}
1551
1552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1553#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1554#[serde(rename_all = "camelCase")]
1555#[serde(default)]
1556pub struct MorphedSpiralParams {
1557    pub containment: Containment,
1558    pub stepover: Stepover,
1559}
1560
1561/// Horizontal (flat-area) finishing.
1562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1563#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1564#[serde(rename_all = "camelCase")]
1565#[serde(default)]
1566pub struct HorizontalParams {
1567    pub containment: Containment,
1568    pub stepover: Stepover,
1569    /// Treat surfaces within this slope of horizontal as flat.
1570    pub flat_angle_deg: f64,
1571}
1572
1573#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1574#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1575#[serde(rename_all = "camelCase")]
1576#[serde(default)]
1577pub struct RampFinishParams {
1578    pub containment: Containment,
1579    pub step_down: f64,
1580    pub direction: CutDirection,
1581}
1582
1583impl Default for RampFinishParams {
1584    fn default() -> Self {
1585        Self { containment: Containment::default(), step_down: 1.0, direction: CutDirection::Climb }
1586    }
1587}
1588
1589/// Project 2D curves onto the surface.
1590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1591#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1592#[serde(rename_all = "camelCase")]
1593#[serde(default)]
1594pub struct ProjectParams {
1595    pub containment: Containment,
1596    /// Curves to project, as 2D polylines in machine XY.
1597    pub curves: Vec<Vec<super::Point2>>,
1598}
1599
1600#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1601#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1602#[serde(rename_all = "camelCase")]
1603#[serde(default)]
1604pub struct FlowParams {
1605    pub containment: Containment,
1606    pub stepover: Stepover,
1607    /// Follow the surface's U (true) or V (false) direction.
1608    pub along_u: bool,
1609}
1610
1611/// Boundary curves use the same direction and normalized arc-length correspondence.
1612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1613#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1614#[serde(rename_all = "camelCase", default)]
1615pub struct MorphParams {
1616    pub containment: Containment,
1617    pub stepover: Stepover,
1618    pub first_curve: Vec<super::Point2>,
1619    pub second_curve: Vec<super::Point2>,
1620}
1621
1622/// Source of residual material for rest finishing.
1623#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1624#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1625#[serde(rename_all = "camelCase")]
1626pub enum RestSource { #[default] ReferenceTool, Stock }
1627
1628impl RestSource {
1629    /// Serde names of every variant, in declaration order.
1630    pub const VARIANTS: &'static [&'static str] = &["referenceTool", "stock"];
1631}
1632
1633/// Reference-tool accessibility or simulated-stock rest finishing.
1634#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1635#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1636#[serde(rename_all = "camelCase", default)]
1637pub struct RestFinishParams {
1638    pub source: RestSource,
1639    pub containment: Containment,
1640    pub stepover: Stepover,
1641    pub reference_tool: super::ToolId,
1642    pub minimum_rest: f64,
1643    pub pass_angle_deg: f64,
1644}
1645
1646// ---------------------------------------------------------------------------
1647// Multi-axis
1648// ---------------------------------------------------------------------------
1649
1650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1651#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1652#[serde(rename_all = "camelCase")]
1653#[serde(default)]
1654pub struct SwarfParams {
1655    /// Extra tilt away from the wall in degrees.
1656    pub tilt_deg: f64,
1657    /// Number of depth passes down the wall.
1658    pub passes: u32,
1659}
1660
1661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1662#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1663#[serde(rename_all = "camelCase")]
1664#[serde(default)]
1665pub struct MultiAxisContourParams {
1666    pub containment: Containment,
1667    pub lead_deg: f64,
1668    pub tilt_deg: f64,
1669}
1670
1671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1672#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1673#[serde(rename_all = "camelCase")]
1674#[serde(default)]
1675pub struct RotaryWrapParams {
1676    pub stepover: Stepover,
1677    /// Diameter the flat toolpath is wrapped onto.
1678    pub wrap_diameter: f64,
1679    /// Rotary axis letter (`A` or `B`).
1680    pub axis: String,
1681}
1682
1683fn default_rotary_axis() -> String {
1684    "A".to_string()
1685}
1686
1687impl Default for RotaryWrapParams {
1688    fn default() -> Self {
1689        Self { stepover: Stepover::default(), wrap_diameter: 50.0, axis: default_rotary_axis() }
1690    }
1691}
1692
1693#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1694#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1695#[serde(rename_all = "camelCase")]
1696#[serde(default)]
1697pub struct RotaryFinishParams {
1698    /// Rotary axis letter (`A` about X, `B` about Y).
1699    pub axis: String,
1700    /// Diameter the passes start from (the stock), also the stepover's reference.
1701    pub stock_diameter: f64,
1702    /// Pass spacing as arc length at the stock diameter (line passes) or
1703    /// axial advance per turn (spiral).
1704    pub stepover: Stepover,
1705    /// One continuous spiral instead of line passes.
1706    pub spiral: bool,
1707    /// Alternate the direction of successive line passes.
1708    pub zig_zag: bool,
1709    /// Sample spacing along the axis before refinement.
1710    pub sample_step: f64,
1711    /// Axial range to machine (`None`: the target mesh's extent along the axis).
1712    #[serde(skip_serializing_if = "Option::is_none")]
1713    pub range: Option<[f64; 2]>,
1714}
1715
1716impl Default for RotaryFinishParams {
1717    fn default() -> Self {
1718        Self { axis: "A".into(), stock_diameter: 50.0, stepover: Stepover::default(), spiral: false, zig_zag: true, sample_step: 1.0, range: None }
1719    }
1720}
1721
1722impl RotaryFinishParams {
1723    pub fn validate(&self) -> Result<(), String> {
1724        if !matches!(self.axis.as_str(), "A" | "a" | "B" | "b") {
1725            return Err("rotary axis must be A or B".into());
1726        }
1727        if !self.stock_diameter.is_finite() || self.stock_diameter <= 0.0 {
1728            return Err("rotary stock diameter must be positive".into());
1729        }
1730        if !self.stepover.distance.is_finite() || self.stepover.distance <= 0.0 {
1731            return Err("rotary stepover must be positive".into());
1732        }
1733        if !self.sample_step.is_finite() || self.sample_step <= 0.0 {
1734            return Err("rotary sample step must be positive".into());
1735        }
1736        if let Some([lo, hi]) = self.range {
1737            if !lo.is_finite() || !hi.is_finite() || lo >= hi {
1738                return Err("rotary range must be finite with lo < hi".into());
1739            }
1740        }
1741        Ok(())
1742    }
1743}
1744
1745/// How a multi-axis finishing pass orients the tool axis at each contact
1746/// (lane C10b; the geometric evaluation lives in `multiaxis::orientation`).
1747#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1748#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1749#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
1750pub enum AxisMode {
1751    /// The surface normal rotated by `lead_deg` about the binormal (forward
1752    /// positive) and `tilt_deg` about the tangent (left positive).
1753    Normal {
1754        #[serde(default)]
1755        lead_deg: f64,
1756        #[serde(default)]
1757        tilt_deg: f64,
1758    },
1759    /// Along the line from `point` through the contact (`away: true`) or the
1760    /// reverse (the tool points at `point`).
1761    Point { point: Point3, away: bool },
1762    /// Perpendicular to the line through `origin` with `direction`, through
1763    /// the contact point, away from (`away: true`) or toward the line.
1764    Line { origin: Point3, direction: Point3, away: bool },
1765    /// Constant: `angle_deg` from +Z toward the azimuth `azimuth_deg` (from +X).
1766    FixedFromVertical { angle_deg: f64, azimuth_deg: f64 },
1767}
1768
1769impl AxisMode {
1770    /// Serde names of every variant, in declaration order.
1771    pub const VARIANTS: &'static [&'static str] = &["normal", "point", "line", "fixedFromVertical"];
1772}
1773
1774impl Default for AxisMode {
1775    fn default() -> Self {
1776        AxisMode::Normal { lead_deg: 0.0, tilt_deg: 0.0 }
1777    }
1778}
1779
1780impl AxisMode {
1781    pub fn validate(&self) -> Result<(), String> {
1782        match self {
1783            AxisMode::Normal { lead_deg, tilt_deg } => {
1784                for (n, v) in [("lead", lead_deg), ("tilt", tilt_deg)] {
1785                    if !v.is_finite() || v.abs() >= 90.0 {
1786                        return Err(format!("axis {n} must be within (-90, 90) degrees"));
1787                    }
1788                }
1789                Ok(())
1790            }
1791            AxisMode::Point { point, .. } => {
1792                if !point.is_finite() {
1793                    return Err("axis point is not finite".into());
1794                }
1795                Ok(())
1796            }
1797            AxisMode::Line { origin, direction, .. } => {
1798                if !origin.is_finite() || direction.normalized().is_none() {
1799                    return Err("axis line needs a finite origin and a non-zero direction".into());
1800                }
1801                Ok(())
1802            }
1803            AxisMode::FixedFromVertical { angle_deg, azimuth_deg } => {
1804                if !angle_deg.is_finite() || *angle_deg < 0.0 || *angle_deg >= 90.0 || !azimuth_deg.is_finite() {
1805                    return Err("fixed axis angle must be in [0, 90) degrees with a finite azimuth".into());
1806                }
1807                Ok(())
1808            }
1809        }
1810    }
1811}
1812
1813/// Multi-axis finishing over an inner 3-axis finishing operation (lane C10b).
1814#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1815#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1816#[serde(rename_all = "camelCase")]
1817#[serde(default)]
1818pub struct MultiAxisFinishParams {
1819    pub mode: AxisMode,
1820    /// Largest axis change between consecutive samples, degrees.
1821    pub max_axis_step_deg: f64,
1822    /// Largest angle of the axis from +Z, degrees.
1823    pub max_from_vertical_deg: f64,
1824    /// Largest extra tilt used to clear the shank/holder or the machine
1825    /// limits, degrees.
1826    pub reach_tilt_deg: f64,
1827    /// What a CL point no tilt within the reach clears does to the path
1828    /// (absent: `trim`, so documents written before the option read the same).
1829    #[serde(default, skip_serializing_if = "UnreachablePolicy::is_trim")]
1830    pub unreachable: UnreachablePolicy,
1831}
1832
1833/// A multi-axis finish's handling of CL points where the shank or holder
1834/// collides at every tilt within the reach.
1835#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1836#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1837#[serde(rename_all = "camelCase")]
1838pub enum UnreachablePolicy {
1839    /// Break the path there: retract, link and re-enter past them; the
1840    /// material left is reported (a warning and an empty region per point).
1841    #[default]
1842    Trim,
1843    /// Refuse the operation (its report fails) when any point is unreachable.
1844    Fail,
1845}
1846
1847impl UnreachablePolicy {
1848    /// Serde names of every variant, in declaration order.
1849    pub const VARIANTS: &'static [&'static str] = &["trim", "fail"];
1850
1851    pub fn is_trim(&self) -> bool {
1852        *self == UnreachablePolicy::Trim
1853    }
1854}
1855
1856impl Default for MultiAxisFinishParams {
1857    fn default() -> Self {
1858        Self { mode: AxisMode::default(), max_axis_step_deg: 5.0, max_from_vertical_deg: 60.0, reach_tilt_deg: 30.0, unreachable: UnreachablePolicy::Trim }
1859    }
1860}
1861
1862impl MultiAxisFinishParams {
1863    pub fn validate(&self) -> Result<(), String> {
1864        let bad = |m: &str| Err(format!("multi-axis finish: {m}"));
1865        if let Err(e) = self.mode.validate() {
1866            return bad(&e);
1867        }
1868        if !self.max_axis_step_deg.is_finite() || self.max_axis_step_deg <= 0.0 {
1869            return bad("axis step must be positive");
1870        }
1871        if !self.max_from_vertical_deg.is_finite() || self.max_from_vertical_deg < 0.0 || self.max_from_vertical_deg >= 90.0 {
1872            return bad("angle from vertical must be in [0, 90)");
1873        }
1874        if !self.reach_tilt_deg.is_finite() || self.reach_tilt_deg < 0.0 || self.reach_tilt_deg >= 90.0 {
1875            return bad("reach tilt must be in [0, 90)");
1876        }
1877        Ok(())
1878    }
1879}
1880
1881// ---------------------------------------------------------------------------
1882// Turning
1883// ---------------------------------------------------------------------------
1884
1885#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1886#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1887#[serde(rename_all = "camelCase")]
1888#[serde(default)]
1889pub struct TurnFaceParams {
1890    pub step_over: f64,
1891    pub finish_pass: bool,
1892    /// X (radius) the facing passes end at; `0` faces to centre.
1893    pub end_x: f64,
1894    /// Material left for the finishing pass.
1895    pub finish_allowance: f64,
1896}
1897
1898impl Default for TurnFaceParams {
1899    fn default() -> Self {
1900        Self { step_over: 1.0, finish_pass: true, end_x: 0.0, finish_allowance: 0.1 }
1901    }
1902}
1903
1904#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1905#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1906#[serde(rename_all = "camelCase")]
1907#[serde(default)]
1908pub struct TurnProfileParams {
1909    pub depth_of_cut: f64,
1910    pub roughing: bool,
1911    pub finishing: bool,
1912    /// Outside diameter (true) or inside/bore (false).
1913    pub outside: bool,
1914    /// Roughing pass direction: along the axis (Z) or radial (X plunges).
1915    pub direction: RoughingDirection,
1916}
1917
1918impl Default for TurnProfileParams {
1919    fn default() -> Self {
1920        Self { depth_of_cut: 1.0, roughing: true, finishing: true, outside: true, direction: RoughingDirection::Axial }
1921    }
1922}
1923
1924/// Direction of turning roughing passes.
1925#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1926#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
1927#[serde(rename_all = "kebab-case")]
1928pub enum RoughingDirection {
1929    #[default]
1930    Axial,
1931    Radial,
1932}
1933
1934impl RoughingDirection {
1935    /// Serde names of every variant, in declaration order.
1936    pub const VARIANTS: &'static [&'static str] = &["axial", "radial"];
1937}
1938
1939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1940#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1941#[serde(rename_all = "camelCase")]
1942#[serde(default)]
1943pub struct TurnGrooveParams {
1944    pub width: f64,
1945    pub depth: f64,
1946    /// Axial step between plunges when the groove is wider than the blade.
1947    pub step_over: f64,
1948    pub finish_allowance: f64,
1949    pub finish_pass: bool,
1950}
1951
1952impl Default for TurnGrooveParams {
1953    fn default() -> Self {
1954        Self { width: 3.0, depth: 2.0, step_over: 1.0, finish_allowance: 0.1, finish_pass: true }
1955    }
1956}
1957
1958#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1959#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1960#[serde(rename_all = "camelCase")]
1961#[serde(default)]
1962pub struct TurnThreadParams {
1963    pub pitch: f64,
1964    pub depth: f64,
1965    pub passes: u32,
1966    pub internal: bool,
1967    /// Equal chip area per pass (true) or equal depth per pass (false).
1968    pub constant_area: bool,
1969    /// Infeed angle in degrees: `0` radial, positive along the flank.
1970    pub infeed_angle_deg: f64,
1971    pub alternate_flanks: bool,
1972    pub spring_passes: u32,
1973}
1974
1975impl Default for TurnThreadParams {
1976    fn default() -> Self {
1977        Self { pitch: 1.5, depth: 0.92, passes: 6, internal: false, constant_area: true, infeed_angle_deg: 0.0, alternate_flanks: false, spring_passes: 0 }
1978    }
1979}
1980
1981#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1982#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1983#[serde(rename_all = "camelCase")]
1984#[serde(default)]
1985pub struct TurnPartOffParams {
1986    /// X (radius) the part-off ends at; `0` cuts to centre.
1987    pub end_x: f64,
1988    pub chamfer: f64,
1989    /// Peck depth in X (`0`: no pecking).
1990    pub peck_depth: f64,
1991    pub peck_retract: f64,
1992    /// Below this radius the feed is multiplied by `reduced_feed_factor`.
1993    pub reduced_feed_radius: f64,
1994    pub reduced_feed_factor: f64,
1995}
1996
1997impl Default for TurnPartOffParams {
1998    fn default() -> Self {
1999        Self { end_x: 0.0, chamfer: 0.0, peck_depth: 0.0, peck_retract: 0.0, reduced_feed_radius: 0.0, reduced_feed_factor: 1.0 }
2000    }
2001}
2002
2003// ---------------------------------------------------------------------------
2004// Patterns and manual NC (lane C15)
2005// ---------------------------------------------------------------------------
2006
2007/// A pattern of other enabled source operations. Count INCLUDES the original
2008/// (linear, circular); a mirror emits the original and the reflected instance;
2009/// WCS offsets re-run the sources at each listed work offset with identical
2010/// source-relative tool geometry.
2011#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2012#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2013#[serde(rename_all = "camelCase")]
2014#[serde(default)]
2015pub struct PatternParams {
2016    pub sources: Vec<OperationId>,
2017    /// Serialized as `patternKind`: the strategy's own `kind` tag sits at the same level.
2018    #[serde(rename = "patternKind")]
2019    pub kind: PatternKind,
2020    pub order: PatternOrder,
2021}
2022
2023impl Default for PatternParams {
2024    fn default() -> Self {
2025        Self { sources: Vec::new(), kind: PatternKind::default(), order: PatternOrder::default() }
2026    }
2027}
2028
2029impl PatternParams {
2030    pub fn validate(&self) -> Result<(), String> {
2031        if self.sources.is_empty() || self.sources.iter().any(|s| s.as_str().is_empty()) {
2032            return Err("a pattern needs at least one source operation".into());
2033        }
2034        self.kind.validate()
2035    }
2036}
2037
2038/// Shape of a pattern.
2039#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2040#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2041#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
2042pub enum PatternKind {
2043    /// `count` instances (including the original) `spacing` apart along `direction`.
2044    Linear { count: u32, spacing: f64, direction: Point3 },
2045    /// `count` instances (including the original) stepped by `angle_deg` about `axis` through `origin`.
2046    Circular { count: u32, angle_deg: f64, axis: Point3, origin: Point3 },
2047    /// The original and its reflection in the plane through `origin` with `normal`.
2048    Mirror { origin: Point3, normal: Point3 },
2049    /// The sources re-run at each work offset.
2050    WcsOffsets { list: Vec<WorkOffset> },
2051}
2052
2053impl PatternKind {
2054    /// Serde names of every variant, in declaration order.
2055    pub const VARIANTS: &'static [&'static str] = &["linear", "circular", "mirror", "wcsOffsets"];
2056}
2057
2058impl Default for PatternKind {
2059    fn default() -> Self {
2060        PatternKind::Linear { count: 2, spacing: 10.0, direction: Point3::X }
2061    }
2062}
2063
2064impl PatternKind {
2065    pub fn validate(&self) -> Result<(), String> {
2066        match self {
2067            PatternKind::Linear { count, spacing, direction } => {
2068                if *count < 1 {
2069                    return Err("linear pattern count must be at least 1".into());
2070                }
2071                positive("pattern spacing", *spacing)?;
2072                if direction.normalized().is_none() {
2073                    return Err("linear pattern direction must be a non-zero vector".into());
2074                }
2075                Ok(())
2076            }
2077            PatternKind::Circular { count, angle_deg, axis, origin } => {
2078                if *count < 1 {
2079                    return Err("circular pattern count must be at least 1".into());
2080                }
2081                if !angle_deg.is_finite() || *angle_deg == 0.0 {
2082                    return Err("circular pattern angle must be finite and non-zero".into());
2083                }
2084                if axis.normalized().is_none() || !origin.is_finite() {
2085                    return Err("circular pattern needs a non-zero axis and a finite origin".into());
2086                }
2087                Ok(())
2088            }
2089            PatternKind::Mirror { origin, normal } => {
2090                if normal.normalized().is_none() || !origin.is_finite() {
2091                    return Err("mirror pattern needs a non-zero normal and a finite origin".into());
2092                }
2093                Ok(())
2094            }
2095            PatternKind::WcsOffsets { list } => {
2096                if list.is_empty() {
2097                    return Err("WCS pattern needs at least one work offset".into());
2098                }
2099                if list.iter().any(|w| w.number == 0 || !w.origin.is_finite()) {
2100                    return Err("WCS pattern offsets need a number of at least 1 and a finite origin".into());
2101                }
2102                Ok(())
2103            }
2104        }
2105    }
2106}
2107
2108/// Order of a pattern's expanded instances.
2109#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
2111#[serde(rename_all = "kebab-case")]
2112pub enum PatternOrder {
2113    /// Every source at one instance, then the next instance.
2114    #[default]
2115    ByInstance,
2116    /// Every instance of one tool's operations before the next tool.
2117    ByTool,
2118}
2119
2120impl PatternOrder {
2121    /// Serde names of every variant, in declaration order.
2122    pub const VARIANTS: &'static [&'static str] = &["by-instance", "by-tool"];
2123}
2124
2125/// A manual NC entry: exactly the requested typed entry, no implicit
2126/// machining state (no tool, spindle or coolant preamble).
2127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2128#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
2129#[serde(rename_all = "camelCase")]
2130#[serde(default)]
2131pub struct ManualNcParams {
2132    /// Serialized as `manualKind`: the strategy's own `kind` tag sits at the same level.
2133    #[serde(rename = "manualKind")]
2134    pub kind: ManualNcKind,
2135}
2136
2137impl ManualNcParams {
2138    pub fn validate(&self) -> Result<(), String> {
2139        match &self.kind {
2140            ManualNcKind::Dwell { seconds } => positive_or_zero("manual dwell", *seconds),
2141            ManualNcKind::ToolChange { tool } if tool.as_str().is_empty() => Err("manual tool change needs a tool".into()),
2142            _ => Ok(()),
2143        }
2144    }
2145}
2146
2147/// The manual entries. `PassThrough` text reaches the post byte for byte.
2148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2149#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2150#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
2151pub enum ManualNcKind {
2152    Stop,
2153    OptionalStop,
2154    Dwell { seconds: f64 },
2155    Comment { text: String },
2156    PassThrough { text: String },
2157    ToolChange { tool: ToolId },
2158}
2159
2160impl ManualNcKind {
2161    /// Serde names of every variant, in declaration order.
2162    pub const VARIANTS: &'static [&'static str] = &["stop", "optionalStop", "dwell", "comment", "passThrough", "toolChange"];
2163}
2164
2165impl Default for ManualNcKind {
2166    fn default() -> Self {
2167        ManualNcKind::Comment { text: String::new() }
2168    }
2169}