diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b14368..86747ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Post read-back, mill audit and five-axis audit accept `G91.1` as an explicit declaration of incremental IJK arc centres. `G90.1` is refused with an explanation that absolute arc centres are not supported. Custom post headers can now declare `G91.1` without failing their own read-back (#144). +- Mill modal audit accepts `G53` machine-coordinate retracts without adding a part-frame motion or changing its position, motion mode or plane, matching read-back (#145). ## 0.10.0 — 2026-11-09 diff --git a/crates/post/README.md b/crates/post/README.md index 425a5b7..7570d76 100644 --- a/crates/post/README.md +++ b/crates/post/README.md @@ -26,6 +26,8 @@ A Probe event with a contact pose and a feed is one `G38.2` block: the three axi `read_back`, `audit` and `audit_five` accept `G91.1` as an explicit declaration of incremental `I`/`J`/`K` arc centres, including in a custom post header or on a motion block. It does not change the `G90` positioning mode. `G90.1` is rejected with a specific diagnostic because absolute arc centres are not supported. Built-in post headers are unchanged. +`audit` accepts `G53` machine-coordinate moves, including retracts in custom tool-change templates. They are excluded from part-frame motions and preserve the part position, motion mode and plane, matching `read_back` and the existing five-axis audit. This replay does not establish a machine-to-work coordinate transform or verify the physical retract path. + `audit` reads posted mill G-code and returns the modal state a control would keep at each motion block: motion mode (`G0`–`G3`), plane (`G17`–`G19`), distance (`G90`/`G91`), units (`G20`/`G21`), feed mode (`G93`/`G94`), the active `F`, work offset (`G54`–`G59`), the tool in the spindle (`T` and `M6`, or the post's tool-change comment), tool-length offset (`G43`/`G49`), spindle or beam (`M3`/`M4`/`M5`, including a laser `S` word), coolant, and a modal rotary word when the post has `rotary`. A word these posts do not write is an error, so a typo in a template fails the replay. On a post with `probe`, `G38.2` or `G38.3` is not a motion: position and motion mode stay put, and `F` on that block updates the modal feed. On a post with `digital_out`, `M64` and `M65` are accepted when `P` equals `vacuum_pin` or `dust_pin`. Other posts still reject those codes. A `G2` or `G3` needs a known plane and a full centre: both in-plane letters (`I`/`J`, `I`/`K`, or `J`/`K`) or an `R`. A feed move needs a positive `F` already set. An inverse-time feed block must carry its own `F`. When line numbers are enabled, every numbered line follows the post's start and step, and each number stays at or below 1000000000. Each line must fit `max_line_length`. On a `decimal_point` post, dimensional words include a decimal point, and that includes rotary `A`, `B`, and `C`. A negative zero, scientific notation, and a word whose absolute value is above `1e9` are errors. diff --git a/crates/post/src/audit.rs b/crates/post/src/audit.rs index 5b44dbb..dfc3821 100644 --- a/crates/post/src/audit.rs +++ b/crates/post/src/audit.rs @@ -7,6 +7,8 @@ //! A word this checker does not know is an error, so a typo in a template fails here. Geometry //! is also checked against [`crate::read_back`] so the two readers stay aligned. //! `G91.1` confirms incremental IJK without changing positioning; `G90.1` is rejected. +//! `G53` is a machine-coordinate move, not a part point; it preserves part position, motion +//! mode and plane, matching read-back. //! //! Lathe posts are rejected; lathe and mill-turn text is replayed by [`crate::audit_lathe`]. //! Five-axis posts are replayed by [`crate::audit_five`]. Work offset @@ -382,6 +384,9 @@ impl<'a> Parser<'a> { } fn interpret(&mut self, line_no: usize, words: &[Word]) -> Result<()> { + let previous_motion = self.motion; + let previous_plane = self.plane; + let mut machine_move = false; let mut saw_dwell = false; let mut want_length = false; let mut h_word: Option = None; @@ -413,6 +418,7 @@ impl<'a> Parser<'a> { 'G' if self.is_probe_word(word) => probe_words += 1, 'G' => { let code = g_or_m(line_no, word, 'G')?; + machine_move |= code == 53; self.apply_g(line_no, code, &mut saw_dwell, &mut want_length, &mut block_motion, &mut block_plane, &mut block_feed)?; } 'M' if self.is_immediate_digital(word) => { @@ -516,6 +522,14 @@ impl<'a> Parser<'a> { return Ok(()); } + if machine_move { + // Validate the whole block first, but do not turn machine coordinates into a + // part-frame point or let its G0/G1 replace the part's modal motion. + self.motion = previous_motion; + self.plane = previous_plane; + return Ok(()); + } + let has_axis = x.is_some() || y.is_some() || z.is_some() || saw_rotary; if !has_axis { return Ok(()); @@ -606,7 +620,7 @@ impl<'a> Parser<'a> { 19 => self.set_plane(line_no, Plane::Yz, block_plane)?, 20 => return Err(fail(line_no, "inches are not supported")), 21 => self.set_units(line_no, UnitMode::Millimetre)?, - 40 | 64 | 80 => {} + 40 | 53 | 64 | 80 => {} 43 => *want_length = true, 49 => { if *want_length { diff --git a/crates/post/tests/mill_g53.rs b/crates/post/tests/mill_g53.rs new file mode 100644 index 0000000..80d1d1b --- /dev/null +++ b/crates/post/tests/mill_g53.rs @@ -0,0 +1,75 @@ +//! Regression for #145: machine retracts are not part-frame motion. + +#![allow(clippy::expect_used)] + +use campanion_ir::{ClBlock, ClMotion, ClPath, CompensationMode, Event, Feeds, MoveRole, Pose, Program, Tool, Vec3}; +use campanion_post::{MotionMode, PostOptions, audit, builtin, compare, load_post, post_program, read_back}; +use proptest::prelude::*; + +const PROGRAM: &str = "G21 G90 G17\nG0 X12 Y20 Z5\nG1 Z0 F100\nG3 X10 Y22 I-2 J0\nX8 Y20 I0 J-2\n"; + +#[test] +fn machine_retract_preserves_part_position_and_modal_arc() { + let post = builtin("mach3").expect("post"); + let baseline = audit(PROGRAM, &post).expect("baseline"); + for retract in ["G53 G0 Z0", "G0 X100 Y200 Z300 G53", "G53 G0 G18 Z0"] { + let text = PROGRAM.replace("X8 Y20", &format!("{retract}\nX8 Y20")); + let report = audit(&text, &post).expect("audit G53"); + assert_eq!(report.motions.len(), baseline.motions.len()); + for (mut actual, expected) in report.motions.into_iter().zip(&baseline.motions) { + actual.line = expected.line; + assert_eq!(&actual, expected); + } + let moves = read_back(&text, &post).expect("read-back G53"); + assert_eq!(moves.len(), 4); + assert!(matches!(moves.last(), Some(campanion_post::ReadMove::Arc { end, center, .. }) + if *end == [8.0, 20.0, 0.0] && *center == [10.0, 20.0, 0.0])); + } +} + +#[test] +fn custom_tool_change_retract_posts_and_round_trips() { + let json = include_str!("../posts/mach3.json").replace("\"T{tool} M6\"", "\"G53 G0 Z0\", \"T{tool} M6\""); + let post = load_post(&json).expect("custom post"); + let pose = |x, y, z| Pose::new(Vec3::new(x, y, z).expect("point"), Vec3::Z).expect("pose"); + let blocks = vec![ + ClBlock::Event(Event::ToolChange { tool_id: 1 }), + ClBlock::Motion(ClMotion::Rapid { to: pose(12.0, 20.0, 5.0), feed: None }), + ClBlock::Motion(ClMotion::Linear { to: pose(12.0, 20.0, 0.0), feed: 100.0, role: MoveRole::Cut }), + ]; + let feeds = Feeds::try_new(100.0, None, None, None, None).expect("feeds"); + let path = ClPath::try_new(None, Some(1), CompensationMode::Off, feeds, None, Vec::new(), blocks).expect("path"); + let tool = Tool::try_new(1, None, Some(6.0), None).expect("tool"); + let program = Program::try_new(None, vec![tool], None, vec![path], Vec::new(), Vec::new(), None).expect("program"); + let text = post_program(&program, &post, &PostOptions { arc_tolerance: 0.01 }).expect("post"); + assert!(text.contains("G53 G0 Z0\nT1 M6"), "{text}"); + let moves = read_back(&text, &post).expect("read-back"); + compare(&program, &moves, 0.01, &post).expect("IR round-trip"); + let report = audit(&text, &post).expect("audit"); + assert_eq!(report.motions.len(), 2); + assert_eq!(report.tool, Some(1)); +} + +#[test] +fn machine_retract_does_not_bypass_word_validation() { + let post = builtin("mach3").expect("post"); + for block in ["G53 G0 G1 Z0", "G53 G91 Z0", "G53 G123 Z0", "G53 G0 Z0 Z1", "G53 G0 Z1000000001", "G53 G0 Z0 F-1"] { + let text = format!("G21 G90 G17\n{block}\nG0 X1 Y2 Z3\n"); + assert!(audit(&text, &post).is_err(), "{block}"); + } +} + +proptest! { + #[test] + fn machine_axes_never_leak_into_following_part_move(x in -1000i32..1000, y in -1000i32..1000, z in -1000i32..1000) { + let post = builtin("mach3").expect("post"); + let text = format!("G21 G90 G17\nG0 X12 Y20 Z5\nG1 Z-2 F100\nG53 G0 X{x} Y{y} Z{z}\nX14\n"); + let report = audit(&text, &post).expect("audit"); + let moves = read_back(&text, &post).expect("read-back"); + prop_assert_eq!(moves.len(), 3); + prop_assert_eq!(report.motions.len(), 3); + let last = report.motions.last().expect("last motion"); + prop_assert_eq!(last.end, [14.0, 20.0, -2.0]); + prop_assert_eq!(last.mode, MotionMode::Linear); + } +}