Skip to main content

brep_cam/planar/
machining.rs

1//! What the 2.5D operations share: the resolved planar input, depth levels,
2//! pass assembly from exact loops/chains, the no-gouge certificate of every
3//! constant-Z chord against the full protected mesh, and the hand-over to
4//! the linker (lane C4's `link::connect`), filter and arc fitter.
5//!
6//! A pass is certified in two parts. Above the cut the push cutter's
7//! horizontal sweep (`push::PushContext::segment`) at `z + tolerance` must
8//! report no collision interval: every wall, fixture and step higher than
9//! the machining tolerance above the cut is caught with the full cutter
10//! profile. At the cut the drop cutter's point bound at the chord ends must
11//! not exceed `z`: the floor under the pass is at or below the cut. An
12//! operation whose pass fails either check is an error, never a usable path.
13
14use super::{Chain, Loop, PlanarError, Region, Vec2};
15use serde::{Serialize, Deserialize};
16use crate::arcfit::ArcFitOptions;
17use crate::drop::DropCutter;
18use crate::link::{connect, LinkContext, LinkOptions, Pass};
19use crate::model::{
20    CutDirection, Depths, Diagnostic, FeedsSpeeds, Heights, Motion, Operation, Point2, Point3, Setup, Tab, Tool,
21    Toolpath,
22};
23use crate::push::PushContext;
24use crate::{CamError, Cutter, CutterProfile, MeshSnapshot, Runner, Tolerance, TriangleIndex};
25
26/// A planar region at a machine height (a planar face, a sketch profile, a
27/// stock outline).
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29pub struct PlanarRegion {
30    pub region: Region,
31    /// Machine Z of the plane.
32    pub z: f64,
33    /// `true` when the plane's normal points up (+Z) in machine space.
34    pub upward: bool,
35}
36
37/// A chain of edges at one machine height.
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39pub struct PlanarChain {
40    pub chain: Chain,
41    pub z: f64,
42}
43
44/// A circular feature for bore / circular / thread milling.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct Circle2d {
47    pub center: Point2,
48    /// Radius of the hole (internal) or boss (external).
49    pub radius: f64,
50    /// Machine Z of the feature top.
51    pub top: f64,
52    /// Machine Z of the feature bottom.
53    pub bottom: f64,
54    /// A hole (`true`) or a boss (`false`).
55    pub internal: bool,
56    /// For a boss: the radius out to which material is cleared (the stock).
57    pub outer_radius: Option<f64>,
58}
59
60impl Circle2d {
61    pub fn hole(center: Point2, radius: f64, top: f64, bottom: f64) -> Self {
62        Self { center, radius, top, bottom, internal: true, outer_radius: None }
63    }
64
65    pub fn boss(center: Point2, radius: f64, top: f64, bottom: f64, outer_radius: f64) -> Self {
66        Self { center, radius, top, bottom, internal: false, outer_radius: Some(outer_radius) }
67    }
68
69    pub fn validate(&self, strategy: &str) -> Result<(), CamError> {
70        if !self.center.is_finite() || !self.radius.is_finite() || !self.top.is_finite() || !self.bottom.is_finite() {
71            return Err(invalid(strategy, "a circle has a non-finite parameter"));
72        }
73        if self.radius <= 0.0 {
74            return Err(invalid(strategy, "a circle has a non-positive radius"));
75        }
76        if self.bottom > self.top {
77            return Err(invalid(strategy, "a circle's bottom is above its top"));
78        }
79        if let Some(o) = self.outer_radius {
80            if !o.is_finite() || o <= self.radius {
81                return Err(invalid(strategy, "a boss's outer radius must exceed its radius"));
82            }
83        }
84        Ok(())
85    }
86}
87
88/// The resolved geometry a 2.5D operation works on, in machine space. The
89/// caller resolves the operation's targets (through
90/// `kernel_adapter::planar` in the app, directly in tests) and hands it over;
91/// the operation never sees the kernel.
92#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
93pub struct PlanarInput {
94    pub regions: Vec<PlanarRegion>,
95    pub chains: Vec<PlanarChain>,
96    pub circles: Vec<Circle2d>,
97    /// Machine Z of the stock top when known (the default top of cut).
98    pub stock_top: Option<f64>,
99}
100
101/// Everything an operation validates once: tool, heights, feeds, mesh.
102pub struct OpContext<'a> {
103    pub operation: &'a Operation,
104    pub setup: &'a Setup,
105    pub tool: &'a Tool,
106    pub mesh: &'a MeshSnapshot,
107    pub index: Option<&'a dyn TriangleIndex>,
108    pub tolerance: Tolerance,
109    pub cutter: Cutter,
110    pub heights: Heights,
111    pub feeds: FeedsSpeeds,
112    pub strategy: &'static str,
113    /// Top of the material the operation removes, recorded by `top_of_cut`
114    /// (the highest of the operation's, the setup's and the stock's tops):
115    /// the linker keeps rapids and partial retracts above the stock this
116    /// operation has not cut yet (lane C4's `LinkOptions::material_top`).
117    pub material_top: std::cell::Cell<Option<f64>>,
118}
119
120pub fn invalid(strategy: &str, message: impl std::fmt::Display) -> CamError {
121    CamError::InvalidInput(format!("{strategy}: {message}"))
122}
123
124impl<'a> OpContext<'a> {
125    /// Validate the operation's shared inputs for `strategy`.
126    pub fn new(
127        strategy: &'static str,
128        operation: &'a Operation,
129        setup: &'a Setup,
130        tool: &'a Tool,
131        mesh: &'a MeshSnapshot,
132        index: Option<&'a dyn TriangleIndex>,
133        tolerance: Tolerance,
134    ) -> Result<Self, CamError> {
135        tolerance.validate()?;
136        operation.validate().map_err(|m| invalid(strategy, m))?;
137        if !operation.enabled {
138            return Err(invalid(strategy, "operation is disabled"));
139        }
140        if operation.setup != setup.id || operation.tool != tool.id {
141            return Err(invalid(strategy, "setup or tool identity mismatch"));
142        }
143        if let Some(axis) = operation.tool_axis {
144            if axis.normalized().map_or(true, |a| a.distance(Point3::Z) > tolerance.angle_eps) {
145                return Err(invalid(strategy, "requires the machine +Z tool axis; transform indexed setups first"));
146            }
147        }
148        if operation.stock_to_leave.radial < 0.0 || operation.stock_to_leave.axial < 0.0 {
149            return Err(invalid(strategy, "stock to leave must not be negative"));
150        }
151        let cutter = Cutter::from_tool(tool)?;
152        let heights = operation.heights.clone().unwrap_or_else(|| setup.heights.clone());
153        heights.validate().map_err(|m| invalid(strategy, m))?;
154        let feeds = operation.feeds.clone().unwrap_or_else(|| tool.feeds.clone());
155        feeds.validate().map_err(|m| invalid(strategy, m))?;
156        if mesh.is_empty() {
157            return Err(CamError::EmptyTarget { operation_id: operation.id.to_string() });
158        }
159        for solid in operation.targets.protected_bodies() {
160            if !mesh.faces.iter().any(|f| f.solid == solid) {
161                return Err(CamError::UnresolvedTarget { solid, face: None });
162            }
163        }
164        for face in operation.targets.drive_faces.iter().chain(&operation.targets.avoid_faces) {
165            if mesh.resolve_faces(face).is_empty() {
166                return Err(CamError::UnresolvedTarget { solid: face.solid.clone(), face: face.face.clone() });
167            }
168        }
169        if heights.clearance <= mesh.bounds.max.z + tolerance.distance_eps {
170            return Err(invalid(strategy, "clearance height must be above the full protected mesh"));
171        }
172        // The drop cutter validates the cutter against the mesh and index once.
173        DropCutter::new(mesh, &cutter, index, tolerance)?;
174        Ok(Self { operation, setup, tool, mesh, index, tolerance, cutter, heights, feeds, strategy, material_top: std::cell::Cell::new(None) })
175    }
176
177    pub fn radius(&self) -> f64 {
178        self.cutter.radius()
179    }
180
181    /// The default top of cut: the operation's heights, else the stock top
182    /// from the input, else the mesh top.
183    pub fn top_of_cut(&self, depths: &Depths, input: &PlanarInput) -> f64 {
184        let top = depths.top.or(self.heights.top).or(input.stock_top).unwrap_or(self.mesh.bounds.max.z);
185        let material = [depths.top, self.heights.top, input.stock_top].into_iter().flatten().fold(top, f64::max);
186        self.note_material_top(material);
187        top
188    }
189
190    /// Raise the recorded material top to `z` (a feature's own top above the
191    /// operation's default, e.g. a circle's).
192    pub fn note_material_top(&self, z: f64) {
193        self.material_top.set(Some(self.material_top.get().map_or(z, |m: f64| m.max(z))));
194    }
195
196    /// The highest material over a feature whose own top is `local_top`, for
197    /// strategies that write their own approaches (bore, thread mill): their
198    /// rapid to the feed height must stop above the stock, not the feature.
199    pub fn material_over(&self, depths: &Depths, input: &PlanarInput, local_top: f64) -> f64 {
200        self.top_of_cut(depths, input);
201        self.note_material_top(local_top);
202        self.material_top.get().unwrap_or(local_top)
203    }
204
205    /// Certify one constant-Z polyline pass against the full protected mesh.
206    /// `floor_exempt` skips the floor check (chamfers and engraving cut into
207    /// the model by design and certify walls and fixtures only).
208    pub fn certify(&self, points: &[Point3], report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
209        self.certify_with(points, false, report, runner)
210    }
211
212    pub fn certify_with(&self, points: &[Point3], fixtures_only: bool, report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
213        self.certify_with_cutter(points, &self.cutter, fixtures_only, report, runner)
214    }
215
216    /// As [`Self::certify_with`] with an explicit cutter (a strategy that
217    /// cuts a slot of exactly its own width certifies with the cutter shrunk
218    /// by the tolerance band, since the walls are the cut itself).
219    pub fn certify_with_cutter(&self, points: &[Point3], cutter: &Cutter, fixtures_only: bool, report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
220        if points.len() < 2 {
221            return Ok(());
222        }
223        let tol = self.tolerance;
224        let z = points[0].z;
225        if points.iter().any(|p| (p.z - z).abs() > tol.distance_eps) {
226            return Err(invalid(self.strategy, "a pass is not at constant Z"));
227        }
228        if fixtures_only {
229            // Only protected-only bodies and avoid faces are checked.
230            let keep: Vec<bool> = self
231                .mesh
232                .faces
233                .iter()
234                .map(|f| f.protected_only || self.operation.targets.protected_solids.contains(&f.solid) || self.operation.targets.avoid_faces.iter().any(|a| a.solid == f.solid && a.face.as_ref().map_or(true, |n| *n == f.face)))
235                .collect();
236            if !keep.iter().any(|k| *k) {
237                return Ok(());
238            }
239            let mut builder = crate::MeshBuilder::new(tol);
240            let mut map = vec![u32::MAX; self.mesh.faces.len()];
241            for (i, f) in self.mesh.faces.iter().enumerate() {
242                if keep[i] {
243                    map[i] = builder.add_face(f.clone());
244                }
245            }
246            for t in &self.mesh.triangles {
247                if keep[t.face as usize] {
248                    builder.add_triangle(map[t.face as usize], t.a, t.b, t.c).map_err(|e| invalid(self.strategy, e))?;
249                }
250            }
251            let sub = builder.finish();
252            if sub.is_empty() {
253                return Ok(());
254            }
255            return certify_against(&sub, cutter, None, tol, self.operation.tolerance, points, self.strategy, report, runner);
256        }
257        certify_against(self.mesh, cutter, self.index, tol, self.operation.tolerance, points, self.strategy, report, runner)
258    }
259
260    /// Certify a 3D polyline (a helix): every chord's walls are checked at
261    /// the chord's lower Z and the floor at both ends.
262    pub fn certify_3d(&self, points: &[Point3], fixtures_only: bool, report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
263        for w in points.windows(2) {
264            let z = w[0].z.min(w[1].z);
265            let flat = [Point3::new(w[0].x, w[0].y, z), Point3::new(w[1].x, w[1].y, z)];
266            self.certify_with(&flat, fixtures_only, report, runner)?;
267        }
268        Ok(())
269    }
270
271    /// Wrap directly emitted motions (helical strategies) into the toolpath
272    /// with the report finished and counted; no linker, filter or arc fit.
273    pub fn direct(&self, motions: Vec<Motion>, passes: u64, mut report: crate::model::OperationReport) -> Result<Toolpath, CamError> {
274        if motions.is_empty() {
275            return Err(invalid(self.strategy, "no motions produced"));
276        }
277        let mut path = Toolpath::new(self.operation.id.clone(), self.tool.id.clone());
278        path.motions = motions;
279        if let Some(i) = path.first_non_finite() {
280            return Err(CamError::NonFinite { what: format!("motion {i}") });
281        }
282        report.counts.triangles = self.mesh.triangle_count() as u64;
283        report.counts.passes = passes;
284        report.counts.motions = path.motions.len() as u64;
285        report.finish();
286        path.report = report;
287        Ok(path)
288    }
289
290    /// Link certified passes into the operation's motion stream: proven
291    /// approaches, links, leads and ramps from the linker, then the chord
292    /// filter and the arc fitter.
293    pub fn finish(&self, passes: &[Pass], reorder: bool, mut report: crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<Toolpath, CamError> {
294        if passes.is_empty() {
295            return Err(invalid(self.strategy, "no passes to link (empty or unreachable region)"));
296        }
297        let options = LinkOptions {
298            chord_tolerance: self.operation.tolerance,
299            reorder,
300            allow_reverse: false,
301            material_top: self.material_top.get(),
302            ..LinkOptions::default()
303        };
304        let linked = crate::link::with_current_pre_op_stock(|stock| {
305        let ctx = LinkContext {
306            mesh: self.mesh,
307            cutter: &self.cutter,
308            index: self.index,
309            tolerance: self.tolerance,
310            heights: &self.heights,
311            feeds: &self.feeds,
312            params: &self.operation.linking,
313            options,
314            stock,
315        };
316        connect(passes, &ctx, runner)
317        })?;
318        report.warnings.extend(linked.report.warnings);
319        report.counts.samples += linked.report.counts.samples;
320        report.counts.candidates += linked.report.counts.candidates;
321        report.counts.contacts += linked.report.counts.contacts;
322        let mut path = Toolpath::new(self.operation.id.clone(), self.tool.id.clone());
323        path.motions = linked.motions;
324        path.report = report;
325        let filtered = crate::filter::filter_toolpath(&path, self.tolerance.distance_eps)?;
326        let mut out = if self.operation.linking.fit_arcs {
327            crate::arcfit::fit_toolpath(&filtered, &ArcFitOptions::with_tolerance(self.operation.tolerance))?
328        } else {
329            filtered
330        };
331        out.report.counts.triangles = self.mesh.triangle_count() as u64;
332        out.report.counts.passes = passes.len() as u64;
333        out.report.counts.motions = out.motions.len() as u64;
334        out.report.finish();
335        Ok(out)
336    }
337}
338
339/// Certify the chords of `points` against `mesh` (see the module doc).
340#[allow(clippy::too_many_arguments)]
341fn certify_against(
342    mesh: &MeshSnapshot,
343    cutter: &Cutter,
344    index: Option<&dyn TriangleIndex>,
345    tol: Tolerance,
346    machining_tolerance: f64,
347    points: &[Point3],
348    strategy: &str,
349    report: &mut crate::model::OperationReport,
350    runner: &mut Runner<'_>,
351) -> Result<(), CamError> {
352    let z = points[0].z;
353    let push = PushContext { mesh, cutter, index, tolerance: tol, allowance: 0.0 };
354    push.validate()?;
355    let lift = machining_tolerance.max(2.0 * tol.distance_eps);
356    let drop = DropCutter::new(mesh, cutter, index, tol)?;
357    let floor = mesh.bounds.min.z - cutter.total_height() - 1.0;
358    for (i, p) in points.iter().enumerate() {
359        report.counts.samples += 1;
360        // Over a floor every candidate's top is at the pass height and the
361        // bound cannot exceed it: the cutter is dropped only near walls.
362        let (clear, candidates) = drop.bound_at_most(p.x, p.y, z + lift, runner)?;
363        if clear {
364            report.counts.candidates += candidates;
365            continue;
366        }
367        let bound = drop.point(p.x, p.y, floor, runner)?;
368        report.counts.candidates += bound.candidate_count;
369        report.counts.contacts += bound.contact_count;
370        if bound.point.z > z + lift {
371            let what = bound.point.contact.as_ref().map(|c| format!("{:?} contact with triangle {:?} at {:?}", c.contact_type, c.triangle_id, c.point)).unwrap_or_default();
372            let d = Diagnostic::new("gouge-detected", format!("{strategy}: the cutter at point {i} of a pass would cut {:.4} below the model surface ({what})", bound.point.z - z)).at(*p);
373            let text = format!("a pass gouges the model at ({:.4}, {:.4}, {:.4}): {}", p.x, p.y, p.z, d.message);
374            report.fail(d);
375            return Err(invalid(strategy, text));
376        }
377    }
378    for w in points.windows(2) {
379        let (a, b) = (Point3::new(w[0].x, w[0].y, z + lift), Point3::new(w[1].x, w[1].y, z + lift));
380        runner.work(1, "certify-chords", 0, 0)?;
381        report.counts.samples += 1;
382        if let Some((at, triangle, dist, radius)) = chord_collision(mesh, cutter, index, a, b, tol) {
383            let d = Diagnostic::new(
384                "gouge-detected",
385                format!("{strategy}: a pass chord collides with protected geometry above the cut: triangle {triangle} comes within {dist:.4} of the chord where the cutter is {radius:.4} wide"),
386            )
387            .at(at);
388            let text = format!("a pass collides with the model or a fixture at ({:.4}, {:.4}, {:.4}): {}", at.x, at.y, at.z, d.message);
389            report.fail(d);
390            return Err(invalid(strategy, text));
391        }
392    }
393    let _ = &push;
394    Ok(())
395}
396
397/// Exact swept-envelope collision of a horizontal chord `a`-`b` (the tool
398/// reference point at `a.z`) with the mesh: every triangle is clipped to
399/// the cutter's height slabs and its XY distance to the chord compared with
400/// the cutter's radius in that slab (exact for cylindrical slabs, within
401/// the slab's radius growth otherwise, so never optimistic). Returns the
402/// offending point, triangle id, its distance and the radius there.
403pub fn chord_collision(mesh: &MeshSnapshot, cutter: &Cutter, index: Option<&dyn TriangleIndex>, a: Point3, b: Point3, tol: Tolerance) -> Option<(Point3, u32, f64, f64)> {
404    let z0 = a.z;
405    let height = cutter.total_height();
406    let radius = cutter.radius().max(cutter.shaft_radius());
407    let lo = Point3::new(a.x.min(b.x) - radius, a.y.min(b.y) - radius, z0);
408    let hi = Point3::new(a.x.max(b.x) + radius, a.y.max(b.y) + radius, z0 + height);
409    let bounds = crate::model::Bounds3::new(lo, hi);
410    let ids: Vec<u32> = match index {
411        Some(i) => i.query(&bounds, crate::ProjectionMode::Xyz),
412        None => (0..mesh.triangle_count() as u32).collect(),
413    };
414    // Height slabs: profile segment ranges, the shank, subdivided where the
415    // radius changes so the slab's top radius is a tight bound.
416    let mut breaks: Vec<f64> = vec![0.0, cutter.cutting_length(), height];
417    for s in cutter.segments() {
418        breaks.extend(s.height_range);
419    }
420    breaks.retain(|h| h.is_finite() && *h >= 0.0 && *h <= height);
421    breaks.sort_by(f64::total_cmp);
422    breaks.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
423    let mut slabs: Vec<(f64, f64, f64)> = Vec::new(); // (h0, h1, radius bound)
424    for w in breaks.windows(2) {
425        let (h0, h1) = (w[0], w[1]);
426        if h1 - h0 <= 0.0 {
427            continue;
428        }
429        let r0 = cutter.max_radius_at_height(h0.max(1e-12)).unwrap_or(0.0);
430        let r1 = cutter.max_radius_at_height(h1).unwrap_or(radius);
431        let parts = if (r1 - r0).abs() <= tol.distance_eps { 1 } else { 16 };
432        for k in 0..parts {
433            let s0 = h0 + (h1 - h0) * k as f64 / parts as f64;
434            let s1 = h0 + (h1 - h0) * (k + 1) as f64 / parts as f64;
435            let rb = cutter.max_radius_at_height(s1).unwrap_or(radius).max(cutter.max_radius_at_height(s0.max(1e-12)).unwrap_or(0.0));
436            slabs.push((s0, s1, rb));
437        }
438    }
439    let chord = (Point2::new(a.x, a.y), Point2::new(b.x, b.y));
440    let mut worst: Option<(Point3, u32, f64, f64)> = None;
441    for id in ids {
442        let t = mesh.triangle(id);
443        if t.max_z() <= z0 + tol.distance_eps || t.min_z() >= z0 + height {
444            continue;
445        }
446        if t.bounds.min.x > hi.x || t.bounds.max.x < lo.x || t.bounds.min.y > hi.y || t.bounds.max.y < lo.y {
447            continue;
448        }
449        for &(h0, h1, rb) in &slabs {
450            let (zlo, zhi) = (z0 + h0, z0 + h1);
451            if t.max_z() <= zlo + tol.distance_eps || t.min_z() >= zhi - tol.distance_eps {
452                continue;
453            }
454            let poly = clip_to_slab(&t.vertices(), zlo + tol.distance_eps, zhi);
455            if poly.len() < 2 {
456                continue;
457            }
458            let (d, at) = polygon_chord_distance(&poly, chord);
459            if d < rb - tol.distance_eps && worst.as_ref().map_or(true, |w| d < w.2) {
460                worst = Some((at, id, d, rb));
461            }
462        }
463    }
464    worst
465}
466
467/// A triangle clipped to `zlo <= z <= zhi` (convex polygon, possibly empty).
468fn clip_to_slab(tri: &[Point3; 3], zlo: f64, zhi: f64) -> Vec<Point3> {
469    let mut poly: Vec<Point3> = tri.to_vec();
470    for (level, keep_above) in [(zlo, true), (zhi, false)] {
471        let mut out = Vec::with_capacity(poly.len() + 2);
472        let n = poly.len();
473        for i in 0..n {
474            let p = poly[i];
475            let q = poly[(i + 1) % n];
476            let inside = |v: Point3| if keep_above { v.z >= level } else { v.z <= level };
477            if inside(p) {
478                out.push(p);
479            }
480            if inside(p) != inside(q) && (q.z - p.z).abs() > 0.0 {
481                let f = (level - p.z) / (q.z - p.z);
482                out.push(p.lerp(q, f.clamp(0.0, 1.0)));
483            }
484        }
485        poly = out;
486        if poly.is_empty() {
487            break;
488        }
489    }
490    poly
491}
492
493/// Minimal XY distance between a convex polygon and a segment, with the
494/// polygon point realising it (zero when the chord crosses the polygon).
495fn polygon_chord_distance(poly: &[Point3], chord: (Point2, Point2)) -> (f64, Point3) {
496    let n = poly.len();
497    let mut best = (f64::INFINITY, poly[0]);
498    let xy = |p: Point3| Point2::new(p.x, p.y);
499    // Chord endpoints inside the polygon (n >= 3) mean overlap.
500    if n >= 3 {
501        for c in [chord.0, chord.1] {
502            let mut sign = 0.0f64;
503            let mut inside = true;
504            for i in 0..n {
505                let e = xy(poly[(i + 1) % n]).sub(xy(poly[i]));
506                let s = e.cross(c.sub(xy(poly[i])));
507                if sign == 0.0 {
508                    sign = s;
509                } else if s * sign < 0.0 {
510                    inside = false;
511                    break;
512                }
513            }
514            if inside && sign != 0.0 {
515                return (0.0, Point3::new(c.x, c.y, poly[0].z));
516            }
517        }
518    }
519    let edges = if n >= 3 { n } else { 1 };
520    for i in 0..edges {
521        let p = poly[i];
522        let q = poly[(i + 1) % n];
523        let (d, at) = segment_segment_distance(xy(p), xy(q), chord.0, chord.1);
524        if d < best.0 {
525            let f = if xy(q).distance(xy(p)) > 0.0 { at.distance(xy(p)) / xy(q).distance(xy(p)) } else { 0.0 };
526            best = (d, p.lerp(q, f.clamp(0.0, 1.0)));
527        }
528    }
529    best
530}
531
532/// Distance between segments `p0p1` and `q0q1` in 2D and the point on the
533/// first realising it.
534fn segment_segment_distance(p0: Point2, p1: Point2, q0: Point2, q1: Point2) -> (f64, Point2) {
535    let sp = super::Segment::line(p0, p1);
536    let sq = super::Segment::line(q0, q1);
537    let cross = |a: Point2, b: Point2| a.x * b.y - a.y * b.x;
538    // Proper crossing: distance zero.
539    let d1 = p1.sub(p0);
540    let d2 = q1.sub(q0);
541    let denom = cross(d1, d2);
542    if denom.abs() > 1e-18 {
543        let w = q0.sub(p0);
544        let t = cross(w, d2) / denom;
545        let u = cross(w, d1) / denom;
546        if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
547            return (0.0, sp.point_at(t));
548        }
549    }
550    let mut best = (f64::INFINITY, p0);
551    for (seg, pts, on_p) in [(&sq, [p0, p1], true), (&sp, [q0, q1], false)] {
552        for e in pts {
553            let (near, _) = seg.nearest(e);
554            let d = near.distance(e);
555            if d < best.0 {
556                best = (d, if on_p { e } else { near });
557            }
558        }
559    }
560    best
561}
562
563/// Depth levels from `top` down to `bottom`: equal roughing steps no larger
564/// than `step_down` (one level when it is zero), then `finishing_passes`
565/// levels of `finishing_step_down` ending at `bottom`. Descending Z.
566pub fn depth_levels(top: f64, bottom: f64, depths: &Depths, strategy: &str) -> Result<Vec<f64>, CamError> {
567    if !top.is_finite() || !bottom.is_finite() {
568        return Err(invalid(strategy, "top or bottom of cut is not finite"));
569    }
570    if bottom > top + 1e-9 {
571        return Err(invalid(strategy, format!("bottom of cut {bottom} is above the top of cut {top}")));
572    }
573    let finishing = if depths.finishing_passes > 0 && depths.finishing_step_down > 0.0 {
574        (depths.finishing_passes as f64 * depths.finishing_step_down).min(top - bottom)
575    } else {
576        0.0
577    };
578    let rough_bottom = bottom + finishing;
579    let mut levels = Vec::new();
580    let span = top - rough_bottom;
581    if span > 1e-9 {
582        let n = if depths.step_down > 0.0 { (span / depths.step_down - 1e-9).ceil().max(1.0) } else { 1.0 };
583        if n > 100_000.0 {
584            return Err(invalid(strategy, "depth level budget exceeded"));
585        }
586        let n = n as usize;
587        for i in 1..=n {
588            levels.push(top - span * i as f64 / n as f64);
589        }
590    } else {
591        levels.push(rough_bottom);
592    }
593    if finishing > 0.0 {
594        let n = depths.finishing_passes as usize;
595        for i in 1..=n {
596            levels.push(rough_bottom - finishing * i as f64 / n as f64);
597        }
598    }
599    // Exactly the bottom last.
600    if let Some(last) = levels.last_mut() {
601        *last = bottom;
602    }
603    levels.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
604    Ok(levels)
605}
606
607/// Orient a loop for the cut direction. `material_right` says whether the
608/// uncut material lies on the right of the loop as given (true for the
609/// inward offsets of pockets and inside contours, false for outward
610/// offsets). Climb milling keeps the material on the right of travel.
611/// Returns the oriented loop and whether the material is on its right.
612pub fn orient_for(loop_: &Loop, material_right: bool, direction: CutDirection, alternate: bool) -> (Loop, bool) {
613    let climb = match direction {
614        CutDirection::Climb => true,
615        CutDirection::Conventional => false,
616        CutDirection::Either => !alternate,
617    };
618    if climb == material_right {
619        (loop_.clone(), material_right)
620    } else {
621        (loop_.reversed(), !material_right)
622    }
623}
624
625/// Chord points of a segment for a pass: arcs whose centre side holds the
626/// material are discretised outward (the chords never dip into it), the
627/// others inward.
628fn pass_points(seg: &super::Segment, chord_tol: f64, material_right: Option<bool>) -> Vec<Point2> {
629    match (seg.arc, material_right) {
630        (Some(a), Some(mr)) if (!a.ccw) == mr => seg.discretize_outward(chord_tol),
631        _ => seg.discretize(chord_tol),
632    }
633}
634
635/// A closed loop as a constant-Z polyline pass starting at its first
636/// vertex, closed back onto it, with an optional overlap along the loop.
637pub fn loop_pass(loop_: &Loop, z: f64, chord_tol: f64, overlap: f64, path_id: u32, material_right: Option<bool>) -> Pass {
638    let mut pts: Vec<Point2> = Vec::new();
639    for s in &loop_.segments {
640        let p = pass_points(s, chord_tol, material_right);
641        pts.extend_from_slice(&p[..p.len() - 1]);
642    }
643    pts.push(pts[0]);
644    if overlap > 0.0 {
645        let mut acc = 0.0;
646        let mut extra = Vec::new();
647        for w in pts.clone().windows(2) {
648            let d = w[0].distance(w[1]);
649            if acc + d >= overlap {
650                let f = ((overlap - acc) / d).clamp(0.0, 1.0);
651                extra.push(w[0].add(w[1].sub(w[0]).scale(f)));
652                break;
653            }
654            acc += d;
655            extra.push(w[1]);
656        }
657        pts.extend(extra);
658    }
659    Pass::new(pts.iter().map(|p| Point3::new(p.x, p.y, z)).collect(), Some(path_id))
660}
661
662/// An open chain as a constant-Z polyline pass.
663pub fn chain_pass(chain: &Chain, z: f64, chord_tol: f64, path_id: u32, material_right: Option<bool>) -> Pass {
664    let mut pts: Vec<Point2> = Vec::new();
665    for (i, s) in chain.segments.iter().enumerate() {
666        let p = pass_points(s, chord_tol, material_right);
667        if i == 0 {
668            pts.push(p[0]);
669        }
670        pts.extend_from_slice(&p[1..]);
671    }
672    Pass::new(pts.iter().map(|p| Point3::new(p.x, p.y, z)).collect(), Some(path_id))
673}
674
675/// A full circle of tool-centre radius `rho` at `z` as chord points, outward
676/// when the material is inside the circle (a boss), inward for a hole.
677pub fn circle_points(center: Point2, rho: f64, ccw: bool, z: f64, chord_tol: f64, material_inside: bool) -> Vec<Point3> {
678    let l = Loop::circle(center, rho, ccw);
679    // Inside the circle is the centre side: material there means outward chords.
680    let mr = if material_inside { Some(!ccw) } else { Some(ccw) };
681    let mut pts: Vec<Point2> = Vec::new();
682    for s in &l.segments {
683        let p = pass_points(s, chord_tol, mr);
684        pts.extend_from_slice(&p[..p.len() - 1]);
685    }
686    pts.push(pts[0]);
687    pts.iter().map(|p| Point3::new(p.x, p.y, z)).collect()
688}
689
690/// Raise the pass over its tabs: within each tab's window along the pass
691/// (centre at `at` times the pass length, `width` wide) the cut rises to
692/// `bottom + height` when the pass is below that, with vertical steps at
693/// the window edges.
694pub fn apply_tabs(pass: &mut Pass, tabs: &[Tab], bottom: f64) {
695    if tabs.is_empty() {
696        return;
697    }
698    let total: f64 = pass.points.windows(2).map(|w| w[0].distance_xy(w[1])).sum();
699    if total <= 0.0 {
700        return;
701    }
702    let z = pass.points[0].z;
703    let mut out: Vec<Point3> = Vec::new();
704    for tab in tabs {
705        let ztab = bottom + tab.height;
706        if ztab <= z || tab.width <= 0.0 {
707            continue;
708        }
709        let s0 = (tab.at.rem_euclid(1.0) * total - 0.5 * tab.width).max(0.0);
710        let s1 = (s0 + tab.width).min(total);
711        out.clear();
712        let mut acc = 0.0;
713        let pts = pass.points.clone();
714        for (i, w) in pts.windows(2).enumerate() {
715            let a = w[0];
716            let b = w[1];
717            let d = a.distance_xy(b);
718            if i == 0 {
719                out.push(a);
720            }
721            let mut push_at = |s: f64, zz: f64| {
722                let f = if d > 0.0 { ((s - acc) / d).clamp(0.0, 1.0) } else { 0.0 };
723                let p = a.lerp(b, f);
724                out.push(Point3::new(p.x, p.y, zz));
725            };
726            if s0 > acc && s0 < acc + d {
727                push_at(s0, z);
728                push_at(s0, ztab);
729            }
730            if s1 > acc && s1 < acc + d {
731                push_at(s1, ztab);
732                push_at(s1, z);
733            }
734            let end_z = if acc + d > s0 && acc + d < s1 { ztab } else { b.z };
735            out.push(Point3::new(b.x, b.y, end_z));
736            acc += d;
737        }
738        pass.points = out.clone();
739    }
740}
741
742/// The exact motion count check every operation runs before linking.
743pub fn non_empty(passes: &[Pass], strategy: &str) -> Result<(), CamError> {
744    if passes.is_empty() {
745        Err(invalid(strategy, "the operation produced no passes: empty or unreachable region"))
746    } else {
747        Ok(())
748    }
749}
750
751impl From<PlanarError> for crate::model::Diagnostic {
752    fn from(e: PlanarError) -> Self {
753        Diagnostic::new("planar", e.to_string())
754    }
755}
756
757/// Keep a motion list's path ids compact after an operation (helper for
758/// strategies that assemble several passes).
759pub fn renumber_paths(motions: &mut [Motion]) {
760    let mut next = 0u32;
761    let mut last: Option<u32> = None;
762    for m in motions.iter_mut() {
763        if let Some(id) = m.path_id {
764            if last != Some(id) {
765                last = Some(id);
766                next += 1;
767            }
768            m.path_id = Some(next - 1);
769        }
770    }
771}