use crate::drop::path::{Dilated, Sampling};
use crate::drop::DropCutter;
use crate::model::{
Diagnostic, Feed, FeedsSpeeds, Heights, LeadKind, LinkingParams, Motion, MotionKind, OperationReport, Point3,
RampKind, RetractPolicy, Toolpath,
};
use crate::{CamError, Cutter, CutterProfile, MeshSnapshot, Runner, Tolerance, TriangleIndex};
use std::f64::consts::{PI, TAU};
mod stock;
use stock::StockMap;
thread_local! {
static PRE_OP_STOCK: std::cell::Cell<*const crate::StockEnvelope> = const { std::cell::Cell::new(std::ptr::null()) };
}
pub fn with_pre_op_stock<R>(stock: Option<&crate::StockEnvelope>, f: impl FnOnce() -> R) -> R {
struct Restore(*const crate::StockEnvelope);
impl Drop for Restore {
fn drop(&mut self) {
PRE_OP_STOCK.with(|c| c.set(self.0));
}
}
let previous = PRE_OP_STOCK.with(|c| c.replace(stock.map_or(std::ptr::null(), |s| s as *const _)));
let _restore = Restore(previous);
f()
}
pub fn with_current_pre_op_stock<R>(f: impl for<'s> FnOnce(Option<&'s crate::StockEnvelope>) -> R) -> R {
let p = PRE_OP_STOCK.with(|c| c.get());
let stock = if p.is_null() { None } else { Some(unsafe { &*p }) };
f(stock)
}
fn scope_has_pre_op_stock() -> bool {
PRE_OP_STOCK.with(|c| !c.get().is_null())
}
#[derive(Clone, Debug, PartialEq)]
pub struct Pass {
pub points: Vec<Point3>,
pub path_id: Option<u32>,
}
impl Pass {
pub fn new(points: Vec<Point3>, path_id: Option<u32>) -> Self {
Self { points, path_id }
}
pub fn start(&self) -> Point3 {
self.points[0]
}
pub fn end(&self) -> Point3 {
self.points[self.points.len() - 1]
}
fn start_tangent(&self) -> Option<Point3> {
self.points.windows(2).find_map(|w| xy_unit(w[1].sub(w[0])))
}
fn end_tangent(&self) -> Option<Point3> {
self.points.windows(2).rev().find_map(|w| xy_unit(w[1].sub(w[0])))
}
}
fn xy_unit(d: Point3) -> Option<Point3> {
Point3::new(d.x, d.y, 0.0).normalized()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeadSide {
#[default]
Left,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LinkOptions {
pub high_feed: f64,
pub radial_allowance: f64,
pub axial_allowance: f64,
pub chord_tolerance: f64,
pub lead_side: LeadSide,
pub reorder: bool,
pub allow_reverse: bool,
pub ramp_length: Option<f64>,
pub max_samples: u64,
pub material_top: Option<f64>,
}
impl Default for LinkOptions {
fn default() -> Self {
Self {
high_feed: 5_000.0,
radial_allowance: 0.0,
axial_allowance: 0.0,
chord_tolerance: 0.01,
lead_side: LeadSide::Left,
reorder: false,
allow_reverse: true,
ramp_length: None,
max_samples: 1_000_000,
material_top: None,
}
}
}
impl LinkOptions {
pub fn validate(&self) -> Result<(), CamError> {
let bad = |m: &str| Err(CamError::InvalidInput(format!("linking: {m}")));
if !self.high_feed.is_finite() || self.high_feed <= 0.0 {
return bad("high feed must be finite and positive");
}
if !self.radial_allowance.is_finite() || self.radial_allowance < 0.0 || !self.axial_allowance.is_finite() || self.axial_allowance < 0.0 {
return bad("allowances must be finite and non-negative");
}
if !self.chord_tolerance.is_finite() || self.chord_tolerance <= 0.0 {
return bad("chord tolerance must be finite and positive");
}
if let Some(l) = self.ramp_length {
if !l.is_finite() || l <= 0.0 {
return bad("ramp length must be finite and positive");
}
}
if self.max_samples < 2 {
return bad("sample budget too small");
}
if self.material_top.is_some_and(|t| !t.is_finite()) {
return bad("material top must be finite");
}
Ok(())
}
}
pub struct LinkContext<'a> {
pub mesh: &'a MeshSnapshot,
pub cutter: &'a Cutter,
pub index: Option<&'a dyn TriangleIndex>,
pub tolerance: Tolerance,
pub heights: &'a Heights,
pub feeds: &'a FeedsSpeeds,
pub params: &'a LinkingParams,
pub options: LinkOptions,
pub stock: Option<&'a crate::StockEnvelope>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Linked {
pub motions: Vec<Motion>,
pub report: OperationReport,
}
struct Prover<'a> {
mesh: &'a MeshSnapshot,
index: Option<&'a dyn TriangleIndex>,
tolerance: Tolerance,
dilated: Dilated<'a>,
sampling: Sampling,
axial: f64,
stock: std::cell::RefCell<Option<StockMap>>,
}
impl<'a> Prover<'a> {
fn new(ctx: &LinkContext<'a>) -> Result<Self, CamError> {
let dilated = Dilated { base: ctx.cutter, pad: ctx.options.radial_allowance };
DropCutter::from_profile(ctx.mesh, &dilated, ctx.index, ctx.tolerance)?;
let floor = ctx.mesh.bounds.min.z - ctx.cutter.total_height() - 1.0;
let sampling = Sampling {
sample_spacing: 1.0,
min_sample_spacing: 1e-6,
chord_error: ctx.options.chord_tolerance,
max_samples: ctx.options.max_samples,
floor_z: floor,
..Sampling::default()
};
sampling.validate()?;
Ok(Self { mesh: ctx.mesh, index: ctx.index, tolerance: ctx.tolerance, dilated, sampling, axial: ctx.options.axial_allowance, stock: std::cell::RefCell::new(None) })
}
fn stock_point(&self, p: Point3) -> f64 {
self.stock.borrow().as_ref().map_or(f64::NEG_INFINITY, |s| s.point(p))
}
fn stock_chord(&self, a: Point3, b: Point3) -> f64 {
self.stock.borrow().as_ref().map_or(f64::NEG_INFINITY, |s| s.chord(a, b))
}
fn drop(&self) -> Result<DropCutter<'_>, CamError> {
DropCutter::from_profile(self.mesh, &self.dilated, self.index, self.tolerance)
}
fn point_bound(&self, p: Point3, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<f64, CamError> {
if report.counts.samples >= self.sampling.max_samples {
return Err(CamError::InvalidInput("linking sample work budget exhausted".into()));
}
let r = self.drop()?.point(p.x, p.y, self.sampling.floor_z, runner)?;
report.counts.samples += 1;
report.counts.candidates += r.candidate_count;
report.counts.contacts += r.contact_count;
Ok(r.point.z + self.axial)
}
fn chord_bound(&self, a: Point3, b: Point3, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<f64, CamError> {
if a.distance_xy(b) <= 0.0 {
return self.point_bound(a, report, runner);
}
Ok(self.drop()?.segment_bound(a, b, &self.sampling, report, runner)? + self.axial)
}
fn chord_safe(&self, a: Point3, b: Point3, eps: f64, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<bool, CamError> {
let bound = self.chord_bound(a, b, report, runner)?;
Ok(a.z.min(b.z) + eps >= bound)
}
fn polyline_safe(&self, points: &[Point3], eps: f64, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<bool, CamError> {
for w in points.windows(2) {
if !self.chord_safe(w[0], w[1], eps, report, runner)? {
return Ok(false);
}
}
Ok(true)
}
}
struct Emitter {
motions: Vec<Motion>,
path_id: Option<u32>,
}
impl Emitter {
fn push(&mut self, mut m: Motion) {
if m.path_id.is_none() {
m.path_id = self.path_id;
}
self.motions.push(m);
}
fn at(&self) -> Option<Point3> {
self.motions.iter().rev().find_map(|m| m.end())
}
}
pub fn connect(passes: &[Pass], ctx: &LinkContext<'_>, runner: &mut Runner<'_>) -> Result<Linked, CamError> {
connect_with(passes, ctx, &[], runner)
}
struct KeepOut {
points: Vec<Point3>,
reach: f64,
z: f64,
}
fn connect_with(passes: &[Pass], ctx: &LinkContext<'_>, keep: &[KeepOut], runner: &mut Runner<'_>) -> Result<Linked, CamError> {
if ctx.stock.is_none() && scope_has_pre_op_stock() {
return Err(CamError::InvalidInput("linking: LinkContext built without the active pre-operation stock (read it with link::with_current_pre_op_stock)".into()));
}
ctx.options.validate()?;
ctx.params.validate().map_err(CamError::InvalidInput)?;
ctx.heights.validate().map_err(CamError::InvalidInput)?;
ctx.feeds.validate().map_err(CamError::InvalidInput)?;
ctx.tolerance.validate()?;
ctx.cutter.validate()?;
if passes.is_empty() {
return Err(CamError::InvalidInput("linking: no passes to link".into()));
}
for (i, p) in passes.iter().enumerate() {
if p.points.is_empty() {
return Err(CamError::InvalidInput(format!("linking: pass {i} is empty")));
}
if let Some(q) = p.points.iter().find(|q| !q.is_finite()) {
return Err(CamError::NonFinite { what: format!("pass {i} point {q:?}") });
}
}
if ctx.mesh.is_empty() {
return Err(CamError::EmptyTarget { operation_id: "linking".into() });
}
let heights = ctx.heights;
let eps = ctx.options.chord_tolerance.max(ctx.tolerance.distance_eps);
let top = ctx.mesh.bounds.max.z + ctx.options.axial_allowance;
if heights.clearance < top + eps {
return Err(CamError::InvalidInput("linking: clearance height must be above the full protected mesh and allowance".into()));
}
if passes.iter().flat_map(|p| &p.points).any(|p| p.z >= heights.clearance) {
return Err(CamError::InvalidInput("linking: a pass reaches the clearance height".into()));
}
let prover = Prover::new(ctx)?;
if ctx.options.material_top.is_some() || ctx.stock.is_some() || !keep.is_empty() {
let extent = passes.iter().flat_map(|p| p.points.iter().copied()).chain(keep.iter().flat_map(|k| k.points.iter().copied()));
let mut map = StockMap::new(extent, ctx.cutter, ctx.options.material_top, ctx.stock, 4_000_000);
for k in keep {
map.raise(&k.points, k.reach, k.z);
}
*prover.stock.borrow_mut() = Some(map);
}
let mut swept = 0usize;
let mut report = OperationReport::default();
report.counts.triangles = ctx.mesh.triangle_count() as u64;
let mut em = Emitter { motions: Vec::new(), path_id: None };
let order: Vec<(usize, bool)> = if ctx.options.reorder {
let start = Point3::new(passes[0].start().x, passes[0].start().y, heights.clearance);
order_passes(passes, start, ctx.options.allow_reverse)
} else {
(0..passes.len()).map(|i| (i, false)).collect()
};
runner.stage("link", "Link passes", 0, order.len() as u64)?;
let mut previous: Option<Pass> = None;
for (n, &(index, reversed)) in order.iter().enumerate() {
let mut pass = passes[index].clone();
if reversed {
pass.points.reverse();
}
em.path_id = pass.path_id;
let lead_in = plan_lead(&pass, true, &ctx.params.lead_in, ctx, &prover, eps, &mut report, runner)?;
let entry = lead_in.as_ref().and_then(|l| l.first().copied()).unwrap_or(pass.start());
match previous.take() {
None => {
em.push(Motion::rapid(Point3::new(entry.x, entry.y, heights.clearance)));
descend(&pass, entry, heights.clearance, ctx, &prover, eps, &mut em, &mut report, runner)?;
}
Some(prev) => {
if let Some(map) = prover.stock.borrow_mut().as_mut() {
let from = em.motions[..swept].iter().rev().find_map(|m| m.end());
map.sweep(from, &em.motions[swept..]);
}
swept = em.motions.len();
let exit = em.at().unwrap_or(prev.end());
link_between(&prev, exit, &pass, entry, ctx, &prover, eps, &mut em, &mut report, runner)?;
}
}
if let Some(lead) = lead_in {
let feed = ctx.feeds.lead_feed.unwrap_or(ctx.feeds.cutting_feed);
for p in lead.iter().skip(1) {
em.push(Motion::new(MotionKind::LeadIn { to: *p }, Feed::Rate(feed)));
}
}
for p in pass.points.iter().skip(1) {
em.push(Motion::cut(*p, ctx.feeds.cutting_feed));
}
if let Some(lead) = plan_lead(&pass, false, &ctx.params.lead_out, ctx, &prover, eps, &mut report, runner)? {
let feed = ctx.feeds.lead_feed.unwrap_or(ctx.feeds.cutting_feed);
for p in lead.iter().skip(1) {
em.push(Motion::new(MotionKind::LeadOut { to: *p }, Feed::Rate(feed)));
}
}
report.counts.passes += 1;
previous = Some(pass);
runner.work(1, "link", n as u64 + 1, order.len() as u64)?;
}
let last = em.at().expect("a pass was emitted");
em.push(Motion::retract(Point3::new(last.x, last.y, heights.clearance), retract_feed(ctx)));
report.counts.motions = em.motions.len() as u64;
report.finish();
Ok(Linked { motions: em.motions, report })
}
fn retract_feed(ctx: &LinkContext<'_>) -> Feed {
ctx.feeds.retract_feed.map_or(Feed::RAPID, Feed::Rate)
}
fn feed_height(entry: Point3, from_z: f64, ctx: &LinkContext<'_>, prover: &Prover<'_>, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<f64, CamError> {
let bound = prover.point_bound(entry, report, runner)?.max(prover.stock_point(entry));
let z = ctx.heights.feed.max(bound + ctx.params.safe_distance).max(entry.z);
Ok(z.min(from_z))
}
#[allow(clippy::too_many_arguments)]
fn descend(pass: &Pass, entry: Point3, from_z: f64, ctx: &LinkContext<'_>, prover: &Prover<'_>, eps: f64, em: &mut Emitter, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
let zf = feed_height(entry, from_z, ctx, prover, report, runner)?;
if zf < from_z {
em.push(Motion::rapid(Point3::new(entry.x, entry.y, zf)));
}
if zf == entry.z {
return Ok(());
}
if zf > entry.z {
if let Some(ramp) = plan_ramp(pass, entry, zf, ctx, prover, eps, report, runner)? {
for m in ramp {
em.push(m);
}
return Ok(());
}
}
em.push(Motion::plunge(entry, ctx.feeds.plunge_feed));
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn link_between(prev: &Pass, exit: Point3, next: &Pass, entry: Point3, ctx: &LinkContext<'_>, prover: &Prover<'_>, eps: f64, em: &mut Emitter, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
let _ = prev;
let heights = ctx.heights;
let params = ctx.params;
if params.keep_down_distance > 0.0 && exit.distance_xy(entry) <= params.keep_down_distance {
if let Some(path) = keep_down_path(exit, entry, ctx, prover, eps, report, runner)? {
for p in path {
em.push(Motion::link(p, ctx.feeds.cutting_feed));
}
return Ok(());
}
report.warn(Diagnostic::new("link-keep-down-unproven", "A keep-down link could not stay within the safe distance of the surface and retracts instead").at(exit));
}
let vertical = exit.distance_xy(entry) == 0.0;
let partial = match params.retract_policy {
RetractPolicy::FullRetract => None,
RetractPolicy::RetractHeight => Some(heights.retract.max(exit.z).max(entry.z)),
RetractPolicy::MinimumRetract => {
let stock = if vertical { f64::NEG_INFINITY } else { prover.stock_chord(exit, entry) };
let bound = prover.chord_bound(exit, entry, report, runner)?.max(stock);
Some((bound + ctx.params.safe_distance).max(exit.z).max(entry.z))
}
};
if let Some(z) = partial {
if z < heights.clearance - eps {
let a = Point3::new(exit.x, exit.y, z);
let b = Point3::new(entry.x, entry.y, z);
let proven = (vertical || prover.stock_chord(a, b) <= z) && prover.chord_safe(a, b, eps, report, runner)?;
if proven {
if z > exit.z {
em.push(Motion::retract(a, retract_feed(ctx)));
}
if a.distance_xy(b) > 0.0 {
em.push(Motion::link(b, ctx.options.high_feed));
}
descend(next, entry, z, ctx, prover, eps, em, report, runner)?;
return Ok(());
}
report.warn(Diagnostic::new("link-fallback-clearance", format!("A retract to Z={z} could not be proven clear of material; the link uses the clearance height")).at(exit));
}
}
em.push(Motion::retract(Point3::new(exit.x, exit.y, heights.clearance), retract_feed(ctx)));
if exit.distance_xy(entry) > 0.0 {
em.push(Motion::rapid(Point3::new(entry.x, entry.y, heights.clearance)));
}
descend(next, entry, heights.clearance, ctx, prover, eps, em, report, runner)
}
fn keep_down_path(exit: Point3, entry: Point3, ctx: &LinkContext<'_>, prover: &Prover<'_>, eps: f64, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<Option<Vec<Point3>>, CamError> {
if prover.stock_chord(exit, entry) > exit.z.min(entry.z) {
return Ok(None);
}
let length = exit.distance_xy(entry);
if length <= eps {
let bound = prover.point_bound(entry, report, runner)?;
return Ok((exit.z.min(entry.z) + eps >= bound).then(|| vec![entry]));
}
let step = (ctx.cutter.radius() * 0.5).max(eps * 10.0);
let n = ((length / step).ceil() as usize).clamp(1, 10_000);
let floor = exit.z.min(entry.z);
let mut pts: Vec<(Point3, bool)> = Vec::with_capacity(n + 1);
pts.push((exit, true));
for i in 1..n {
let q = exit.lerp(entry, i as f64 / n as f64);
let z = prover.point_bound(q, report, runner)?;
pts.push((Point3::new(q.x, q.y, z.max(floor)), false));
}
pts.push((entry, true));
let min_len = (eps * 4.0).max(1e-6);
let mut i = 0usize;
let mut splits = 0usize;
while i + 1 < pts.len() {
let (a, a_fixed) = pts[i];
let (b, b_fixed) = pts[i + 1];
let bound = prover.chord_bound(a, b, report, runner)?;
if a.z.min(b.z) + eps >= bound {
i += 1;
continue;
}
let mut lifted = false;
if !a_fixed && a.z < bound {
pts[i].0.z = bound;
lifted = true;
}
if !b_fixed && b.z < bound {
pts[i + 1].0.z = bound;
lifted = true;
}
if lifted {
continue;
}
if a.distance_xy(b) <= min_len || splits >= 10_000 {
return Ok(None);
}
let m = a.lerp(b, 0.5);
let z = prover.point_bound(m, report, runner)?;
pts.insert(i + 1, (Point3::new(m.x, m.y, z.max(floor)), false));
splits += 1;
}
if pts.iter().any(|(p, _)| p.z > floor + ctx.params.safe_distance + eps) {
return Ok(None);
}
let pts: Vec<Point3> = pts.into_iter().map(|(p, _)| p).collect();
let filtered = crate::filter::filter_points(&pts, ctx.options.chord_tolerance, &[])?;
Ok(Some(filtered.points.into_iter().skip(1).collect()))
}
#[allow(clippy::too_many_arguments)]
fn plan_lead(pass: &Pass, lead_in: bool, lead: &crate::model::Lead, ctx: &LinkContext<'_>, prover: &Prover<'_>, eps: f64, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<Option<Vec<Point3>>, CamError> {
if lead.kind == LeadKind::None {
return Ok(None);
}
let (anchor, tangent) = if lead_in { (pass.start(), pass.start_tangent()) } else { (pass.end(), pass.end_tangent()) };
let Some(t) = tangent else {
report.warn(Diagnostic::new("lead-no-tangent", "The pass has no horizontal direction for a lead").at(anchor));
return Ok(None);
};
let name = if lead_in { "lead-in" } else { "lead-out" };
let sides = match ctx.options.lead_side {
LeadSide::Left => [1.0, -1.0],
LeadSide::Right => [-1.0, 1.0],
};
let mut candidates: Vec<Vec<Point3>> = Vec::new();
match lead.kind {
LeadKind::None => {}
LeadKind::Linear => {
let len = lead.radius.max(lead.linear_length);
if len > 0.0 {
let far = anchor.sub(t.scale(len));
candidates.push(if lead_in { vec![far, anchor] } else { vec![anchor, anchor.add(t.scale(len))] });
}
}
LeadKind::Arc => {
if lead.radius > 0.0 && lead.sweep_deg > 0.0 {
for side in sides {
candidates.push(lead_arc(anchor, t, lead.radius, lead.sweep_deg.to_radians(), lead.linear_length, side, lead_in, ctx.options.chord_tolerance));
}
}
}
LeadKind::Vertical => {
let r = if lead.vertical_radius > 0.0 { lead.vertical_radius } else { lead.radius };
if r > 0.0 {
candidates.push(vertical_lead(anchor, t, r, lead_in, ctx.options.chord_tolerance));
}
}
}
if candidates.is_empty() {
report.warn(Diagnostic::new("lead-zero-size", format!("The {name} has no size and was skipped")).at(anchor));
return Ok(None);
}
for c in candidates {
if c.iter().any(|p| p.z >= ctx.heights.clearance) {
continue;
}
if prover.polyline_safe(&c, eps, report, runner)? {
return Ok(Some(c));
}
}
report.warn(Diagnostic::new("lead-unproven", format!("No {name} could be proven clear of material; the pass starts without one")).at(anchor));
Ok(None)
}
fn lead_arc(anchor: Point3, t: Point3, radius: f64, sweep: f64, linear: f64, side: f64, lead_in: bool, chord_tol: f64) -> Vec<Point3> {
let (t, side) = if lead_in { (t, side) } else { (t.scale(-1.0), -side) };
let n = Point3::new(-t.y, t.x, 0.0).scale(side);
let center = anchor.add(n.scale(radius));
let steps = arc_steps(radius, sweep, chord_tol);
let mut pts = Vec::with_capacity(steps + 2);
let far_at = |a: f64| -> Point3 {
let radial = n.scale(-a.cos()).add(t.scale(-a.sin()));
center.add(radial.scale(radius))
};
let far = far_at(sweep);
if linear > 0.0 {
let dir = n.scale(sweep.sin()).add(t.scale(-sweep.cos()));
pts.push(far.add(dir.scale(linear)));
}
for i in 0..=steps {
let a = sweep * (1.0 - i as f64 / steps as f64);
pts.push(far_at(a));
}
*pts.last_mut().expect("at least the anchor") = anchor;
if !lead_in {
pts.reverse();
}
pts
}
fn vertical_lead(anchor: Point3, t: Point3, r: f64, lead_in: bool, chord_tol: f64) -> Vec<Point3> {
let t = if lead_in { t } else { t.scale(-1.0) };
let center = anchor.add(Point3::new(0.0, 0.0, r));
let steps = arc_steps(r, PI / 2.0, chord_tol);
let mut pts: Vec<Point3> = (0..=steps)
.map(|i| {
let a = (PI / 2.0) * (i as f64 / steps as f64);
center.add(t.scale(-r * a.cos())).add(Point3::new(0.0, 0.0, -r * a.sin()))
})
.collect();
*pts.last_mut().expect("at least the anchor") = anchor;
if !lead_in {
pts.reverse();
}
pts
}
fn arc_steps(radius: f64, sweep: f64, chord_tol: f64) -> usize {
let max_step = if chord_tol >= radius { PI } else { 2.0 * (1.0 - chord_tol / radius).acos() };
((sweep / max_step.max(1e-6)).ceil() as usize).clamp(1, 3600)
}
#[allow(clippy::too_many_arguments)]
fn plan_ramp(pass: &Pass, entry: Point3, zf: f64, ctx: &LinkContext<'_>, prover: &Prover<'_>, eps: f64, report: &mut OperationReport, runner: &mut Runner<'_>) -> Result<Option<Vec<Motion>>, CamError> {
let ramp = &ctx.params.ramp;
if ramp.kind == RampKind::Plunge {
return Ok(None);
}
let feed = ctx.feeds.ramp_feed.unwrap_or(ctx.feeds.cutting_feed);
let angle = ramp.angle_deg.to_radians();
if angle <= 0.0 {
report.warn(Diagnostic::new("ramp-zero-angle", "The ramp angle is zero; plunging instead").at(entry));
return Ok(None);
}
let Some(t) = pass.start_tangent() else {
report.warn(Diagnostic::new("ramp-no-tangent", "The pass has no horizontal direction for a ramp; plunging instead").at(entry));
return Ok(None);
};
let descent = zf - entry.z;
let proven: Option<Vec<Motion>> = match ramp.kind {
RampKind::Helix | RampKind::Smooth => {
let d = if ramp.helix_diameter > 0.0 { ramp.helix_diameter } else { ctx.cutter.radius() };
let n = Point3::new(-t.y, t.x, 0.0);
let r = 0.5 * d;
let center = Point3::new(entry.x + n.x * r, entry.y + n.y * r, 0.0);
let per_turn = TAU * r * angle.tan();
let turns = descent / per_turn;
let steps = (arc_steps(r, TAU, ctx.options.chord_tolerance) as f64 * turns).ceil().max(1.0) as usize;
let steps = steps.min(100_000);
let at = |frac: f64| -> Point3 {
let a = -TAU * turns * (1.0 - frac);
let radial = n.scale(-a.cos()).add(t.scale(a.sin()));
Point3::new(center.x + radial.x * r, center.y + radial.y * r, entry.z + descent * (1.0 - frac))
};
let mut chords: Vec<Point3> = vec![Point3::new(entry.x, entry.y, zf)];
chords.extend((0..=steps).map(|i| at(i as f64 / steps as f64)));
if prover.polyline_safe(&chords, eps, report, runner)? {
let mut motions = Vec::new();
let start = chords[1];
motions.push(Motion::link(Point3::new(start.x, start.y, zf), ctx.options.high_feed));
let full = turns.floor() as usize;
let pitch = -per_turn;
let c3 = Point3::new(center.x, center.y, entry.z);
for k in 1..=full {
let p = at(k as f64 / turns);
motions.push(Motion::new(MotionKind::ArcCcw { to: p, center: c3, plane: Default::default(), pitch }, Feed::Rate(feed)));
}
if turns - full as f64 > 1e-9 {
motions.push(Motion::new(MotionKind::ArcCcw { to: entry, center: c3, plane: Default::default(), pitch }, Feed::Rate(feed)));
} else if let Some(last) = motions.last_mut() {
if let MotionKind::ArcCcw { to, .. } = &mut last.kind {
*to = entry;
}
}
let ring_at = |a: f64| -> Point3 {
let radial = n.scale(-a.cos()).add(t.scale(a.sin()));
Point3::new(center.x + radial.x * r, center.y + radial.y * r, entry.z)
};
let ring_steps = arc_steps(r, TAU, ctx.options.chord_tolerance).max(2);
let ring: Vec<Point3> = (0..=ring_steps).map(|i| if i == ring_steps { entry } else { ring_at(TAU * i as f64 / ring_steps as f64) }).collect();
if prover.polyline_safe(&ring, eps, report, runner)? {
for to in [ring_at(PI), entry] {
motions.push(Motion::new(MotionKind::ArcCcw { to, center: c3, plane: Default::default(), pitch: 0.0 }, Feed::Rate(feed)));
}
} else {
report.warn(Diagnostic::new("ramp-floor-ring-unproven", "The flat turn at the bottom of the helix could not be proven clear of the part; the floor under the helix is left on its slope").at(entry));
}
Some(motions)
} else {
None
}
}
RampKind::Profile | RampKind::ZigZag => {
let length = ctx.options.ramp_length.unwrap_or(if ramp.helix_diameter > 0.0 { 2.0 * ramp.helix_diameter } else { 4.0 * ctx.cutter.radius() });
let track: Vec<Point3> = if ramp.kind == RampKind::Profile {
let mut pts = vec![entry];
let mut acc = 0.0;
for w in pass.points.windows(2) {
let seg = w[1].distance_xy(w[0]);
if acc + seg >= length {
let f = ((length - acc) / seg).clamp(0.0, 1.0);
pts.push(w[0].lerp(w[1], f));
break;
}
acc += seg;
pts.push(w[1]);
}
pts
} else {
vec![entry, entry.add(t.scale(length))]
};
let track_len: f64 = track.windows(2).map(|w| w[1].distance_xy(w[0])).sum();
if track_len <= eps {
None
} else {
let mut legs = (descent / (track_len * angle.tan())).ceil().max(1.0) as usize;
if legs % 2 == 1 {
legs += 1;
}
let drop_per_leg = descent / legs as f64;
let mut chords = vec![Point3::new(entry.x, entry.y, zf)];
let mut z = zf;
for leg in 0..legs {
let forward = leg % 2 == 0;
let pts: Vec<Point3> = if forward { track.iter().skip(1).copied().collect() } else { track.iter().rev().skip(1).copied().collect() };
let mut travelled = 0.0;
let mut at = if forward { track[0] } else { track[track.len() - 1] };
for p in pts {
travelled += p.distance_xy(at);
at = p;
chords.push(Point3::new(p.x, p.y, z - drop_per_leg * travelled / track_len));
}
z -= drop_per_leg;
}
if let Some(last) = chords.last_mut() {
*last = entry;
}
if prover.polyline_safe(&chords, eps, report, runner)? {
Some(chords.iter().skip(1).map(|p| Motion::new(MotionKind::LeadIn { to: *p }, Feed::Rate(feed))).collect())
} else {
None
}
}
}
RampKind::Plunge => None,
};
if proven.is_none() {
report.warn(Diagnostic::new("ramp-unproven", "The ramp could not be proven clear of material; plunging instead").at(entry));
}
Ok(proven)
}
pub fn order_passes(passes: &[Pass], start: Point3, allow_reverse: bool) -> Vec<(usize, bool)> {
let mut remaining: Vec<usize> = (0..passes.len()).collect();
let mut at = start;
let mut out = Vec::with_capacity(passes.len());
while !remaining.is_empty() {
let mut best: Option<(usize, bool, f64)> = None;
for (k, &i) in remaining.iter().enumerate() {
let p = &passes[i];
let forward = at.distance_xy(p.start());
let candidates: [(bool, f64); 2] = [(false, forward), (true, if allow_reverse { at.distance_xy(p.end()) } else { f64::INFINITY })];
for (rev, cost) in candidates {
let better = match best {
None => true,
Some((_, _, c)) => cost < c - 1e-12,
};
if better {
best = Some((k, rev, cost));
}
}
}
let (k, rev, _) = best.expect("remaining is non-empty");
let i = remaining.remove(k);
at = if rev { passes[i].start() } else { passes[i].end() };
out.push((i, rev));
}
out
}
pub fn passes_of(path: &Toolpath) -> Result<Vec<Pass>, CamError> {
let mut passes = Vec::new();
let mut current: Option<Pass> = None;
let mut at: Option<Point3> = None;
for m in &path.motions {
match &m.kind {
MotionKind::Cut { to } => {
let start = at.ok_or_else(|| CamError::InvalidInput("relink: a cut has no start position".into()))?;
let pass = current.get_or_insert_with(|| Pass::new(vec![start], m.path_id));
pass.points.push(*to);
at = Some(*to);
}
MotionKind::ArcCw { .. } | MotionKind::ArcCcw { .. } => {
return Err(CamError::InvalidInput("relink: the stream already contains arcs; relink before arc fitting".into()));
}
other => {
if let Some(p) = current.take() {
passes.push(p);
}
if let Some(p) = other.end() {
at = Some(p);
}
}
}
}
if let Some(p) = current.take() {
passes.push(p);
}
Ok(passes)
}
pub fn relink(path: &Toolpath, ctx: &LinkContext<'_>, runner: &mut Runner<'_>) -> Result<Toolpath, CamError> {
let passes = passes_of(path)?;
if passes.is_empty() {
return Err(CamError::InvalidInput(format!("relink: operation {} has no cut passes", path.operation)));
}
let linked = connect(&passes, ctx, runner)?;
Ok(merge_linked(path, linked))
}
fn merge_linked(path: &Toolpath, linked: Linked) -> Toolpath {
let mut out = path.clone();
out.motions = linked.motions;
out.report.warnings.retain(|w| w.code != "retract-linking");
out.report.warnings.extend(linked.report.warnings);
out.report.counts.samples += linked.report.counts.samples;
out.report.counts.candidates += linked.report.counts.candidates;
out.report.counts.contacts += linked.report.counts.contacts;
out.report.counts.motions = out.motions.len() as u64;
out.report.finish();
out
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrimReason {
Unreachable,
Holder,
}
impl TrimReason {
pub fn code(self) -> &'static str {
match self {
TrimReason::Unreachable => "unreachable-trimmed",
TrimReason::Holder => "holder-trimmed",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct BlockedSpan {
pub motions: std::ops::Range<usize>,
pub reason: TrimReason,
pub material_top: Option<f64>,
pub reach: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TrimmedSpan {
pub reason: TrimReason,
pub points: Vec<Point3>,
pub bounds: crate::model::Bounds3,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Trimmed {
pub path: Toolpath,
pub spans: Vec<TrimmedSpan>,
}
pub fn trim_blocked(path: &Toolpath, blocked: &[BlockedSpan], ctx: &LinkContext<'_>, runner: &mut Runner<'_>) -> Result<Trimmed, CamError> {
if blocked.is_empty() {
return Ok(Trimmed { path: path.clone(), spans: Vec::new() });
}
let fallback_top = ctx.options.material_top.or_else(|| ctx.stock.and_then(|e| e.max_top()));
let mut reason_of: Vec<Option<usize>> = vec![None; path.motions.len()];
for (k, span) in blocked.iter().enumerate() {
if span.motions.is_empty() || span.motions.end > path.motions.len() {
return Err(CamError::InvalidInput(format!("trim: span {k} ({:?}) is empty or outside the {} motions", span.motions, path.motions.len())));
}
if !(span.reach.is_finite() && span.reach >= 0.0) || span.material_top.is_some_and(|z| !z.is_finite()) {
return Err(CamError::InvalidInput(format!("trim: span {k} has a non-finite reach or material top")));
}
if span.material_top.or(fallback_top).is_none() {
return Err(CamError::InvalidInput(format!("trim: span {k} has no material top and the operation has no stock")));
}
for i in span.motions.clone() {
if !matches!(path.motions[i].kind, MotionKind::Cut { .. }) {
return Err(CamError::InvalidInput(format!("trim: span {k} covers motion {i}, which is not a straight cut")));
}
reason_of[i].get_or_insert(k);
}
}
let mut passes = Vec::new();
let mut current: Option<Pass> = None;
let mut spans: Vec<(usize, Vec<Point3>)> = Vec::new();
let mut open_span: Option<(usize, Vec<Point3>)> = None;
let mut at: Option<Point3> = None;
for (i, m) in path.motions.iter().enumerate() {
match (&m.kind, reason_of[i]) {
(MotionKind::Cut { to }, Some(k)) => {
let start = at.ok_or_else(|| CamError::InvalidInput("trim: a cut has no start position".into()))?;
if let Some(p) = current.take() {
passes.push(p);
}
match &mut open_span {
Some((open, points)) if *open == k => points.push(*to),
_ => {
if let Some(done) = open_span.take() {
spans.push(done);
}
open_span = Some((k, vec![start, *to]));
}
}
at = Some(*to);
}
(MotionKind::Cut { to }, None) => {
if let Some(done) = open_span.take() {
spans.push(done);
}
let start = at.ok_or_else(|| CamError::InvalidInput("trim: a cut has no start position".into()))?;
current.get_or_insert_with(|| Pass::new(vec![start], m.path_id)).points.push(*to);
at = Some(*to);
}
(MotionKind::ArcCw { .. } | MotionKind::ArcCcw { .. }, _) => {
return Err(CamError::InvalidInput("trim: the stream already contains arcs; trim before arc fitting".into()));
}
(other, _) => {
if let Some(done) = open_span.take() {
spans.push(done);
}
if let Some(p) = current.take() {
passes.push(p);
}
if let Some(p) = other.end() {
at = Some(p);
}
}
}
}
if let Some(done) = open_span.take() {
spans.push(done);
}
if let Some(p) = current.take() {
passes.push(p);
}
if passes.is_empty() {
return Err(CamError::InvalidInput(format!("trim: every cut of operation {} is blocked", path.operation)));
}
let trimmed: Vec<TrimmedSpan> = spans
.iter()
.map(|(k, points)| {
let span = &blocked[*k];
let reach = span.reach.max(ctx.cutter.radius());
let top = span.material_top.or(fallback_top).expect("checked above");
let mut bounds = crate::model::Bounds3::from_points(points.iter().copied()).expanded(reach);
bounds.min.z = points.iter().map(|p| p.z).fold(f64::INFINITY, f64::min);
bounds.max.z = top.max(points.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max));
TrimmedSpan { reason: span.reason, points: points.clone(), bounds }
})
.collect();
let keep: Vec<KeepOut> = spans
.iter()
.map(|(k, points)| {
let span = &blocked[*k];
KeepOut { points: points.clone(), reach: span.reach.max(ctx.cutter.radius()), z: span.material_top.or(fallback_top).expect("checked above") }
})
.collect();
let linked = connect_with(&passes, ctx, &keep, runner)?;
let mut out = merge_linked(path, linked);
for reason in [TrimReason::Unreachable, TrimReason::Holder] {
let of: Vec<&TrimmedSpan> = trimmed.iter().filter(|t| t.reason == reason).collect();
let Some(first) = of.first() else { continue };
let shown: Vec<String> = of.iter().take(20).map(|t| { let p = t.points[0]; format!("({:.4}, {:.4}, {:.4})", p.x, p.y, p.z) }).collect();
let more = if of.len() > shown.len() { format!(" and {} more", of.len() - shown.len()) } else { String::new() };
let why = match reason {
TrimReason::Unreachable => "the cutter cannot reach them",
TrimReason::Holder => "the shank or holder meets material there",
};
let spans: Vec<String> = of
.iter()
.take(20)
.map(|t| {
let (a, b) = (t.points[0], t.points[t.points.len() - 1]);
format!("({:.3}, {:.3}, {:.3})-({:.3}, {:.3}, {:.3}) material to {:.3}", a.x, a.y, a.z, b.x, b.y, b.z, t.bounds.max.z)
})
.collect();
out.report.warn(
Diagnostic::new(reason.code(), format!("{} cut spans are left out because {why}: the path breaks, retracts and re-enters; material is left at {}{more}", of.len(), shown.join(", ")))
.at(first.points[0])
.detail(format!("spans (from-to): {}{more}", spans.join(", "))),
);
for _ in &of {
out.report.empty_regions.push(crate::model::EmptyRegion { reason: reason.code().into(), target: None });
}
}
out.report.finish();
Ok(Trimmed { path: out, spans: trimmed })
}
pub fn finish(path: &Toolpath, ctx: &LinkContext<'_>, filter_tolerance: f64, arcs: Option<&crate::arcfit::ArcFitOptions>, runner: &mut Runner<'_>) -> Result<Toolpath, CamError> {
let linked = relink(path, ctx, runner)?;
let filtered = if filter_tolerance > 0.0 { crate::filter::filter_toolpath(&linked, filter_tolerance)? } else { linked };
match arcs {
Some(opts) => crate::arcfit::fit_toolpath(&filtered, opts),
None => Ok(filtered),
}
}