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 built-in `mach4` three-axis mill/router post writes metric output with incremental IJK (`G91.1`) and `M9`/`M30` at the end. The opt-in `dwell.decimal_seconds` setting makes read-back treat an integer `G4 P` as milliseconds and a decimal `G4 P` as seconds, matching Mach4, and the writer always emits a decimal P so Mach4 reads seconds. Existing posts keep their dwell behavior and golden output. This output has not been proven on a machine.

## 0.12.0 — 2026-11-12

Plasma improvements: optional G41/G42 from IR controller compensation, job-file lead-ins and lead-outs with the small-hole rule, automatic contour side and climb or conventional direction, and a narrower-than-kerf refusal plus a warning when a rapid crosses a freed slug (#163–#166). This output has not been proven on a machine.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ that post. The G-code is read back and compared to the toolpath at the job chord
| `linuxcnc_router` | LinuxCNC router | - | Millimetre LinuxCNC gantry router. G38.2 tool-length probe, vacuum and dust as M64/M65. No rotary axis. |
| `grbl` | Grbl | - | Millimetre dialect for Grbl. Absolute moves, IJK arcs and helices, no line numbers, tool changes as comments. No rotary axis. |
| `mach3` | Mach3 | A, degrees per minute | Millimetre dialect for Mach3. Incremental IJK arcs and helices, dwell P in seconds, no line numbers. A axis with degrees-per-minute feed when the angle changes. |
| `mach4` | Mach4 | - | Metric three-axis mill/router. Explicit G91.1, IJK arcs and helices, decimal P dwell seconds, no line numbers or automatic tool-length compensation. |
| `fanuc` | Fanuc | A, inverse time | Millimetre Fanuc mill dialect. Program O0001, sequence numbers, R arcs, helices as lines, dwell P in milliseconds. A axis with inverse-time feed (G93, cancelled by G94) when the angle changes. |
| `haas` | Haas | A, inverse time | Millimetre Haas mill dialect. Program O00001, sequence numbers, IJK arcs and helices, dwell P in milliseconds. A axis with inverse-time feed (G93, cancelled by G94) when the angle changes. |
| `plasma` | LinuxCNC plasma | - | Millimetre plasma. Torch on M3 S1 / off M5, pierce dwell from the path, optional THC codes, helices as lines. `process_cycle` `per_cut` keeps every process, including spindle, off during G0. No rotary axis. |
Expand Down Expand Up @@ -133,7 +134,7 @@ placeholders, so the same program and post always produce the same bytes. Start
- `modal_motion` — when true, a repeated `G0` or `G1` is left off the next line.
- `arcs` — `mode` is `ijk` (centre offsets `I` `J` `K`), `r` (signed radius), or `lines` (chords within the chord tolerance). `max_sweep_deg` splits a longer arc (greater than 0, at most 360). `helix` writes a helix as arcs when true, and as chords when false.
- `planes` — when true, the post can change plane. When false, an arc has to stay in the header plane.
- `dwell` — `word` (one letter) and `unit` (`s` or `ms`).
- `dwell` — `word` (one letter) and `unit` (`s` or `ms`). Optional `decimal_seconds: true` requires `P` and `s`: always writes decimal seconds and reads integer P as milliseconds (Mach4). See [Mach4 setup and dwell rules](crates/post/README.md#mach4).
- `process` — map from a process name (`spindle`, `torch`, `laser`, `extruder`, `knife`, `other`) to `{ "on": [lines], "off": [lines] }`. Placeholders: `{speed}`, `{power}`, `{program_name}`, `{date-free!}`.
- `tool_change` — lines for a tool change. Placeholder: `{tool}`.
- `coolant` — `flood`, `mist`, and `off`, each a list of lines.
Expand Down
13 changes: 11 additions & 2 deletions crates/cli/tests/post_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ fn first_diff(left: &str, right: &str) -> String {
fn sample_job_posts_with_each_builtin() -> Result<(), String> {
let root = workspace_root()?;
let job = sample_job(&root)?;
for id in ["linuxcnc", "grbl"] {
for id in ["linuxcnc", "grbl", "mach4"] {
let out = temp_path(&format!("campanion-post-e2e-{id}.nc"));
let out_arg = path_arg(&out)?;
run_ok(&["run", &job, "-o", &out_arg, "--post", id])?;
Expand All @@ -81,6 +81,14 @@ fn sample_job_posts_with_each_builtin() -> Result<(), String> {
return Err(format!("{id}: unexpected grbl program\n{posted}"));
}
}
"mach4" => {
if !posted.starts_with("(sample.job)\nG21\nG90\nG91.1\nG94\nG17\nG40\nG49\nG80\n")
|| !posted.ends_with("M30\n")
|| posted.lines().any(|line| line.starts_with('N'))
{
return Err(format!("{id}: unexpected Mach4 program\n{posted}"));
}
}
_ => return Err(format!("untested builtin {id}")),
}
}
Expand Down Expand Up @@ -159,6 +167,7 @@ fn posts_lists_every_builtin() -> Result<(), String> {
"linuxcnc_5ax_tcp",
"grbl",
"mach3",
"mach4",
"fanuc",
"haas",
"haas_umc",
Expand Down Expand Up @@ -189,7 +198,7 @@ fn post_on_committed_ir_matches_run() -> Result<(), String> {
// before it writes IR, so this posts the IR that same run just wrote.
let root = workspace_root()?;
let job = sample_job(&root)?;
for id in ["linuxcnc", "grbl"] {
for id in ["linuxcnc", "grbl", "mach4"] {
let from_run_path = temp_path(&format!("campanion-post-match-run-{id}.nc"));
let ir_path = temp_path(&format!("campanion-post-match-{id}.ir.json"));
let from_post_path = temp_path(&format!("campanion-post-match-post-{id}.nc"));
Expand Down
28 changes: 27 additions & 1 deletion crates/post/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,35 @@ A Probe event with a contact pose and a feed is one `G38.2` block: the three axi

`audit` treats the probe block as a non-motion and accepts `M64`/`M65` only when `digital_out` is set and `P` is one of the declared pins. `G53` handling is unchanged. This output has not been proven on a machine.

## Mach4

`mach4` is a metric three-axis mill/router post, based on the public
[Mach4 Mill Programming Guide v1.1, Build 3775](https://www.machsupport.com/wp-content/uploads/2014/05/Mill%20GCode%20Programming.pdf).
Its header declares `G21 G90 G91.1 G94 G17 G40 G49 G80` on separate blocks: millimetres,
absolute endpoints, incremental IJK centres, feed per minute, XY plane, and cancelled cutter
compensation, tool-length offsets and canned cycles. It writes full circles and helices in
XY/ZX/YZ, standard `T… M6`, `M3`/`M5`, `M7`/`M8`/`M9`, and ends with `M30`.
There are no line numbers, machine parking coordinates, rotary/five-axis or lathe support,
probing macros, automatic tool measurement or accessory mappings.

The operator must establish the work offset and correct tool-tip Z for each tool with
length compensation cancelled (`G49`). `T… M6` uses the machine's configured tool-change
behavior; this post does not emit `G43 H…` or choose a tool-measurement macro. Review that
setup before use. Software read-back and audit do not establish controller or machine acceptance.

Mach4 distinguishes literal dwell spelling (manual p. 17): `G4 P2` means 0.002 seconds,
while `G4 P2.0` means 2 seconds. Its dwell must be the only G code in the block.
`dwell: { "word": "P", "unit": "s", "decimal_seconds": true }` opts into these rules for
built-in or custom posts. The writer keeps a decimal point even with `trailing_zeros: false`
or `decimals.dwell: 0`; `read_back` returns the actual seconds for integer and decimal P.
The mill audit validates the literal-P block but does not report elapsed dwell time.
X/U dwell variants and variable expressions are outside this literal-P scope.
The option defaults to false, preserving existing `s` and `ms` posts. It requires `word: P`
and `unit: s`. Dimensional `decimal_point` does not control dwell formatting.

## Modal audit

`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.
`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. Existing built-in post headers are unchanged; `mach4` declares `G91.1` explicitly.

`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.

Expand Down
119 changes: 119 additions & 0 deletions crates/post/posts/mach4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
{
"id": "mach4",
"name": "Mach4",
"description": "Metric three-axis Mach4 mill/router. Explicit incremental IJK, arcs and helices in all planes. Decimal P dwell seconds; integer P milliseconds. No rotary axes or automatic tool-length compensation.",
"units": "mm",
"line_numbers": {
"enabled": false,
"start": 10,
"step": 10,
"prefix": "N"
},
"decimals": {
"linear": 4,
"feed": 3,
"dwell": 3
},
"trailing_zeros": false,
"header": [
"({program_name})",
"G21",
"G90",
"G91.1",
"G94",
"G17",
"G40",
"G49",
"G80"
],
"footer": [
"M9"
],
"modal_motion": true,
"arcs": {
"mode": "ijk",
"max_sweep_deg": 360,
"helix": true
},
"planes": true,
"dwell": {
"word": "P",
"unit": "s",
"decimal_seconds": true
},
"process": {
"spindle": {
"on": [
"M3 S{speed}"
],
"off": [
"M5"
]
},
"torch": {
"on": [
"M3 S{speed}"
],
"off": [
"M5"
]
},
"laser": {
"on": [
"M3 S{speed}"
],
"off": [
"M5"
]
},
"extruder": {
"on": [
"M3 S{speed}"
],
"off": [
"M5"
]
},
"knife": {
"on": [
"M3 S{speed}"
],
"off": [
"M5"
]
},
"other": {
"on": [
"M3 S{speed}"
],
"off": [
"M5"
]
}
},
"tool_change": [
"T{tool} M6"
],
"coolant": {
"flood": [
"M8"
],
"mist": [
"M7"
],
"off": [
"M9"
]
},
"comments": {
"style": "paren"
},
"program_end": [
"M30"
],
"max_line_length": 256,
"axis_letters": "XYZ",
"references": [
"Newfangled Solutions, Mach4 Mill Programming Guide v1.1, Build 3775, pp. 15-17, 34-35, 40-42, 50-54, 58, 83-84. https://www.machsupport.com/wp-content/uploads/2014/05/Mill%20GCode%20Programming.pdf"
]
}
15 changes: 14 additions & 1 deletion crates/post/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,19 @@ impl<'a> Parser<'a> {
self.length = LengthOffset::Active(h);
}
if saw_dwell {
if self.post.dwell.decimal_seconds {
if words.iter().filter(|word| word.letter == 'G').count() != 1 {
return Err(fail(line_no, "dwell must be the only G code in its block"));
}
// P was checked for finiteness, sign and duplication above. The audit
// validates the block; read_back reports its decimal-sensitive duration.
if p_value.is_none() {
return Err(fail(line_no, "dwell is missing its value"));
}
if i.is_some() || j.is_some() || k.is_some() || r.is_some() {
return Err(fail(line_no, "literal-P dwell does not accept arc or cycle words"));
}
}
if block_motion.is_some() || x.is_some() || y.is_some() || z.is_some() || saw_rotary {
return Err(fail(line_no, "dwell cannot share a block with motion"));
}
Expand Down Expand Up @@ -1222,7 +1235,7 @@ mod tests {
let path = ClPath::try_new(Some("outer".into()), Some(1), CompensationMode::Off, feeds, None, Vec::new(), blocks).expect("path");
let program = Program::try_new(Some("plate".into()), vec![tool], None, vec![path], Vec::new(), Vec::new(), None).expect("program");
let options = PostOptions { arc_tolerance: 0.05 };
for id in ["linuxcnc", "linuxcnc_router", "grbl", "mach3", "fanuc", "haas", "plasma", "grbl_laser"] {
for id in ["linuxcnc", "linuxcnc_router", "grbl", "mach3", "mach4", "fanuc", "haas", "plasma", "grbl_laser"] {
let post = builtin(id).expect(id);
let text = post_program(&program, &post, &options).unwrap_or_else(|err| panic!("{id}: {err}"));
assert!(!text.contains("-0"), "{id}: {text}");
Expand Down
13 changes: 13 additions & 0 deletions crates/post/src/def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ pub(crate) struct Arcs {
pub(crate) struct Dwell {
pub(crate) word: String,
pub(crate) unit: String,
/// Literal P: a decimal point means seconds, otherwise milliseconds (Mach4).
/// Writing always retains a decimal point. Opt-in; requires word=P and unit=s.
#[serde(default)]
pub(crate) decimal_seconds: bool,
}

impl Dwell {
pub(crate) fn seconds(&self, value: f64, has_decimal: bool) -> f64 {
if self.unit == "ms" || (self.decimal_seconds && !has_decimal) { value / 1000.0 } else { value }
}
}

#[derive(Clone, Debug, PartialEq, Deserialize)]
Expand Down Expand Up @@ -774,6 +784,9 @@ impl PostDef {
if !matches!(self.dwell.unit.as_str(), "s" | "ms") {
return Err(field("dwell.unit", "must be s or ms"));
}
if self.dwell.decimal_seconds && (self.dwell.unit != "s" || self.dwell.word != "P") {
return Err(field("dwell.decimal_seconds", "requires word P and unit s"));
}
check_power_scale(self.power_scale)?;
if !matches!(self.z_mode.as_str(), "keep" | "drop") {
return Err(field("z_mode", "must be keep or drop"));
Expand Down
3 changes: 3 additions & 0 deletions crates/post/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const LINUXCNC_5AX_JSON: &str = include_str!("../posts/linuxcnc_5ax.json");
const LINUXCNC_5AX_TCP_JSON: &str = include_str!("../posts/linuxcnc_5ax_tcp.json");
const GRBL_JSON: &str = include_str!("../posts/grbl.json");
const MACH3_JSON: &str = include_str!("../posts/mach3.json");
const MACH4_JSON: &str = include_str!("../posts/mach4.json");
const FANUC_JSON: &str = include_str!("../posts/fanuc.json");
const HAAS_JSON: &str = include_str!("../posts/haas.json");
const HAAS_UMC_JSON: &str = include_str!("../posts/haas_umc.json");
Expand All @@ -79,6 +80,7 @@ pub fn builtin_ids() -> &'static [&'static str] {
"linuxcnc_5ax_tcp",
"grbl",
"mach3",
"mach4",
"fanuc",
"haas",
"haas_umc",
Expand All @@ -102,6 +104,7 @@ pub fn builtin(id: &str) -> Option<PostDef> {
"linuxcnc_5ax_tcp" => LINUXCNC_5AX_TCP_JSON,
"grbl" => GRBL_JSON,
"mach3" => MACH3_JSON,
"mach4" => MACH4_JSON,
"fanuc" => FANUC_JSON,
"haas" => HAAS_JSON,
"haas_umc" => HAAS_UMC_JSON,
Expand Down
33 changes: 27 additions & 6 deletions crates/post/src/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,15 @@ impl<'a> Parser<'a> {
if stripped.trim() == "%" {
return Ok(None);
}
let mut words = tokenize(&stripped)?;
let dwell_letter = self.post.dwell.word.chars().next().unwrap_or('P');
let (mut words, dwell_decimal) = tokenize_with_decimal(&stripped, dwell_letter)?;
if self.post.line_numbers.enabled {
strip_line_number(&mut words, self.post.line_numbers.prefix.chars().next().unwrap_or('N'))?;
}
if words.is_empty() {
return Ok(None);
}
self.interpret(words, stripped.trim())
self.interpret(words, stripped.trim(), dwell_decimal)
}
}

Expand Down Expand Up @@ -226,7 +227,7 @@ impl<'a> Parser<'a> {
self.post.probe_number().is_some_and(|code| (value - code).abs() < 1e-6)
}

fn interpret(&mut self, mut words: Vec<(char, f64)>, raw: &str) -> Result<Option<ReadMove>> {
fn interpret(&mut self, mut words: Vec<(char, f64)>, raw: &str, dwell_decimal: bool) -> Result<Option<ReadMove>> {
self.note_sub_line(raw);
if let Some((letter, value)) = words.first().copied()
&& letter == 'O'
Expand Down Expand Up @@ -267,6 +268,15 @@ impl<'a> Parser<'a> {
letter => return Err(invalid(format!("unexpected word {letter}"))),
}
}
if self.post.dwell.decimal_seconds && g_codes.contains(&4) {
// Check before non-motion shortcuts (e.g. G53), including decimal G91.1.
if words.iter().filter(|(letter, _)| *letter == 'G').count() != 1 {
return Err(invalid("dwell must be the only G code in its block"));
}
if words.iter().any(|(letter, _)| matches!(letter, 'I' | 'J' | 'K' | 'R' | 'Q')) {
return Err(invalid("literal-P dwell does not accept arc or cycle words"));
}
}
// Resolve feed mode before scaling words, regardless of their order in the block.
if self.post.is_inch() {
let inverse = if g_codes.contains(&93) {
Expand Down Expand Up @@ -432,7 +442,7 @@ impl<'a> Parser<'a> {
return Err(invalid("dwell cannot share a block with motion"));
}
let word = unique(&words, dwell_letter)?.ok_or_else(|| invalid("dwell is missing its value"))?;
let seconds = if self.post.dwell.unit == "ms" { word / 1000.0 } else { word };
let seconds = self.post.dwell.seconds(word, dwell_decimal);
if !(seconds.is_finite() && seconds >= 0.0) {
return Err(invalid("dwell must be >= 0"));
}
Expand Down Expand Up @@ -918,6 +928,12 @@ pub(crate) fn strip_comments(line: &str) -> String {
}

pub(crate) fn tokenize(line: &str) -> Result<Vec<(char, f64)>> {
tokenize_with_decimal(line, '\0').map(|(words, _)| words)
}

// Preserve the requested word's decimal spelling without changing other numeric semantics.
fn tokenize_with_decimal(line: &str, decimal_word: char) -> Result<(Vec<(char, f64)>, bool)> {
let mut has_decimal = false;
let bytes = line.as_bytes();
let mut index = 0;
let mut words = Vec::new();
Expand All @@ -937,10 +953,15 @@ pub(crate) fn tokenize(line: &str) -> Result<Vec<(char, f64)>> {
return Err(invalid("unexpected character in g-code"));
}
index += 1;
let start = index;
let value = take_number(line, bytes, &mut index)?;
words.push(((letter as char).to_ascii_uppercase(), value));
let letter = (letter as char).to_ascii_uppercase();
if letter == decimal_word {
has_decimal |= line.get(start..index).is_some_and(|raw| raw.contains('.'));
}
words.push((letter, value));
}
Ok(words)
Ok((words, has_decimal))
}

fn take_number(line: &str, bytes: &[u8], index: &mut usize) -> Result<f64> {
Expand Down
Loading
Loading