Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- A diameter tool may set `cutter_sense` (`upcut`, `downcut`, or `compression`) and, with compression, `upcut_length` (millimetres at the tip). Omitted, compensation stays the flat diameter offset. Sense is stored on the job and the IR for later checks; it does not change the 2D offset. Posted G-code is unchanged when the field is omitted. This output has not been proven on a machine.

## 0.9.3 — 2026-11-03

Code-generation hardening and in-process stock. A modal replay checker and property tests now cover every built-in post (mill, plasma, laser, Grbl, lathe and mill-turn, five-axis with and without TCP, and 3+1 rotary). The audit found five bugs, fixed below: three in posted output and two in the `compare` checker. Mill jobs may model the in-process stock as a Z-dexel and check rapids and the tool assembly against it, with removal rate and engagement per move. This output has not been proven on a machine.
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ pub(crate) fn parse_tool(value: &Value) -> Result<ParsedTool, CliError> {
let diameter = optional_f64(map, "diameter")?;
let kerf = optional_f64(map, "kerf")?;
let (path, diameter, kerf) = match (diameter, kerf) {
(Some(diameter), None) => (PathTool::Diameter { diameter }, Some(diameter), None),
(Some(diameter), None) => (PathTool::diameter(diameter), Some(diameter), None),
(None, Some(kerf)) => (PathTool::Kerf { width: kerf }, None, Some(kerf)),
(Some(_), Some(_)) => return Err(CliError::invalid("tool has both diameter and kerf")),
(None, None) => return Err(CliError::invalid("tool needs a diameter or a kerf")),
Expand Down
266 changes: 259 additions & 7 deletions crates/cli/src/jobfile.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/cli/src/live_mill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ fn x_span(name: &str, contours: &[Contour]) -> Result<f64, CliError> {
fn toolpath_moves(spec: &MillSpec, contours: &[Contour]) -> Result<Vec<Move>, CliError> {
let depths = pass_depths(spec.depth, spec.step_down).map_err(|err| op_err(&spec.name, err))?;
let tolerance = Tolerance::new(spec.tolerance, Tolerance::DEFAULT_ANGULAR_RAD).map_err(|err| op_err(&spec.name, err))?;
let tool = Tool::Diameter { diameter: spec.tool_diameter };
let tool = Tool::diameter(spec.tool_diameter);
let mut moves = Vec::new();
match spec.strategy {
MillStrategy::Profile { side } => {
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ fn push_wall_contour(cut: &Cut<'_>, contour: &Contour, side: ProfileSide, finish
/// Those rapids share one Z, so `campanion verify` (safe height inferred from the rapids)
/// accepts them. The document is built the same way a drill is.
fn face_paths(paths: &mut Vec<ClPath>, cut: &Cut<'_>) -> Result<(), CliError> {
let PathTool::Diameter { diameter } = cut.tool.path_tool() else {
let PathTool::Diameter { diameter, .. } = cut.tool.path_tool() else {
return Err(op_err(cut.op, "face is not valid with a kerf tool"));
};
let Some(stepover) = cut.op.stepover() else {
Expand Down Expand Up @@ -1080,7 +1080,7 @@ fn is_not_circle(err: &impl std::fmt::Display) -> bool {

/// One V-bit plunge per closed contour, then the same clearance link an engrave uses.
fn chamfer_paths(paths: &mut Vec<ClPath>, cut: &Cut<'_>, side: ProfileSide) -> Result<(), CliError> {
let PathTool::Diameter { diameter } = cut.tool.path_tool() else {
let PathTool::Diameter { diameter, .. } = cut.tool.path_tool() else {
return Err(op_err(cut.op, "chamfer is not valid with a kerf tool"));
};
let mut features = Vec::with_capacity(cut.contours.len());
Expand Down
1 change: 1 addition & 0 deletions crates/cli/tests/run_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fn sample_jobs_write_ngc_and_clean_ir() -> Result<(), String> {
("examples/sample_delays.job.json", true),
("examples/sample_svg.job.json", false),
("examples/curves.job.json", false),
("examples/router_cutter_sense.job.json", false),
] {
if let Err(err) = check_job(&root, job, delays) {
failures.push(err);
Expand Down
2 changes: 1 addition & 1 deletion crates/ir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ pub use kinematics::{
pub use machine::{Axis, AxisIndex, AxisKind, AxisLetter, AxisLimits, AxisName, Channel, KinematicNode, Machine};
pub use motion::{AxisBlock, AxisMotion, AxisTarget, ChannelProgram, ClBlock, ClMotion, ClPath, CompensationMode, Feeds, MoveRole, ProfileTab};
pub use ngc::{NgcOptions, ProgramEnd, write_ngc, write_ngc_with};
pub use process::{AssemblySegment, CoolantMode, Event, LiveDirection, ParamValue, ProcessKind, ProcessParams, Tool, ToolAssembly};
pub use process::{AssemblySegment, CoolantMode, CutterSense, Event, LiveDirection, ParamValue, ProcessKind, ProcessParams, Tool, ToolAssembly};
pub use program::{DEFAULT_MAX_AXIS_STEP_DEG, Document, Fixture, FixtureBox, Program, SyncPoint};
pub use rotary::{
RotaryAxisKind, RotaryDirection, RotaryLimits, RotarySetup, index_angle_for_axis, machine_to_part, part_to_machine, rotate_point, rotate_vector,
Expand Down
157 changes: 155 additions & 2 deletions crates/ir/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ use crate::Result;
use crate::check::{self, finite, non_negative, optional_name, positive, required_name, text_len};
use crate::geom::Pose;

/// Helix sense of a rotating router cutter.
///
/// Stored on a diameter tool. Omitted, compensation stays the flat diameter offset. Sense is
/// data for the job and the verifier; it does not change the 2D offset.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CutterSense {
/// Chips climb the flute toward the shank.
Upcut,
/// Chips are pushed toward the tip.
Downcut,
/// Upcut at the tip, downcut above that. Requires [`Tool`]'s `upcut_length`.
Compression,
}

/// A cutter or a kerf-only process.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
Expand All @@ -28,11 +43,17 @@ pub struct Tool {
/// Holder and head envelope. Absent on a tool that does not name one, so older files stay unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
assembly: Option<ToolAssembly>,
/// Router flute helix. Omitted from JSON when absent, so older files stay unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
cutter_sense: Option<CutterSense>,
/// Upcut length at the tip, millimetres. Only with [`CutterSense::Compression`].
#[serde(default, skip_serializing_if = "Option::is_none")]
upcut_length: Option<f64>,
}

impl Tool {
pub fn try_new(id: u32, name: Option<String>, diameter: Option<f64>, kerf: Option<f64>) -> Result<Self> {
let tool = Self { id, name, diameter, kerf, assembly: None };
let tool = Self { id, name, diameter, kerf, assembly: None, cutter_sense: None, upcut_length: None };
tool.check()?;
Ok(tool)
}
Expand All @@ -44,6 +65,17 @@ impl Tool {
Ok(tool)
}

/// Store router cutter sense and re-validate. A kerf tool cannot carry one.
///
/// [`CutterSense::Compression`] needs a positive finite `upcut_length` at most the flute
/// length and stickout when an assembly is present, otherwise at most `diameter * 100`.
/// Other senses reject `upcut_length`.
pub fn with_cutter_sense(self, cutter_sense: Option<CutterSense>, upcut_length: Option<f64>) -> Result<Self> {
let tool = Self { cutter_sense, upcut_length, ..self };
tool.check()?;
Ok(tool)
}

pub fn id(&self) -> u32 {
self.id
}
Expand All @@ -65,6 +97,16 @@ impl Tool {
self.assembly.as_ref()
}

/// Router flute helix. `None` when the file omits `cutter_sense`.
pub fn cutter_sense(&self) -> Option<CutterSense> {
self.cutter_sense
}

/// Upcut length at the tip, millimetres. `None` unless the sense is compression.
pub fn upcut_length(&self) -> Option<f64> {
self.upcut_length
}

pub(crate) fn check(&self) -> Result<()> {
optional_name(&self.name, "tool name")?;
if let Some(diameter) = self.diameter {
Expand All @@ -79,7 +121,7 @@ impl Tool {
}
assembly.check()?;
}
Ok(())
check_tool_cutter_sense(self.diameter, self.kerf, self.assembly.as_ref(), self.cutter_sense, self.upcut_length)
}
}

Expand Down Expand Up @@ -204,6 +246,44 @@ fn check_cutter(value: f64, what: &str) -> Result<()> {
if value > check::MAX_CUTTER_MM { Err(check::invalid(format!("{what} is larger than {} mm", check::MAX_CUTTER_MM))) } else { Ok(()) }
}

fn check_tool_cutter_sense(
diameter: Option<f64>,
kerf: Option<f64>,
assembly: Option<&ToolAssembly>,
cutter_sense: Option<CutterSense>,
upcut_length: Option<f64>,
) -> Result<()> {
if cutter_sense.is_none() && upcut_length.is_none() {
return Ok(());
}
let Some(diameter) = diameter.filter(|_| kerf.is_none()) else {
return Err(check::invalid("cutter_sense is not valid on a kerf tool"));
};
match cutter_sense {
None | Some(CutterSense::Upcut) | Some(CutterSense::Downcut) => {
if upcut_length.is_some() {
return Err(check::invalid("upcut_length is only valid with compression"));
}
Ok(())
}
Some(CutterSense::Compression) => {
let Some(length) = upcut_length else {
return Err(check::invalid("compression needs upcut_length"));
};
let length = positive(length, "upcut_length")?;
let (cap, what) = match assembly {
Some(assembly) if assembly.flute_length() < assembly.stickout() => (assembly.flute_length(), "flute_length"),
Some(assembly) => (assembly.stickout(), "stickout"),
None => (diameter * 100.0, "diameter * 100"),
};
if length > cap {
return Err(check::invalid(format!("upcut_length must be at most {what}")));
}
Ok(())
}
}
}

/// What a process-on event turns on.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
Expand Down Expand Up @@ -767,3 +847,76 @@ impl Event {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::program::{Document, Program};

fn mill(id: u32, diameter: f64) -> Tool {
Tool::try_new(id, None, Some(diameter), None).expect("mill")
}

fn holder(stickout: f64, flute: Option<f64>) -> ToolAssembly {
ToolAssembly::try_new(stickout, flute, None, vec![AssemblySegment::try_new(16.0, 10.0).expect("holder")], None).expect("assembly")
}

fn round_trip(tool: &Tool) -> Tool {
let program = Program::try_new(None, vec![tool.clone()], None, Vec::new(), Vec::new(), Vec::new(), None).expect("program");
let doc = Document::new(program).expect("doc");
let json = doc.to_json().expect("json");
let loaded = Document::from_json(&json).expect("load");
loaded.program().tools()[0].clone()
}

#[test]
fn omitted_cutter_sense_is_dropped_from_json() {
let tool = mill(1, 6.0);
let json = serde_json::to_string(&tool).expect("json");
assert!(!json.contains("cutter_sense"), "{json}");
assert!(!json.contains("upcut_length"), "{json}");
let back = round_trip(&tool);
assert_eq!(back.cutter_sense(), None);
assert_eq!(back.upcut_length(), None);
}

#[test]
fn cutter_sense_round_trips() {
let upcut = mill(1, 6.0).with_cutter_sense(Some(CutterSense::Upcut), None).expect("upcut");
let back = round_trip(&upcut);
assert_eq!(back.cutter_sense(), Some(CutterSense::Upcut));
assert_eq!(back.upcut_length(), None);
let json = serde_json::to_string(&upcut).expect("json");
assert!(json.contains("\"cutter_sense\":\"upcut\""), "{json}");
assert!(!json.contains("upcut_length"), "{json}");

let compression = mill(2, 6.0).with_cutter_sense(Some(CutterSense::Compression), Some(12.0)).expect("compression");
let back = round_trip(&compression);
assert_eq!(back.cutter_sense(), Some(CutterSense::Compression));
assert_eq!(back.upcut_length(), Some(12.0));
let json = serde_json::to_string(&compression).expect("json");
assert!(json.contains("\"cutter_sense\":\"compression\""), "{json}");
assert!(json.contains("\"upcut_length\":12"), "{json}");
}

#[test]
fn cutter_sense_rejects_illegal_combinations() {
let tool = mill(1, 6.0);
assert!(tool.clone().with_cutter_sense(Some(CutterSense::Upcut), Some(1.0)).is_err());
assert!(tool.clone().with_cutter_sense(Some(CutterSense::Downcut), Some(1.0)).is_err());
assert!(tool.clone().with_cutter_sense(Some(CutterSense::Compression), None).is_err());
assert!(tool.clone().with_cutter_sense(None, Some(1.0)).is_err());
assert!(tool.clone().with_cutter_sense(Some(CutterSense::Compression), Some(0.0)).is_err());
assert!(tool.clone().with_cutter_sense(Some(CutterSense::Compression), Some(601.0)).is_err());
assert!(tool.with_cutter_sense(Some(CutterSense::Compression), Some(600.0)).is_ok());

let kerf = Tool::try_new(2, None, None, Some(1.2)).expect("kerf");
assert!(kerf.with_cutter_sense(Some(CutterSense::Upcut), None).is_err());

let short =
mill(3, 6.0).with_assembly(holder(20.0, Some(8.0))).expect("assembly").with_cutter_sense(Some(CutterSense::Compression), Some(9.0));
assert!(short.is_err());
let ok = mill(4, 6.0).with_assembly(holder(20.0, Some(8.0))).expect("assembly").with_cutter_sense(Some(CutterSense::Compression), Some(8.0));
assert!(ok.is_ok());
}
}
16 changes: 6 additions & 10 deletions crates/ir/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,12 @@ fn too_many_axes_are_rejected() {

#[test]
fn profile_converts_to_ir() {
let profile = ProfileOp::new(
PathTool::Diameter { diameter: 6.0 },
Compensation::Controller(ControllerSide::Left),
ProfileSide::On,
CutDirection::Conventional,
)
.lead_in(Lead::Arc { radius: 3.0 })
.tabs(TabSpec::count(1, 4.0).height(2.0))
.generate(&rect(40.0, 20.0))
.expect("profile");
let profile =
ProfileOp::new(PathTool::diameter(6.0), Compensation::Controller(ControllerSide::Left), ProfileSide::On, CutDirection::Conventional)
.lead_in(Lead::Arc { radius: 3.0 })
.tabs(TabSpec::count(1, 4.0).height(2.0))
.generate(&rect(40.0, 20.0))
.expect("profile");
let feeds = Feeds::try_new(1000.0, Some(600.0), Some(700.0), Some(300.0), Some(800.0)).expect("feeds");
let params =
ProcessParams::new().speed(Some(12_000.0)).expect("speed").pierce_delay(0.3, false).expect("pierce").post_delay(0.15, true).expect("post");
Expand Down
2 changes: 1 addition & 1 deletion crates/path/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub use profile::{
CutDirection, DEFAULT_CLEARANCE_MM, DEFAULT_RETRACT_MM, Lead, Profile, ProfileOp, ProfileSide, StartPoint, Tab, TabLayout, TabSpec,
};
pub use timing::{ProcessTiming, apply_timing};
pub use tool::{Compensation, ControllerSide, Tool};
pub use tool::{Compensation, ControllerSide, CutterSense, Tool, check_cutter_sense};
pub use toolpath::{CutKind, Move, Point3, ProcessEvent, Toolpath};

/// Crate version from the package manifest.
Expand Down
6 changes: 3 additions & 3 deletions crates/path/src/pocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1445,7 +1445,7 @@ mod tests {
}

fn tool_diameter(diameter: f64) -> Tool {
Tool::Diameter { diameter }
Tool::diameter(diameter)
}

fn rings_op(stepover: f64) -> PocketOp {
Expand Down Expand Up @@ -2062,7 +2062,7 @@ mod tests {
let region =
if holes.is_empty() { Region::from_contour(outer.clone(), &tol)? } else { Region::try_new(outer.clone(), holes.to_vec(), &tol)? };
let clearance = offset_region(&region, radius, &tol)?;
let cut = PocketOp::new(Tool::Diameter { diameter }, PocketStrategy::OffsetRings, stepover).tolerance(tol).generate(&region)?;
let cut = PocketOp::new(Tool::diameter(diameter), PocketStrategy::OffsetRings, stepover).tolerance(tol).generate(&region)?;
let segs = cut_segments(cut.toolpath())?;
let samples = samples_of(cut.toolpath());
if segs.is_empty() || samples.is_empty() {
Expand Down Expand Up @@ -2388,7 +2388,7 @@ mod tests {
DepthEntry::Helix { radius, pitch } => (pitch / (radius * TAU)).atan(),
DepthEntry::Plunge => return Err(invalid("expected a ramp or helix")),
};
let cut = PocketOp::new(Tool::Diameter { diameter }, PocketStrategy::OffsetRings, stepover)
let cut = PocketOp::new(Tool::diameter(diameter), PocketStrategy::OffsetRings, stepover)
.z(-1.0)
.retract_z(2.0)
.entry(entry)
Expand Down
Loading
Loading