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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions crates/post/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion crates/post/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<u32> = None;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(());
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions crates/post/tests/mill_g53.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading