diff --git a/CHANGELOG.md b/CHANGELOG.md index b513f09..d938114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/cli/src/job.rs b/crates/cli/src/job.rs index f082e10..8622b9b 100644 --- a/crates/cli/src/job.rs +++ b/crates/cli/src/job.rs @@ -238,7 +238,7 @@ pub(crate) fn parse_tool(value: &Value) -> Result { 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")), diff --git a/crates/cli/src/jobfile.rs b/crates/cli/src/jobfile.rs index 523decc..a1c9ab0 100644 --- a/crates/cli/src/jobfile.rs +++ b/crates/cli/src/jobfile.rs @@ -117,6 +117,10 @@ //! `collision` sets the clearance, sample step and axis-angle step used when `campanion run` //! checks the assembly against the finished part and the fixtures. A kerf tool cannot carry an //! assembly. See `docs/job-file.md`. +//! +//! A diameter tool may set `cutter_sense`: `"upcut"`, `"downcut"`, or `"compression"`. +//! `"compression"` requires `upcut_length`, a positive length in `units` at the tip. Omitted, +//! compensation stays the flat diameter offset. Sense does not change that offset. use std::fmt; use std::fs::File; @@ -125,10 +129,10 @@ use std::path::{Component, Path, PathBuf}; use campanion_geom::Tolerance; use campanion_ir::{ - Feeds, FiveAxisSetup, Kinematics, ProcessParams, RotaryAxisKind, RotaryDirection, RotaryLimits, RotarySetup, SolutionPolicy, Tool as IrTool, - ToolPose, Vec3, + CutterSense as IrCutterSense, Feeds, FiveAxisSetup, Kinematics, ProcessParams, RotaryAxisKind, RotaryDirection, RotaryLimits, RotarySetup, + SolutionPolicy, Tool as IrTool, ToolPose, Vec3, }; -use campanion_path::{DepthEntry, ProfileSide, TabLayout, TabSpec, Tool as PathTool}; +use campanion_path::{CutterSense, DepthEntry, ProfileSide, TabLayout, TabSpec, Tool as PathTool}; use serde_json::{Map, Number, Value}; use crate::error::{CliError, MAX_JSON_BYTES}; @@ -560,6 +564,16 @@ impl JobTool { pub fn assembly(&self) -> Option<&ToolAssembly> { self.assembly.as_ref() } + + /// Router flute helix. `None` when the file omits `cutter_sense`. + pub fn cutter_sense(&self) -> Option { + self.path_tool.cutter_sense() + } + + /// Upcut length at the tip, millimetres. `None` unless the sense is compression. + pub fn upcut_length(&self) -> Option { + self.path_tool.upcut_length() + } } /// What an operation asks the path engine to do. Geometry comes from the source layers. @@ -1565,11 +1579,13 @@ fn parse_tools(value: &Value, scale: f64) -> Result, JobFileError> fn parse_tool_value(value: &Value, scale: f64) -> Result { let map = object(value, "tool").map_err(from_cli)?; - check_keys(map, &["id", "diameter", "kerf", "shape", "speed", "assembly"], "tool").map_err(from_cli)?; + check_keys(map, &["id", "diameter", "kerf", "shape", "speed", "assembly", "cutter_sense", "upcut_length"], "tool").map_err(from_cli)?; let mut scaled = map.clone(); scaled.remove("shape"); scaled.remove("speed"); scaled.remove("assembly"); + scaled.remove("cutter_sense"); + scaled.remove("upcut_length"); scale_key(&mut scaled, "diameter", scale)?; scale_key(&mut scaled, "kerf", scale)?; let parsed = parse_tool(&Value::Object(scaled)).map_err(from_cli)?; @@ -1577,7 +1593,58 @@ fn parse_tool_value(value: &Value, scale: f64) -> Result let shape = parse_tool_shape(optional(map, "shape"), parsed.ir().diameter(), scale)?; let speed = parse_rpm(optional(map, "speed"))?; let assembly = parse_assembly(optional(map, "assembly"), parsed.ir().diameter(), scale)?; - Ok(JobTool { id: parsed.ir().id(), path_tool: parsed.path_tool(), ir: parsed.ir().clone(), shape, speed, assembly }) + let (cutter_sense, upcut_length) = + parse_cutter_sense(optional(map, "cutter_sense"), optional(map, "upcut_length"), parsed.ir().diameter(), assembly.as_ref(), scale)?; + let path_tool = + parsed.path_tool().with_cutter_sense(cutter_sense, upcut_length).map_err(|err| JobFileError::Invalid { message: err.to_string() })?; + let ir = parsed + .ir() + .clone() + .with_cutter_sense(cutter_sense.map(ir_cutter_sense), upcut_length) + .map_err(|err| JobFileError::Invalid { message: err.to_string() })?; + Ok(JobTool { id: parsed.ir().id(), path_tool, ir, shape, speed, assembly }) +} + +fn ir_cutter_sense(sense: CutterSense) -> IrCutterSense { + match sense { + CutterSense::Upcut => IrCutterSense::Upcut, + CutterSense::Downcut => IrCutterSense::Downcut, + CutterSense::Compression => IrCutterSense::Compression, + } +} + +/// `"upcut"`, `"downcut"`, or `"compression"`. `upcut_length` is a length in file units. +fn parse_cutter_sense( + sense_value: Option<&Value>, + length_value: Option<&Value>, + diameter: Option, + assembly: Option<&ToolAssembly>, + scale: f64, +) -> Result<(Option, Option), JobFileError> { + if sense_value.is_none() && length_value.is_none() { + return Ok((None, None)); + } + let Some(diameter) = diameter else { + return Err(JobFileError::invalid("cutter_sense is only valid on a diameter tool")); + }; + let Some(sense_value) = sense_value else { + return Err(JobFileError::invalid("upcut_length is only valid with compression")); + }; + let sense = match as_str(sense_value, "cutter_sense").map_err(from_cli)? { + "upcut" => CutterSense::Upcut, + "downcut" => CutterSense::Downcut, + "compression" => CutterSense::Compression, + other => return Err(JobFileError::invalid(format!("unknown cutter_sense `{other}`"))), + }; + let upcut_length = match length_value { + Some(value) => Some(positive_length(value, scale, "upcut_length")?), + None => None, + }; + let stickout = assembly.map(ToolAssembly::stickout); + let flute_length = assembly.map(ToolAssembly::flute_length); + campanion_path::check_cutter_sense(Some(diameter), stickout, flute_length, Some(sense), upcut_length) + .map_err(|err| JobFileError::Invalid { message: err.to_string() })?; + Ok((Some(sense), upcut_length)) } /// `"flat"`, `"ball"`, or `{"bull": corner_radius}`. The corner radius is a length in file units. @@ -3566,8 +3633,9 @@ enum DotFold { #[cfg(test)] mod tests { use super::*; - use campanion_ir::{SolutionPolicy, Vec3}; + use campanion_ir::{Document, Program, SolutionPolicy, Vec3}; use campanion_path::Tool as PathTool; + use proptest::prelude::*; use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -3660,7 +3728,7 @@ mod tests { assert_eq!(job.retract(), 2.0); let diameter = job.tool(1).expect("tool 1"); - assert_eq!(diameter.path_tool(), PathTool::Diameter { diameter: 6.0 }); + assert_eq!(diameter.path_tool(), PathTool::diameter(6.0)); assert_eq!(diameter.ir().diameter(), Some(6.0)); assert_eq!(diameter.ir().kerf(), None); assert_eq!(diameter.speed(), None); @@ -3767,6 +3835,190 @@ mod tests { assert!(err.to_string().contains("speed must be positive and at most 100000"), "{err}"); } + #[test] + fn parses_cutter_sense_on_diameter_tools() { + let upcut = + load(&write_job("sense-upcut", &envelope("plate.dxf", r#"[{"id":1,"diameter":6,"cutter_sense":"upcut"}]"#, "[]"))).expect("upcut"); + let tool = upcut.tool(1).expect("tool"); + assert_eq!(tool.cutter_sense(), Some(CutterSense::Upcut)); + assert_eq!(tool.upcut_length(), None); + assert_eq!(tool.ir().cutter_sense(), Some(IrCutterSense::Upcut)); + assert_eq!(tool.ir().upcut_length(), None); + near(tool.path_tool().compensation_distance().expect("radius"), 3.0); + + let downcut = + load(&write_job("sense-downcut", &envelope("plate.dxf", r#"[{"id":1,"diameter":6,"cutter_sense":"downcut"}]"#, "[]"))).expect("downcut"); + assert_eq!(downcut.tool(1).expect("tool").cutter_sense(), Some(CutterSense::Downcut)); + + let compression = load(&write_job( + "sense-compression", + &envelope("plate.dxf", r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":12}]"#, "[]"), + )) + .expect("compression"); + let tool = compression.tool(1).expect("tool"); + assert_eq!(tool.cutter_sense(), Some(CutterSense::Compression)); + near(tool.upcut_length().expect("length"), 12.0); + assert_eq!(tool.ir().cutter_sense(), Some(IrCutterSense::Compression)); + near(tool.ir().upcut_length().expect("ir length"), 12.0); + near(tool.path_tool().compensation_distance().expect("radius"), 3.0); + + let omitted = load(&write_job("sense-omitted", &envelope("plate.dxf", r#"[{"id":1,"diameter":6}]"#, "[]"))).expect("omitted"); + let tool = omitted.tool(1).expect("tool"); + assert_eq!(tool.cutter_sense(), None); + assert_eq!(tool.upcut_length(), None); + let json = serde_json::to_string(tool.ir()).expect("json"); + assert!(!json.contains("cutter_sense"), "{json}"); + assert!(!json.contains("upcut_length"), "{json}"); + + let with_assembly = load(&write_job( + "sense-assembly", + &envelope( + "plate.dxf", + r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":8,"assembly":{"stickout":20,"flute_length":10,"holder":[{"diameter":12,"length":8}]}}]"#, + "[]", + ), + )) + .expect("assembly"); + near(with_assembly.tool(1).expect("tool").upcut_length().expect("length"), 8.0); + + let inch = load(&write_job( + "sense-inch", + r#"{"format":"campanion-job/1","source":{"path":"plate.dxf","hash":"cbf29ce484222325"},"units":"inch","tools":[{"id":1,"diameter":0.25,"cutter_sense":"compression","upcut_length":0.5}],"operations":[]}"#, + )) + .expect("inch"); + near(inch.tool(1).expect("tool").upcut_length().expect("length"), 0.5 * 25.4); + } + + #[test] + fn cutter_sense_matches_bare_diameter_compensation() { + let bare = load(&write_job("sense-comp-bare", &envelope("plate.dxf", r#"[{"id":1,"diameter":6}]"#, "[]"))).expect("bare"); + let sensed = load(&write_job( + "sense-comp-set", + &envelope("plate.dxf", r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":12}]"#, "[]"), + )) + .expect("sensed"); + let left = bare.tool(1).expect("bare").path_tool().compensation_distance().expect("bare radius"); + let right = sensed.tool(1).expect("sensed").path_tool().compensation_distance().expect("sensed radius"); + near(left, right); + near(left, 3.0); + } + + #[test] + fn rejects_illegal_cutter_sense() { + let too_long_flute = concat!( + r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":12,"#, + r#""assembly":{"stickout":20,"flute_length":10,"holder":[{"diameter":12,"length":8}]}}]"# + ); + let cases = [ + (r#"[{"id":1,"diameter":6,"cutter_sense":"climb"}]"#, "unknown cutter_sense `climb`"), + (r#"[{"id":1,"diameter":6,"cutter_sense":"upcut","upcut_length":1}]"#, "upcut_length is only valid with compression"), + (r#"[{"id":1,"diameter":6,"cutter_sense":"downcut","upcut_length":1}]"#, "upcut_length is only valid with compression"), + (r#"[{"id":1,"diameter":6,"cutter_sense":"compression"}]"#, "compression needs upcut_length"), + (r#"[{"id":1,"diameter":6,"upcut_length":1}]"#, "upcut_length is only valid with compression"), + (r#"[{"id":1,"kerf":1.2,"cutter_sense":"upcut"}]"#, "cutter_sense is only valid on a diameter tool"), + (r#"[{"id":1,"kerf":1.2,"upcut_length":1}]"#, "cutter_sense is only valid on a diameter tool"), + (r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":0}]"#, "upcut_length must be greater than 0"), + (r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":-1}]"#, "upcut_length must be greater than 0"), + (r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":601}]"#, "upcut_length must be at most diameter * 100"), + (too_long_flute, "upcut_length must be at most flute_length"), + ( + r#"[{"id":1,"diameter":6,"cutter_sense":"compression","upcut_length":21,"assembly":{"stickout":20,"holder":[{"diameter":12,"length":8}]}}]"#, + "upcut_length must be at most stickout", + ), + (r#"[{"id":1,"diameter":6,"cutter_sense":"upcut","foo":1}]"#, "unknown tool field `foo`"), + ]; + for (index, (tools, needle)) in cases.iter().enumerate() { + let err = load(&write_job(&format!("sense-bad-{index}"), &envelope("plate.dxf", tools, "[]"))).expect_err(needle); + assert!(invalid_detail(&err).contains(needle), "{needle}: {err}"); + } + } + + #[test] + fn example_router_cutter_sense_job_loads() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/router_cutter_sense.job.json"); + let job = load(&path).expect("example"); + let tool = job.tool(1).expect("tool"); + assert_eq!(tool.cutter_sense(), Some(CutterSense::Compression)); + near(tool.upcut_length().expect("length"), 12.0); + near(tool.path_tool().compensation_distance().expect("radius"), 3.0); + } + + fn cutter_sense_config() -> ProptestConfig { + ProptestConfig { cases: 24, max_shrink_iters: 20, rng_seed: proptest::test_runner::RngSeed::Fixed(0xC077_E5E1), ..ProptestConfig::default() } + } + + fn ir_round_trip_tool(tool: &IrTool) -> IrTool { + 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() + } + + proptest! { + #![proptest_config(cutter_sense_config())] + #[test] + fn diameter_tools_with_sense_round_trip_job_to_ir_json( + diameter in 0.5f64..40.0, + kind in 0u8..4, + upcut_frac in 0.05f64..1.0, + with_assembly in proptest::bool::ANY, + stickout in 15.0f64..80.0, + flute_frac in 0.2f64..1.0, + ) { + prop_assume!(diameter.is_finite() && stickout.is_finite()); + let (sense, length, assembly) = match kind { + 0 => (None, None, None), + 1 => (Some("upcut"), None, None), + 2 => (Some("downcut"), None, None), + _ => { + let flute = if with_assembly { Some(stickout * flute_frac) } else { None }; + let cap = flute.unwrap_or(diameter * 100.0); + let length = (cap * upcut_frac).max(1e-6); + let assembly = if with_assembly { + Some(format!( + r#","assembly":{{"stickout":{stickout},"flute_length":{},"holder":[{{"diameter":12,"length":8}}]}}"#, + flute.expect("flute") + )) + } else { + None + }; + (Some("compression"), Some(length), assembly) + } + }; + let mut tool = format!(r#"{{"id":1,"diameter":{diameter}"#); + if let Some(sense) = sense { + tool.push_str(&format!(r#","cutter_sense":"{sense}""#)); + } + if let Some(length) = length { + tool.push_str(&format!(r#","upcut_length":{length}"#)); + } + if let Some(assembly) = assembly { + tool.push_str(&assembly); + } + tool.push('}'); + let job = load(&write_job("sense-prop", &envelope("plate.dxf", &format!("[{tool}]"), "[]"))).expect("load"); + let parsed = job.tool(1).expect("tool"); + let back = ir_round_trip_tool(parsed.ir()); + prop_assert_eq!(back.cutter_sense(), parsed.ir().cutter_sense()); + match (back.upcut_length(), parsed.ir().upcut_length()) { + (None, None) => {} + (Some(left), Some(right)) => prop_assert!((left - right).abs() < 1e-9, "{left} {right}"), + other => prop_assert!(false, "upcut_length {other:?}"), + } + let json = serde_json::to_string(parsed.ir()).expect("json"); + if sense.is_none() { + prop_assert!(!json.contains("cutter_sense"), "{json}"); + prop_assert!(!json.contains("upcut_length"), "{json}"); + } else { + prop_assert!(json.contains("cutter_sense"), "{json}"); + } + let expected = parsed.path_tool().compensation_distance().expect("radius"); + let bare = PathTool::diameter(diameter).compensation_distance().expect("bare"); + prop_assert!((expected - bare).abs() < 1e-12); + } + } + #[test] fn converts_inches_heights_and_timing_toggles() { let text = r#"{ diff --git a/crates/cli/src/live_mill.rs b/crates/cli/src/live_mill.rs index eb56f5d..8b0003b 100644 --- a/crates/cli/src/live_mill.rs +++ b/crates/cli/src/live_mill.rs @@ -212,7 +212,7 @@ fn x_span(name: &str, contours: &[Contour]) -> Result { fn toolpath_moves(spec: &MillSpec, contours: &[Contour]) -> Result, 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 } => { diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index d9d76e6..25b1077 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -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, 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 { @@ -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, 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()); diff --git a/crates/cli/tests/run_e2e.rs b/crates/cli/tests/run_e2e.rs index bc6b5aa..857ed6d 100644 --- a/crates/cli/tests/run_e2e.rs +++ b/crates/cli/tests/run_e2e.rs @@ -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); diff --git a/crates/ir/src/lib.rs b/crates/ir/src/lib.rs index 52c1a16..c2c91e8 100644 --- a/crates/ir/src/lib.rs +++ b/crates/ir/src/lib.rs @@ -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, diff --git a/crates/ir/src/process.rs b/crates/ir/src/process.rs index ac01f6e..05a449d 100644 --- a/crates/ir/src/process.rs +++ b/crates/ir/src/process.rs @@ -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)] @@ -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, + /// Router flute helix. Omitted from JSON when absent, so older files stay unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + cutter_sense: Option, + /// Upcut length at the tip, millimetres. Only with [`CutterSense::Compression`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + upcut_length: Option, } impl Tool { pub fn try_new(id: u32, name: Option, diameter: Option, kerf: Option) -> Result { - 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) } @@ -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, upcut_length: Option) -> Result { + let tool = Self { cutter_sense, upcut_length, ..self }; + tool.check()?; + Ok(tool) + } + pub fn id(&self) -> u32 { self.id } @@ -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 { + self.cutter_sense + } + + /// Upcut length at the tip, millimetres. `None` unless the sense is compression. + pub fn upcut_length(&self) -> Option { + self.upcut_length + } + pub(crate) fn check(&self) -> Result<()> { optional_name(&self.name, "tool name")?; if let Some(diameter) = self.diameter { @@ -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) } } @@ -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, + kerf: Option, + assembly: Option<&ToolAssembly>, + cutter_sense: Option, + upcut_length: Option, +) -> 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)] @@ -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) -> 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()); + } +} diff --git a/crates/ir/src/tests.rs b/crates/ir/src/tests.rs index 7171daf..21cda9a 100644 --- a/crates/ir/src/tests.rs +++ b/crates/ir/src/tests.rs @@ -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"); diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 39f5299..187ce8a 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -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. diff --git a/crates/path/src/pocket.rs b/crates/path/src/pocket.rs index 6a9920e..cf24c91 100644 --- a/crates/path/src/pocket.rs +++ b/crates/path/src/pocket.rs @@ -1445,7 +1445,7 @@ mod tests { } fn tool_diameter(diameter: f64) -> Tool { - Tool::Diameter { diameter } + Tool::diameter(diameter) } fn rings_op(stepover: f64) -> PocketOp { @@ -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(®ion, radius, &tol)?; - let cut = PocketOp::new(Tool::Diameter { diameter }, PocketStrategy::OffsetRings, stepover).tolerance(tol).generate(®ion)?; + let cut = PocketOp::new(Tool::diameter(diameter), PocketStrategy::OffsetRings, stepover).tolerance(tol).generate(®ion)?; let segs = cut_segments(cut.toolpath())?; let samples = samples_of(cut.toolpath()); if segs.is_empty() || samples.is_empty() { @@ -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) diff --git a/crates/path/src/profile.rs b/crates/path/src/profile.rs index 4490159..5481dab 100644 --- a/crates/path/src/profile.rs +++ b/crates/path/src/profile.rs @@ -1229,7 +1229,7 @@ mod tests { } fn tool2() -> Tool { - Tool::Diameter { diameter: 2.0 } + Tool::diameter(2.0) } fn outside_conventional() -> ProfileOp { @@ -1598,9 +1598,9 @@ mod tests { outside_conventional().lead_in(Lead::Arc { radius: f64::NAN }), outside_conventional().lead_in(Lead::Perpendicular { length: f64::NEG_INFINITY }), outside_conventional().lead_out(Lead::Arc { radius: 0.0 }), - ProfileOp::new(Tool::Diameter { diameter: f64::NAN }, Compensation::Computed, ProfileSide::Outside, CutDirection::Climb), - ProfileOp::new(Tool::Diameter { diameter: 0.0 }, Compensation::Computed, ProfileSide::Outside, CutDirection::Climb), - ProfileOp::new(Tool::Diameter { diameter: -4.0 }, Compensation::Computed, ProfileSide::Inside, CutDirection::Conventional), + ProfileOp::new(Tool::diameter(f64::NAN), Compensation::Computed, ProfileSide::Outside, CutDirection::Climb), + ProfileOp::new(Tool::diameter(0.0), Compensation::Computed, ProfileSide::Outside, CutDirection::Climb), + ProfileOp::new(Tool::diameter(-4.0), Compensation::Computed, ProfileSide::Inside, CutDirection::Conventional), ProfileOp::new(Tool::Kerf { width: f64::NAN }, Compensation::Computed, ProfileSide::Outside, CutDirection::Conventional), ProfileOp::new(Tool::Kerf { width: -1.5 }, Compensation::Controller(ControllerSide::Right), ProfileSide::Outside, CutDirection::Climb), inside_climb().tabs(TabSpec::count(0, 1.0)), @@ -1618,7 +1618,7 @@ mod tests { inside_climb().heights(DEFAULT_CLEARANCE_MM, f64::NAN), inside_climb().heights(DEFAULT_CLEARANCE_MM, -0.1), inside_climb().heights(1.0, 2.0), - ProfileOp::new(Tool::Diameter { diameter: 100.0 }, Compensation::Computed, ProfileSide::Inside, CutDirection::Climb), + ProfileOp::new(Tool::diameter(100.0), Compensation::Computed, ProfileSide::Inside, CutDirection::Climb), ProfileOp::new(tool2(), Compensation::Computed, ProfileSide::On, CutDirection::Conventional).allowance(0.4), ]; for op in cases { @@ -1660,7 +1660,7 @@ mod tests { let z = -12.0; let height = 3.0; let raised = z + height; - let profile = ProfileOp::new(Tool::Diameter { diameter }, Compensation::Computed, ProfileSide::Outside, CutDirection::Climb) + let profile = ProfileOp::new(Tool::diameter(diameter), Compensation::Computed, ProfileSide::Outside, CutDirection::Climb) .z(z) .tabs(TabSpec::count(6, 10.0).height(height)) .generate(&part) @@ -1763,7 +1763,7 @@ mod tests { let z = -6.0; let height = 2.0; let raised = z + height; - let profile = ProfileOp::new(Tool::Diameter { diameter: 4.0 }, Compensation::Computed, ProfileSide::On, CutDirection::Conventional) + let profile = ProfileOp::new(Tool::diameter(4.0), Compensation::Computed, ProfileSide::On, CutDirection::Conventional) .z(z) .tabs(TabSpec::count(3, 8.0).height(height)) .generate(&circle) @@ -1836,7 +1836,7 @@ mod tests { exact_line_gap: bool, ) { assert_eq!(part.orientation(&Tolerance::default()).unwrap(), Orientation::CounterClockwise); - let profile = ProfileOp::new(Tool::Diameter { diameter }, Compensation::Computed, side, direction) + let profile = ProfileOp::new(Tool::diameter(diameter), Compensation::Computed, side, direction) .z(-10.0) .tabs(TabSpec::count(count, width)) .generate(part) @@ -2271,7 +2271,7 @@ mod tests { if height > 0.0 { spec = spec.height(height.min((-z) * 0.5).max(0.2)); } - let op = ProfileOp::new(Tool::Diameter { diameter }, Compensation::Computed, ProfileSide::Outside, direction).z(z).tabs(spec); + let op = ProfileOp::new(Tool::diameter(diameter), Compensation::Computed, ProfileSide::Outside, direction).z(z).tabs(spec); let Ok(profile) = op.generate(&part) else { return Ok(()); }; diff --git a/crates/path/src/timing.rs b/crates/path/src/timing.rs index c5925fa..cb2f032 100644 --- a/crates/path/src/timing.rs +++ b/crates/path/src/timing.rs @@ -422,17 +422,13 @@ mod tests { #[test] fn profile_with_three_tabs_and_leads_times_every_start_and_stop() { - let profile = ProfileOp::new( - Tool::Diameter { diameter: 2.0 }, - Compensation::Controller(ControllerSide::Left), - ProfileSide::On, - CutDirection::Conventional, - ) - .lead_in(Lead::Line { length: 5.0 }) - .lead_out(Lead::Line { length: 4.0 }) - .tabs(TabSpec::count(3, 2.0).height(1.5)) - .generate(&rect(40.0, 20.0)) - .unwrap(); + let profile = + ProfileOp::new(Tool::diameter(2.0), Compensation::Controller(ControllerSide::Left), ProfileSide::On, CutDirection::Conventional) + .lead_in(Lead::Line { length: 5.0 }) + .lead_out(Lead::Line { length: 4.0 }) + .tabs(TabSpec::count(3, 2.0).height(1.5)) + .generate(&rect(40.0, 20.0)) + .unwrap(); assert_eq!(profile.tabs().len(), 3); let source = profile.toolpath(); let starts = source.moves().iter().filter(|mv| matches!(mv, Move::Event(ProcessEvent::On))).count(); diff --git a/crates/path/src/tool.rs b/crates/path/src/tool.rs index 0f21947..33039b7 100644 --- a/crates/path/src/tool.rs +++ b/crates/path/src/tool.rs @@ -2,6 +2,8 @@ //! //! A round tool is described by its diameter. A kerf-only process (no rotating cutter) is //! described by the kerf width. In both cases the computed offset distance is half of that value. +//! A diameter tool may name a router cutter sense. That value is data for the job and a later +//! verifier; compensation stays half the diameter. use crate::{PathError, Result}; @@ -10,26 +12,151 @@ use crate::{PathError, Result}; /// Larger values are rejected so a hostile job cannot ask for an unbounded offset. const MAX_CUTTER_MM: f64 = 1.0e6; +/// Helix sense of a rotating router cutter. +/// +/// `Compression` also needs [`Tool::Diameter`]'s `upcut_length`: millimetres of upcut at the tip. +/// Without stickout, that length may be any positive finite value at most `diameter * 100`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +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. + Compression, +} + /// A cutter. Compensation distance is half the diameter or half the kerf. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Tool { /// Rotating tool. Offset by the radius (`diameter / 2`). - Diameter { diameter: f64 }, + /// + /// `cutter_sense` is omitted for the flat diameter offset used when the job does not name a + /// sense. `upcut_length` is millimetres at the tip and is only set with + /// [`CutterSense::Compression`]. + Diameter { + /// Cutting diameter, millimetres. + diameter: f64, + /// Router flute helix. `None` keeps today's diameter offset. + cutter_sense: Option, + /// Upcut length at the tip, millimetres. Set only with [`CutterSense::Compression`]. + upcut_length: Option, + }, /// Kerf-only process. Offset by half the kerf (`width / 2`). Kerf { width: f64 }, } impl Tool { + /// Rotating cutter with no cutter sense. Compensation is half of `diameter`. + pub fn diameter(diameter: f64) -> Self { + Self::Diameter { diameter, cutter_sense: None, upcut_length: None } + } + + /// Kerf-only process. Compensation is half of `width`. + pub fn kerf(width: f64) -> Self { + Self::Kerf { width } + } + + /// Store cutter sense on a diameter tool and re-validate. + /// + /// A kerf tool cannot carry a sense. [`CutterSense::Compression`] needs a positive finite + /// `upcut_length` at most `diameter * 100`. Other senses reject `upcut_length`. + pub fn with_cutter_sense(self, cutter_sense: Option, upcut_length: Option) -> Result { + match self { + Self::Kerf { .. } => { + if cutter_sense.is_some() || upcut_length.is_some() { + return Err(PathError::InvalidInput("cutter_sense is not valid on a kerf tool".into())); + } + Ok(self) + } + Self::Diameter { diameter, .. } => { + check_cutter_sense(Some(diameter), None, None, cutter_sense, upcut_length)?; + Ok(Self::Diameter { diameter, cutter_sense, upcut_length }) + } + } + } + + /// Helix sense. `None` on a kerf tool and on a diameter tool that omits it. + pub fn cutter_sense(self) -> Option { + match self { + Self::Diameter { cutter_sense, .. } => cutter_sense, + Self::Kerf { .. } => None, + } + } + + /// Upcut length at the tip, millimetres. `None` unless the sense is compression. + pub fn upcut_length(self) -> Option { + match self { + Self::Diameter { upcut_length, .. } => upcut_length, + Self::Kerf { .. } => None, + } + } + /// Radius or half-kerf, in millimetres. + /// + /// Cutter sense does not change this distance. pub fn compensation_distance(self) -> Result { let distance = match self { - Self::Diameter { diameter } => half_positive(diameter, "tool diameter")?, + Self::Diameter { diameter, .. } => half_positive(diameter, "tool diameter")?, Self::Kerf { width } => half_positive(width, "kerf width")?, }; if distance.is_finite() { Ok(distance) } else { Err(PathError::InvalidInput("compensation distance is not finite".into())) } } } +/// Check helix sense and, for compression, the upcut length at the tip. +/// +/// `stickout` and `flute_length` are millimetres when the tool names an assembly. The length must +/// be positive, finite, and at most those values when they exist. Without stickout, any positive +/// finite value at most `diameter * 100` is accepted. +pub fn check_cutter_sense( + diameter: Option, + stickout: Option, + flute_length: Option, + cutter_sense: Option, + upcut_length: Option, +) -> Result<()> { + if cutter_sense.is_none() && upcut_length.is_none() { + return Ok(()); + } + let Some(diameter) = diameter else { + return Err(PathError::InvalidInput("cutter_sense is not valid on a kerf tool".into())); + }; + match cutter_sense { + None | Some(CutterSense::Upcut) | Some(CutterSense::Downcut) => { + if upcut_length.is_some() { + return Err(PathError::InvalidInput("upcut_length is only valid with compression".into())); + } + Ok(()) + } + Some(CutterSense::Compression) => { + let Some(length) = upcut_length else { + return Err(PathError::InvalidInput("compression needs upcut_length".into())); + }; + if !length.is_finite() { + return Err(PathError::InvalidInput("upcut_length must be finite".into())); + } + if length <= 0.0 { + return Err(PathError::InvalidInput("upcut_length must be positive".into())); + } + let (cap, what) = upcut_cap(diameter, stickout, flute_length); + if length > cap { + return Err(PathError::InvalidInput(format!("upcut_length must be at most {what}"))); + } + Ok(()) + } + } +} + +fn upcut_cap(diameter: f64, stickout: Option, flute_length: Option) -> (f64, &'static str) { + match (stickout, flute_length) { + (Some(stickout), Some(flute)) if flute < stickout => (flute, "flute_length"), + (Some(stickout), _) => (stickout, "stickout"), + (None, Some(flute)) => (flute, "flute_length"), + (None, None) => (diameter * 100.0, "diameter * 100"), + } +} + /// Which side of travel a later controller should keep the tool on. /// /// This crate does not emit machine codes. `Left` is the flag a later IR maps to compensation @@ -67,24 +194,75 @@ fn half_positive(value: f64, what: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; #[test] fn diameter_and_kerf_use_half() { - let tool = Tool::Diameter { diameter: 2.0 }; + let tool = Tool::diameter(2.0); assert!((tool.compensation_distance().unwrap() - 1.0).abs() < 1e-12); - let kerf = Tool::Kerf { width: 1.5 }; + let kerf = Tool::kerf(1.5); assert!((kerf.compensation_distance().unwrap() - 0.75).abs() < 1e-12); } + #[test] + fn cutter_sense_does_not_change_compensation() { + let bare = Tool::diameter(6.0); + let upcut = bare.with_cutter_sense(Some(CutterSense::Upcut), None).unwrap(); + let downcut = bare.with_cutter_sense(Some(CutterSense::Downcut), None).unwrap(); + let compression = bare.with_cutter_sense(Some(CutterSense::Compression), Some(12.0)).unwrap(); + let expected = bare.compensation_distance().unwrap(); + for tool in [upcut, downcut, compression] { + assert!((tool.compensation_distance().unwrap() - expected).abs() < 1e-12); + } + } + + #[test] + fn cutter_sense_rejects_illegal_upcut_length() { + let mill = Tool::diameter(6.0); + assert!(mill.with_cutter_sense(Some(CutterSense::Upcut), Some(1.0)).is_err()); + assert!(mill.with_cutter_sense(Some(CutterSense::Downcut), Some(1.0)).is_err()); + assert!(mill.with_cutter_sense(Some(CutterSense::Compression), None).is_err()); + assert!(mill.with_cutter_sense(Some(CutterSense::Compression), Some(0.0)).is_err()); + assert!(mill.with_cutter_sense(Some(CutterSense::Compression), Some(-1.0)).is_err()); + assert!(mill.with_cutter_sense(Some(CutterSense::Compression), Some(f64::NAN)).is_err()); + assert!(mill.with_cutter_sense(Some(CutterSense::Compression), Some(601.0)).is_err()); + assert!(mill.with_cutter_sense(None, Some(1.0)).is_err()); + assert!(Tool::kerf(1.2).with_cutter_sense(Some(CutterSense::Upcut), None).is_err()); + assert!(check_cutter_sense(Some(6.0), Some(20.0), Some(10.0), Some(CutterSense::Compression), Some(12.0)).is_err()); + assert!(check_cutter_sense(Some(6.0), Some(20.0), Some(10.0), Some(CutterSense::Compression), Some(10.0)).is_ok()); + assert!(check_cutter_sense(Some(6.0), Some(20.0), None, Some(CutterSense::Compression), Some(20.0)).is_ok()); + assert!(check_cutter_sense(Some(6.0), Some(20.0), None, Some(CutterSense::Compression), Some(20.1)).is_err()); + } + #[test] fn hostile_cutter_sizes_are_errors() { - for tool in [Tool::Diameter { diameter: f64::NAN }, Tool::Diameter { diameter: f64::INFINITY }, Tool::Kerf { width: f64::NEG_INFINITY }] { + for tool in [Tool::diameter(f64::NAN), Tool::diameter(f64::INFINITY), Tool::kerf(f64::NEG_INFINITY)] { assert!(tool.compensation_distance().is_err()); } - assert!(Tool::Diameter { diameter: 0.0 }.compensation_distance().is_err()); - assert!(Tool::Diameter { diameter: -2.0 }.compensation_distance().is_err()); - assert!(Tool::Kerf { width: -1.5 }.compensation_distance().is_err()); - assert!(Tool::Kerf { width: 0.0 }.compensation_distance().is_err()); - assert!(Tool::Diameter { diameter: 1.0e9 }.compensation_distance().is_err()); + assert!(Tool::diameter(0.0).compensation_distance().is_err()); + assert!(Tool::diameter(-2.0).compensation_distance().is_err()); + assert!(Tool::kerf(-1.5).compensation_distance().is_err()); + assert!(Tool::kerf(0.0).compensation_distance().is_err()); + assert!(Tool::diameter(1.0e9).compensation_distance().is_err()); + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 24, + rng_seed: proptest::test_runner::RngSeed::Fixed(0xC077_E5E1), + ..ProptestConfig::default() + })] + #[test] + fn cutter_sense_keeps_compensation_distance(diameter in 0.5f64..100.0, length in 0.1f64..50.0) { + prop_assume!(length <= diameter * 100.0); + let bare = Tool::diameter(diameter); + let Ok(expected) = bare.compensation_distance() else { + return Ok(()); + }; + let upcut = bare.with_cutter_sense(Some(CutterSense::Upcut), None).expect("upcut"); + let compression = bare.with_cutter_sense(Some(CutterSense::Compression), Some(length)).expect("compression"); + prop_assert!((upcut.compensation_distance().expect("upcut radius") - expected).abs() < 1e-12); + prop_assert!((compression.compensation_distance().expect("compression radius") - expected).abs() < 1e-12); + } } } diff --git a/crates/path/tests/pocket_coverage.rs b/crates/path/tests/pocket_coverage.rs index 65af843..8b9e90e 100644 --- a/crates/path/tests/pocket_coverage.rs +++ b/crates/path/tests/pocket_coverage.rs @@ -239,11 +239,8 @@ fn clearance(outer: &Contour, holes: &[Contour], samples: &[Point]) -> f64 { fn check(name: &str, outer: &Contour, holes: &[Contour], tol: &Tolerance, bounds: [f64; 4]) -> Result<()> { let region = if holes.is_empty() { Region::from_contour(outer.clone(), tol)? } else { Region::try_new(outer.clone(), holes.to_vec(), tol)? }; - let pocket = PocketOp::new(Tool::Diameter { diameter: DIAMETER }, PocketStrategy::OffsetRings, STEP) - .z(CUT_Z) - .retract_z(2.0) - .tolerance(*tol) - .generate(®ion)?; + let pocket = + PocketOp::new(Tool::diameter(DIAMETER), PocketStrategy::OffsetRings, STEP).z(CUT_Z).retract_z(2.0).tolerance(*tol).generate(®ion)?; let cuts = cutting(pocket.toolpath().moves())?; let mut sample_off = 0.0_f64; for (i, point) in cuts.samples.iter().enumerate() { diff --git a/crates/post/tests/full_circles.rs b/crates/post/tests/full_circles.rs index 6ae9972..e83f7ed 100644 --- a/crates/post/tests/full_circles.rs +++ b/crates/post/tests/full_circles.rs @@ -136,13 +136,13 @@ fn closed_circles(path: &Toolpath) -> Vec { fn pocket_disk(cx: f64, cy: f64, radius: f64) -> Toolpath { let contour = circle_contour(cx, cy, radius + TOOL_RADIUS); let region = Region::from_contour(contour, &Tolerance::default()).expect("region"); - let tool = Tool::Diameter { diameter: TOOL_RADIUS * 2.0 }; + let tool = Tool::diameter(TOOL_RADIUS * 2.0); PocketOp::new(tool, PocketStrategy::OffsetRings, radius + 1.0).z(-1.0).retract_z(1.0).generate(®ion).expect("pocket").toolpath().clone() } fn inside_profile(cx: f64, cy: f64, radius: f64, direction: CutDirection) -> Toolpath { let contour = circle_contour(cx, cy, radius + TOOL_RADIUS); - ProfileOp::new(Tool::Diameter { diameter: TOOL_RADIUS * 2.0 }, Compensation::Computed, ProfileSide::Inside, direction) + ProfileOp::new(Tool::diameter(TOOL_RADIUS * 2.0), Compensation::Computed, ProfileSide::Inside, direction) .z(-1.0) .generate(&contour) .expect("profile") @@ -331,13 +331,7 @@ fn ring_pocket() -> Toolpath { }; assert!((mid_y - 40.05843).abs() < 1e-4, "flipped centre y {mid_y}"); let region = Region::try_new(outer, vec![hole], &Tolerance::default()).expect("region"); - PocketOp::new(Tool::Diameter { diameter: 6.0 }, PocketStrategy::OffsetRings, 2.0) - .z(-3.0) - .retract_z(2.0) - .generate(®ion) - .expect("pocket") - .toolpath() - .clone() + PocketOp::new(Tool::diameter(6.0), PocketStrategy::OffsetRings, 2.0).z(-3.0).retract_z(2.0).generate(®ion).expect("pocket").toolpath().clone() } #[test] @@ -358,7 +352,7 @@ fn reported_ring_posts_on_every_mill() { fn inside_circle_profile_posts_on_every_mill() { let contour = circle_contour(15.0, 30.0, 5.0); for direction in [CutDirection::Climb, CutDirection::Conventional] { - let path = ProfileOp::new(Tool::Diameter { diameter: 6.0 }, Compensation::Computed, ProfileSide::Inside, direction) + let path = ProfileOp::new(Tool::diameter(6.0), Compensation::Computed, ProfileSide::Inside, direction) .z(-1.0) .generate(&contour) .expect("profile") diff --git a/docs/job-file.md b/docs/job-file.md index 4236be1..66aed6c 100644 --- a/docs/job-file.md +++ b/docs/job-file.md @@ -86,6 +86,19 @@ lengths in `units`, positive, and at most 1e6 mm after conversion. The same rule job: a diameter is a rotating cutter, a kerf is a kerf-only process, and the path offset is half of that value. +A diameter tool may set `cutter_sense`: `"upcut"`, `"downcut"`, or `"compression"`. Omitted, +compensation stays the flat diameter offset used today. Sense is data for the job and the +verifier; it does not change the 2D offset. `"compression"` requires `upcut_length`, a positive +finite length in `units` measured at the tip. That length must be at most the tool stickout and +flute length when `assembly` sets them. Without stickout, any positive finite value at most +`diameter * 100` is accepted. `upcut_length` is rejected on `"upcut"` and `"downcut"`. A kerf +tool cannot set either field. Unknown keys and illegal combinations fail the load. Existing jobs +that omit these keys parse unchanged. Posted G-code is unchanged when the field is omitted. This +output has not been proven on a machine. + +`examples/router_cutter_sense.job.json` profiles `examples/sample.dxf` with a 6 mm compression +cutter and a 12 mm upcut length at the tip. + `speed` is optional. It is a spindle speed in rpm: positive, finite, and at most 100000. It is not scaled by `units`. Omitted, the tool has no speed. An operation may set `speed` too; see below. diff --git a/docs/schema/campanion-ir-1.schema.json b/docs/schema/campanion-ir-1.schema.json index 845f860..332eef8 100644 --- a/docs/schema/campanion-ir-1.schema.json +++ b/docs/schema/campanion-ir-1.schema.json @@ -770,6 +770,26 @@ } ] }, + "CutterSense": { + "description": "Helix sense of a rotating router cutter.\n\nStored on a diameter tool. Omitted, compensation stays the flat diameter offset. Sense is\ndata for the job and the verifier; it does not change the 2D offset.", + "oneOf": [ + { + "const": "upcut", + "description": "Chips climb the flute toward the shank.", + "type": "string" + }, + { + "const": "downcut", + "description": "Chips are pushed toward the tip.", + "type": "string" + }, + { + "const": "compression", + "description": "Upcut at the tip, downcut above that. Requires [`Tool`]'s `upcut_length`.", + "type": "string" + } + ] + }, "Event": { "description": "One non-motion step.\n\nIn a turning program [`Event::CAxis`] engages a spindle as a positioning C axis and\n[`Event::SpindleBrake`] clamps that spindle. [`Event::LiveToolOn`] starts the driven tool.\n[`Event::FaceInterpolation`] and [`Event::CylinderInterpolation`] select a Cartesian face frame\nor an unrolled cylinder frame; both require the C axis to be engaged when a post writes them.\n`Pose::rotary` on a motion in that program is the C angle in degrees: finite, absolute value\nat most 99999. A live-tool event does not require a turning setup.\n[`Event::SpindleOrient`] turns a spindle to an angle in `0..360` degrees. A secondary-spindle\norient is written while that spindle is not selected.\n[`Event::ChuckClamp`], [`Event::SpindleSync`], [`Event::SpindleRun`] and [`Event::SpindleMove`]\nhand a part between spindles. `SpindleRun` runs a spindle that is not the active binding's spindle.\n[`Event::HeadIndex`] sets the named head's B angle. The tool axis of the moves that follow is\n`(cos b_deg, 0, sin b_deg)`. [`Event::Tailstock`] advances or retracts the tailstock. Hold\nforce and travel are machine settings; the event carries no position.\n[`Event::SteadyRest`] clamps or unclamps the steady rest. The rest is positioned in setup;\nthe event carries no position.\n[`Event::PartsCatcher`] moves the parts catcher into position to catch a part, or out of the\nwork envelope. [`Event::BarFeed`] commands a bar feeder. Optional lengths are one-line\noverrides in millimetres: part length plus cutoff and facing allowance, and minimum clamping\nlength.", "oneOf": [ @@ -2461,6 +2481,17 @@ ], "description": "Holder and head envelope. Absent on a tool that does not name one, so older files stay unchanged." }, + "cutter_sense": { + "anyOf": [ + { + "$ref": "#/$defs/CutterSense" + }, + { + "type": "null" + } + ], + "description": "Router flute helix. Omitted from JSON when absent, so older files stay unchanged." + }, "diameter": { "default": null, "description": "Rotating tool diameter, millimetres.", @@ -2490,6 +2521,14 @@ "string", "null" ] + }, + "upcut_length": { + "description": "Upcut length at the tip, millimetres. Only with [`CutterSense::Compression`].", + "format": "double", + "type": [ + "number", + "null" + ] } }, "required": [ diff --git a/examples/router_cutter_sense.job.json b/examples/router_cutter_sense.job.json new file mode 100644 index 0000000..fd94ac2 --- /dev/null +++ b/examples/router_cutter_sense.job.json @@ -0,0 +1,30 @@ +{ + "format": "campanion-job/1", + "source": { "path": "sample.dxf", "hash": "cee9f36f5ba230a2" }, + "units": "mm", + "tools": [ + { "id": 1, "diameter": 6.0, "speed": 12000, "cutter_sense": "compression", "upcut_length": 12.0 } + ], + "operations": [ + { + "name": "inner", + "type": "profile", + "layers": ["INNER"], + "side": "inside", + "tool_id": 1, + "depth": 3.0, + "step_down": 3.0, + "feeds": { "cut": 1000.0, "plunge": 300.0 } + }, + { + "name": "outer", + "type": "profile", + "layers": ["OUTER"], + "side": "outside", + "tool_id": 1, + "depth": 3.0, + "step_down": 3.0, + "feeds": { "cut": 1000.0, "plunge": 300.0 } + } + ] +}