diff --git a/CHANGELOG.md b/CHANGELOG.md index 055dc01..e93ef5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Optional flat, ball, bull and V-bit cutter geometry in IR tools. Run jobs preserve their shapes and resolve chamfer geometry per tool ID, rejecting conflicting definitions before output (#146). - `verify --mesh` uses each cutting move's tool geometry when `--tool` is omitted, including tool changes within a path. Missing shapes and unsupported V-bit verification fail explicitly; `--tool` remains a global override (#146). +- Three-axis mill and router posts may set `"units": "inch"`. The IR and physical tolerances stay in millimetres. The writer emits `G20` and converts linear words (`X` `Y` `Z`, `I` `J` `K`, `R`) and units-per-minute feeds; read-back and mill audit convert them back to millimetres. Template literals are not converted. Rotary, five-axis, and lathe inch configurations are refused. Built-in posts stay metric. This output has not been proven on a machine (#88). ### Fixed diff --git a/crates/post/README.md b/crates/post/README.md index 7570d76..74a32da 100644 --- a/crates/post/README.md +++ b/crates/post/README.md @@ -4,6 +4,32 @@ RS274/NGC posts. A post definition is JSON (`posts/linuxcnc.json` and the other `decimal_point`, when true, keeps a decimal point on every dimensional word (`X` `Y` `Z`, `I` `J` `K`, `R`, rotary `A`/`B`, and lathe `X`/`Z`): a whole number is `52.` and zero is `0.`. Fanuc and Haas controls read a word written without a decimal point as least-input increments, so `X52` is 0.052 mm and `A90` is 0.09 degrees. Feed (`F`), spindle (`S`), dwell (`P`), line numbers, and tool and program numbers are left alone. LinuxCNC, Mach3, Grbl, and plasma omit the flag and read plain decimals. +## Inch output + +Custom three-axis mill/router posts may set `"units": "inch"`. Built-in posts stay metric. +Use the existing `--post FILE.json` option; input job units and output post units are independent. +The IR, probe targets, feeds, and `PostOptions::arc_tolerance` remain in millimetres and mm/min. +The writer converts linear axes, IJK offsets, arc radii, and feeds to inches and inches/minute. +Dwell times, spindle speeds, identifiers, and digital-output pins are unchanged. + +Start the inch post's header with `G20` and use absolute motion and units-per-minute feeds +(`G90`, `G94`). The writer rejects missing or conflicting units and templates selecting G91, +G93, or G95. A program uses one unit system throughout; switches to G21 are rejected even in +its footer. Literal numbers in templates are already in output units: review such values as +`G64 P0.01` when adapting a metric post. They are not automatically converted. + +`decimals.linear` and `decimals.feed` specify digits in the **output** units. Four linear digits +in inches have a 0.00254 mm increment. Choose enough precision for the required physical +accuracy; `compare`'s positional tolerance is still supplied in mm. Rounding guards and feed +comparison use the physical output increment. Feeds that round to zero are rejected. + +`read_back` and the mill `audit` normalize lengths to mm and G94 feeds to mm/min. The audit +retains `UnitMode::Inch` to identify the actual declaration. G93 feeds in read-back/audit remain +reciprocal minutes. Unit and feed-mode words apply to the whole block regardless of word order. + +Inch posts with `rotary`, `five_axis`, or `lathe` configurations are not supported yet; loading +one returns an explicit error. Their metric output and verification are unchanged. + ## Process cycle `process_cycle` is `hold` or `per_cut`. Omit it for `hold`. diff --git a/crates/post/src/audit.rs b/crates/post/src/audit.rs index dfc3821..863a798 100644 --- a/crates/post/src/audit.rs +++ b/crates/post/src/audit.rs @@ -138,9 +138,9 @@ impl SpindleState { pub struct ArcReplay { /// `G2` or `G3`. pub sense: ArcSense, - /// Absolute centre. + /// Absolute centre in millimetres. pub center: [f64; 3], - /// Signed radius when the block used `R`. + /// Signed radius in millimetres when the block used `R`. pub radius: Option, } @@ -159,7 +159,7 @@ pub struct MotionState { pub units: UnitMode, /// Feed mode after this block. pub feed_mode: FeedMode, - /// Active feed, including a modal `F` from an earlier block. + /// Active feed in mm/min (G94) or reciprocal minutes (G93), including a modal `F`. pub feed: Option, /// This block carried its own `F` word. pub feed_on_block: bool, @@ -173,7 +173,7 @@ pub struct MotionState { pub spindle: SpindleState, /// Coolant. pub coolant: CoolantState, - /// Absolute end, in the frame the post wrote. An omitted axis stays at the previous value. + /// Absolute end in millimetres, in the frame the post wrote. Omitted axes retain their value. pub end: [f64; 3], /// Set on `G2` and `G3`. pub arc: Option, @@ -384,6 +384,7 @@ impl<'a> Parser<'a> { } fn interpret(&mut self, line_no: usize, words: &[Word]) -> Result<()> { + let previous_feed_mode = self.feed_mode; let previous_motion = self.motion; let previous_plane = self.plane; let mut machine_move = false; @@ -488,6 +489,33 @@ impl<'a> Parser<'a> { } } + // Resolve modal G words before normalizing this block's lengths and feed. + if self.post.is_inch() { + if previous_feed_mode != self.feed_mode && !feed_on_block { + self.feed = None; + } + if self.units.is_none() + && (feed_on_block || x.is_some() || y.is_some() || z.is_some() || i.is_some() || j.is_some() || k.is_some() || r.is_some()) + { + return Err(fail(line_no, "length or feed before units are set")); + } + for v in [&mut x, &mut y, &mut z, &mut i, &mut j, &mut k, &mut r].into_iter().flatten() { + *v *= self.post.length_scale(); + if !v.is_finite() { + return Err(fail(line_no, "scaled length is not finite")); + } + } + if feed_on_block && self.feed_mode == FeedMode::PerMinute { + self.feed = self.feed.map(|f| f * self.post.length_scale()); + if self.feed.is_some_and(|f| !f.is_finite()) { + return Err(fail(line_no, "scaled feed is not finite")); + } + } + if saw_rotary { + return Err(fail(line_no, "inch rotary motion is not supported")); + } + } + if digital { let Some(pin) = p_value else { return Err(fail(line_no, "digital output needs a P word")); @@ -618,7 +646,7 @@ impl<'a> Parser<'a> { 17 => self.set_plane(line_no, Plane::Xy, block_plane)?, 18 => self.set_plane(line_no, Plane::Zx, block_plane)?, 19 => self.set_plane(line_no, Plane::Yz, block_plane)?, - 20 => return Err(fail(line_no, "inches are not supported")), + 20 => self.set_units(line_no, UnitMode::Inch)?, 21 => self.set_units(line_no, UnitMode::Millimetre)?, 40 | 53 | 64 | 80 => {} 43 => *want_length = true, @@ -666,6 +694,9 @@ impl<'a> Parser<'a> { } fn set_units(&mut self, line_no: usize, units: UnitMode) -> Result<()> { + if (units == UnitMode::Inch) != self.post.is_inch() { + return Err(fail(line_no, "units disagree with post")); + } if let Some(prev) = self.units && prev != units { @@ -760,7 +791,7 @@ impl<'a> Parser<'a> { if radius == 0.0 { return Err(fail(line_no, "arc radius must be non-zero")); } - let quantum = 10_f64.powi(-i32::from(self.post.linear_decimals())); + let quantum = self.post.linear_quantum(); let center = geom::center_from_radius(self.pos, end, radius, sense, plane, quantum)?; (center, Some(radius)) } else { diff --git a/crates/post/src/audit_5ax.rs b/crates/post/src/audit_5ax.rs index 80154d1..e39eb78 100644 --- a/crates/post/src/audit_5ax.rs +++ b/crates/post/src/audit_5ax.rs @@ -635,7 +635,7 @@ impl<'a> Parser<'a> { 17 => self.set_plane(line_no, Plane::Xy, &mut block_plane)?, 18 => self.set_plane(line_no, Plane::Zx, &mut block_plane)?, 19 => self.set_plane(line_no, Plane::Yz, &mut block_plane)?, - 20 => return Err(fail(line_no, "inches are not supported")), + 20 => return Err(fail(line_no, "inch five-axis verification is not supported yet")), 21 => self.set_units(line_no, UnitMode::Millimetre)?, 40 | 64 | 80 => {} 43 => want_length = true, diff --git a/crates/post/src/audit_lathe.rs b/crates/post/src/audit_lathe.rs index ac0b184..f19c27f 100644 --- a/crates/post/src/audit_lathe.rs +++ b/crates/post/src/audit_lathe.rs @@ -850,7 +850,7 @@ impl<'a> Parser<'a> { match code { 7 => self.x_mode = XMode::Diameter, 8 => self.x_mode = XMode::Radius, - 20 => return Err(fail(line_no, "inches are not supported")), + 20 => return Err(fail(line_no, "inch lathe verification is not supported yet")), 21 => self.units = Some(UnitMode::Millimetre), 40 => self.nose = NoseComp::Off, 41 => self.nose = NoseComp::Left, @@ -1252,7 +1252,8 @@ impl<'a> Parser<'a> { impl Codes { fn from_post(post: &PostDef, lathe: &LatheOutput) -> Self { let mut known_g = BTreeSet::from([ - 0, 1, 2, 3, 4, 7, 8, 17, 18, 19, 21, 28, 40, 41, 42, 43, 49, 50, 53, 54, 55, 56, 57, 58, 59, 61, 64, 80, 90, 93, 94, 95, 96, 97, 98, 99, + 0, 1, 2, 3, 4, 7, 8, 17, 18, 19, 20, 21, 28, 40, 41, 42, 43, 49, 50, 53, 54, 55, 56, 57, 58, 59, 61, 64, 80, 90, 93, 94, 95, 96, 97, 98, + 99, ]); let mut known_m = BTreeSet::from([2, 3, 4, 5, 6, 7, 8, 9, 30]); let mut absorb = |lines: &[String]| { diff --git a/crates/post/src/compare.rs b/crates/post/src/compare.rs index f73a047..f5b0a75 100644 --- a/crates/post/src/compare.rs +++ b/crates/post/src/compare.rs @@ -99,7 +99,14 @@ pub fn compare(program: &Program, moves: &[ReadMove], tol: f64, post: &PostDef) tol, index, drop_z, - LineCheck { setup, angle: end_angle, angle_tol, feed: None, feed_decimals: post.decimals.feed }, + LineCheck { + setup, + angle: end_angle, + angle_tol, + feed: None, + feed_decimals: post.decimals.feed, + feed_scale: post.length_scale(), + }, )?; } } @@ -132,7 +139,14 @@ pub fn compare(program: &Program, moves: &[ReadMove], tol: f64, post: &PostDef) tol, index, drop_z, - LineCheck { setup, angle: end_angle, angle_tol, feed: feed_check, feed_decimals: post.decimals.feed }, + LineCheck { + setup, + angle: end_angle, + angle_tol, + feed: feed_check, + feed_decimals: post.decimals.feed, + feed_scale: post.length_scale(), + }, )?; } } @@ -217,7 +231,13 @@ fn skip_z_only(post: &PostDef, pos: Option, end: Xyz, rapid: bool) -> Resul let Some(pos) = pos else { return Ok(false); }; - if !xy_text_eq(pos, end, post.decimals.linear, post.trailing_zeros, post.decimal_point)? { + if !xy_text_eq( + pos.map(|v| v / post.length_scale()), + end.map(|v| v / post.length_scale()), + post.decimals.linear, + post.trailing_zeros, + post.decimal_point, + )? { return Ok(false); } let drop_rapid = rapid && !post.process_feeds.rapid_z; @@ -350,7 +370,7 @@ fn consume_fallback( if want_inverse { check_recovered_feed(got_feed, geom::dist3(prev_cart, cart), ir_feed, index)?; } else { - check_feed_number(got_feed, ir_feed, post.decimals.feed, index)?; + check_feed_number(got_feed, ir_feed, post.decimals.feed, post.length_scale(), index)?; } } prev_cart = cart; @@ -404,6 +424,7 @@ struct LineCheck<'a> { angle_tol: f64, feed: Option, feed_decimals: u8, + feed_scale: f64, } fn take_line( @@ -446,7 +467,7 @@ fn take_line( check_recovered_feed(got_feed, feed.length, feed.ir_feed, index)?; } else { let expected = if feed.inverse { rotary::inverse_minutes_feed(feed.ir_feed, feed.length)? } else { feed.ir_feed }; - check_feed_number(got_feed, expected, check.feed_decimals, index)?; + check_feed_number(got_feed, expected, check.feed_decimals, if feed.inverse { 1.0 } else { check.feed_scale }, index)?; } } Ok(()) @@ -648,7 +669,7 @@ fn consume_curve( // A short move that merely ends near this arc is the next motion. // The tail is another block of this same circle. R-word centre error // follows the output quantum, not a loose chord tolerance. - let quantum = 10_f64.powi(-i32::from(check.post.linear_decimals())); + let quantum = check.post.linear_quantum(); let centre_tol = if radius.is_some() { radius_centre_limit(point, next_end, center, plane, quantum) } else { tol.max(quantum) }; centre_ok(got_center, center, plane, radius.is_some(), centre_tol) @@ -790,7 +811,7 @@ fn take_thread( if geom::dist3(got, end) > tol { return Err(invalid(format!("motion {index}: end differs by {:.6} mm", geom::dist3(got, end)))); } - let quantum = 10_f64.powi(-i32::from(post.linear_decimals())); + let quantum = post.linear_quantum(); let limit = tol.max(quantum * 0.51); if (got_lead - lead).abs() > limit { return Err(invalid(format!("motion {index}: lead {got_lead} differs from {lead}"))); @@ -850,11 +871,11 @@ fn check_angle(got: Option, expect: f64, tol: f64, index: usize) -> Result< if gap > tol { Err(invalid(format!("motion {index}: rotary angle differs by {gap:.6} deg"))) } else { Ok(()) } } -fn check_feed_number(got: Option, expected: f64, decimals: u8, index: usize) -> Result<()> { +fn check_feed_number(got: Option, expected: f64, decimals: u8, scale: f64, index: usize) -> Result<()> { let Some(got) = got else { return Err(invalid(format!("motion {index}: missing feed"))); }; - let quantum = 10_f64.powi(-i32::from(decimals)); + let quantum = scale * 10_f64.powi(-i32::from(decimals)); let limit = quantum * 0.51 + expected.abs() * 1e-12; if (got - expected).abs() > limit { Err(invalid(format!("motion {index}: feed {got} differs from {expected}"))) } else { Ok(()) } } @@ -906,14 +927,14 @@ fn check_curve_feed( } let implied = check.ir_feed / got; let length = geom::dist3(prev, point); - let quantum = 10_f64.powi(-i32::from(check.post.decimals.linear)); + let quantum = check.post.linear_quantum(); let limit = (4.0 * quantum).max(tol); if (implied - length).abs() > limit { return Err(invalid(format!("motion {index}: inverse-time feed implies {implied:.6} mm, segment is {length:.6} mm"))); } Ok(()) } else { - check_feed_number(move_feed(mv), check.ir_feed, check.post.decimals.feed, index) + check_feed_number(move_feed(mv), check.ir_feed, check.post.decimals.feed, check.post.length_scale(), index) } } diff --git a/crates/post/src/def.rs b/crates/post/src/def.rs index 703a61f..0a7caf2 100644 --- a/crates/post/src/def.rs +++ b/crates/post/src/def.rs @@ -741,8 +741,11 @@ impl PostDef { check_text("name", &self.name, 1, 128)?; check_text("description", &self.description, 0, 1024)?; check_references(&self.references)?; - if self.units != "mm" { - return Err(field("units", "only mm is supported")); + if !matches!(self.units.as_str(), "mm" | "inch") { + return Err(field("units", "must be mm or inch")); + } + if self.is_inch() && (self.rotary.is_some() || self.five_axis.is_some() || self.lathe.is_some()) { + return Err(crate::error::unsupported("inch output is only supported for three-axis mill posts")); } check_line_numbers(&mut self.line_numbers)?; check_decimals(&self.decimals)?; @@ -849,6 +852,19 @@ impl PostDef { self.max_line_length.map(|n| n as usize) } + pub(crate) fn is_inch(&self) -> bool { + self.units == "inch" + } + + /// Millimetres per output length unit. The IR always remains in millimetres. + pub(crate) fn length_scale(&self) -> f64 { + if self.is_inch() { 25.4 } else { 1.0 } + } + + pub(crate) fn linear_quantum(&self) -> f64 { + self.length_scale() * 10_f64.powi(-i32::from(self.decimals.linear)) + } + /// Digits after the decimal point on `X` `Y` `Z` `I` `J` `K` `R`. pub fn linear_decimals(&self) -> u8 { self.decimals.linear diff --git a/crates/post/src/lib.rs b/crates/post/src/lib.rs index 999bb9b..120ec77 100644 --- a/crates/post/src/lib.rs +++ b/crates/post/src/lib.rs @@ -36,6 +36,7 @@ mod geom; mod interp; mod read; mod rotary; +mod units; mod write; pub use audit::{ diff --git a/crates/post/src/read.rs b/crates/post/src/read.rs index 3922c0f..ac4b51e 100644 --- a/crates/post/src/read.rs +++ b/crates/post/src/read.rs @@ -186,10 +186,13 @@ impl<'a> Parser<'a> { } /// Parse `text` with the same post that produced it. +/// Lengths are returned in millimetres and G94 feeds in millimetres per minute. +/// G93 inverse-time feeds remain reciprocal minutes. pub fn read_back(text: &str, post: &PostDef) -> Result> { if text.len() > 64 * 1024 * 1024 { return Err(invalid("g-code is too large")); } + crate::units::validate(text, post)?; let mut parser = Parser::new(post); let mut moves = Vec::new(); for (index, line) in text.lines().enumerate() { @@ -211,7 +214,7 @@ impl<'a> Parser<'a> { self.post.probe_number().is_some_and(|code| (value - code).abs() < 1e-6) } - fn interpret(&mut self, words: Vec<(char, f64)>, raw: &str) -> Result> { + fn interpret(&mut self, mut words: Vec<(char, f64)>, raw: &str) -> Result> { self.note_sub_line(raw); if let Some((letter, value)) = words.first().copied() && letter == 'O' @@ -252,6 +255,27 @@ impl<'a> Parser<'a> { letter => return Err(invalid(format!("unexpected word {letter}"))), } } + // 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) { + true + } else if g_codes.contains(&94) { + false + } else { + self.inverse_time + }; + if g_codes.contains(&93) && g_codes.contains(&94) { + return Err(invalid("line has conflicting feed modes")); + } + for (letter, value) in &mut words { + if axes.contains(letter) || (!g_codes.contains(&4) && matches!(*letter, 'I' | 'J' | 'K' | 'R')) || (*letter == 'F' && !inverse) { + *value *= self.post.length_scale(); + if !value.is_finite() { + return Err(invalid("scaled length or feed is not finite")); + } + } + } + } let f = unique(&words, 'F')?; // Mode lines are recognised before C is applied, so `G107 Y0 C0 R…` does not move and // does not change the modal C word. @@ -327,7 +351,8 @@ impl<'a> Parser<'a> { plane = Plane::Yz; planes += 1; } - 20 => return Err(invalid("inches are not supported")), + 20 if self.post.is_inch() => {} + 20 => return Err(invalid("units disagree with post")), 91 => return Err(invalid("incremental motion is not supported")), 7 | 8 | 50 | 96 | 97 | 98 if self.post.is_lathe() => {} 28 if self.post.is_lathe() => {} @@ -369,6 +394,9 @@ impl<'a> Parser<'a> { if words.iter().any(|(letter, _)| *letter == 'U' || *letter == 'W') && !g_codes.contains(&28) { return Err(invalid("incremental U or W is only accepted on lathe G28")); } + if self.post.is_inch() && inverse_time != self.inverse_time && f.is_none() { + self.feed = None; + } self.inverse_time = inverse_time; // G53 is a non-modal machine-coordinate move. G28 is a reference return (Fanuc writes // U0 W0). Neither block is a part-frame point. @@ -449,6 +477,14 @@ impl<'a> Parser<'a> { self.feed = Some(feed); } let feed = self.feed; + if self.post.is_inch() && !matches!(motion, Motion::Rapid) { + if !feed.is_some_and(|value| value.is_finite() && value > 0.0) { + return Err(invalid("feed move has no positive feed")); + } + if self.inverse_time && f.is_none() { + return Err(invalid("inverse-time block has no F")); + } + } let rotary = self.angle; let inverse_time = self.inverse_time; let five = self.five; @@ -472,7 +508,7 @@ impl<'a> Parser<'a> { return Err(invalid("arc has both R and IJK")); } let center = if let Some(radius) = radius { - let quantum = 10_f64.powi(-i32::from(self.post.decimals.linear)); + let quantum = self.post.linear_quantum(); geom::center_from_radius(start, end, radius, sense, plane, quantum)? } else if i.is_some() || j.is_some() || k.is_some() { [start[0] + i.unwrap_or(0.0), start[1] + j.unwrap_or(0.0), start[2] + k.unwrap_or(0.0)] @@ -622,12 +658,14 @@ impl<'a> Parser<'a> { } fn is_inverse_on(&self, code: u16) -> bool { - self.post.rotary.as_ref().is_some_and(|rotary| rotary.on_code() == Some(code)) + (self.post.is_inch() && code == 93) + || self.post.rotary.as_ref().is_some_and(|rotary| rotary.on_code() == Some(code)) || self.post.five_axis.as_ref().and_then(|five| five.continuous.as_ref()).is_some_and(|cont| cont.on_code() == Some(code)) } fn is_inverse_off(&self, code: u16) -> bool { - self.post.rotary.as_ref().is_some_and(|rotary| rotary.off_code() == Some(code)) + (self.post.is_inch() && code == 94) + || self.post.rotary.as_ref().is_some_and(|rotary| rotary.off_code() == Some(code)) || self.post.five_axis.as_ref().and_then(|five| five.continuous.as_ref()).is_some_and(|cont| cont.off_code() == Some(code)) } @@ -847,7 +885,7 @@ fn strip_line_number(words: &mut Vec<(char, f64)>, prefix: char) -> Result<()> { Ok(()) } -fn strip_comments(line: &str) -> String { +pub(crate) fn strip_comments(line: &str) -> String { let mut out = String::with_capacity(line.len()); let mut chars = line.chars(); while let Some(ch) = chars.next() { @@ -867,7 +905,7 @@ fn strip_comments(line: &str) -> String { out } -fn tokenize(line: &str) -> Result> { +pub(crate) fn tokenize(line: &str) -> Result> { let bytes = line.as_bytes(); let mut index = 0; let mut words = Vec::new(); diff --git a/crates/post/src/tests.rs b/crates/post/src/tests.rs index d6a6444..114df89 100644 --- a/crates/post/src/tests.rs +++ b/crates/post/src/tests.rs @@ -213,7 +213,7 @@ fn builtins_load_and_match_ids() { #[test] fn validation_names_the_field() { let mut raw = serde_json::from_str::(include_str!("../posts/linuxcnc.json")).expect("json"); - raw["units"] = serde_json::json!("inch"); + raw["units"] = serde_json::json!("cm"); let err = load_post(&raw.to_string()).expect_err("units"); assert!(err.to_string().contains("units"), "{err}"); raw["units"] = serde_json::json!("mm"); diff --git a/crates/post/src/units.rs b/crates/post/src/units.rs new file mode 100644 index 0000000..572fcd9 --- /dev/null +++ b/crates/post/src/units.rs @@ -0,0 +1,52 @@ +//! Fixed output units. Literal post templates are already in their declared units. +use crate::def::PostDef; +use crate::error::{Result, invalid}; +use crate::read::{strip_comments, tokenize}; + +/// Inch programs must select G20 before any dimensional word, and never switch units. +/// Keep the historical metric template contract unchanged. +pub(crate) fn validate(text: &str, post: &PostDef) -> Result<()> { + check(text, post, false) +} + +pub(crate) fn validate_written(text: &str, post: &PostDef) -> Result<()> { + check(text, post, true) +} + +fn check(text: &str, post: &PostDef, written: bool) -> Result<()> { + if !post.is_inch() { + return Ok(()); + } + if text.len() > 64 * 1024 * 1024 { + return Err(invalid("g-code is too large")); + } + let mut selected = false; + for (index, line) in text.lines().enumerate() { + if index > 2_000_000 || line.len() > 8192 { + return Err(invalid("g-code exceeds line limits")); + } + let stripped = strip_comments(line); + if stripped.trim() == "%" { + continue; + } + let words = tokenize(&stripped)?; + for &(letter, value) in &words { + if written && letter == 'G' && [91.0, 93.0, 95.0].iter().any(|code| (value - code).abs() < 1e-6) { + return Err(invalid("inch output requires absolute motion and units-per-minute feed templates")); + } + if letter == 'G' && (value - 21.0).abs() < 1e-6 { + return Err(invalid(format!("line {}: units disagree with inch post (G21)", index + 1))); + } + if letter == 'G' && (value - 20.0).abs() < 1e-6 { + selected = true; + } + } + if !selected && words.iter().any(|(letter, _)| post.axis_letters.contains(*letter) || matches!(letter, 'I' | 'J' | 'K' | 'R' | 'F')) { + return Err(invalid(format!("line {}: length or feed before G20", index + 1))); + } + } + if !selected { + return Err(invalid("inch program must declare G20")); + } + Ok(()) +} diff --git a/crates/post/src/write.rs b/crates/post/src/write.rs index 2e5f745..480bac3 100644 --- a/crates/post/src/write.rs +++ b/crates/post/src/write.rs @@ -94,6 +94,7 @@ pub fn post_program(program: &Program, post: &PostDef, options: &PostOptions) -> } writer.finish_five_axis()?; writer.templates("program_end", &post.program_end, &header_vars)?; + crate::units::validate_written(&writer.out, post)?; Ok(writer.out) } @@ -1004,7 +1005,7 @@ impl<'a> Writer<'a> { let mut words = vec![thread.code.clone()]; let rounded = self.push_lathe_axes(&mut words, posted.xyz)?; // A thread stays a ZX move, so it carries no C word. - let lead_text = self.format_axis(lead, self.post.decimals.linear)?; + let lead_text = self.format_length(lead, self.post.decimals.linear)?; words.push(format!("{}{lead_text}", thread.lead_word)); self.linear = None; if thread.lead_word == "F" { @@ -1222,9 +1223,9 @@ impl<'a> Writer<'a> { return Err(invalid("axis letters are missing")); }; let rounded = self.round_xyz(posted.xyz)?; - let x = self.format_axis(rounded[0], self.post.decimals.linear)?; - let y = self.format_axis(rounded[1], self.post.decimals.linear)?; - let z = self.format_axis(rounded[2], self.post.decimals.linear)?; + let x = self.format_length(rounded[0], self.post.decimals.linear)?; + let y = self.format_length(rounded[1], self.post.decimals.linear)?; + let z = self.format_length(rounded[2], self.post.decimals.linear)?; self.emit_words(&["G0".to_string(), format!("{lx}{x}"), format!("{ly}{y}")])?; self.linear = Some("G0"); self.emit_words(&["G0".to_string(), format!("{lz}{z}")])?; @@ -1402,9 +1403,9 @@ impl<'a> Writer<'a> { let chord = geom::in_plane_len(plane, start, rounded); if chord > 1e-9 && ir_radius > 1e-12 { let magnitude = posted_radius.max(chord * 0.5); - let quantum = 10_f64.powi(-i32::from(self.post.decimals.linear)); - let r_text = self.format_axis(magnitude, self.post.decimals.linear)?; - let r_written = r_text.parse::().map_err(|_| invalid("axis value is not a number"))?; + let quantum = self.post.linear_quantum(); + let r_text = self.format_length(magnitude, self.post.decimals.linear)?; + let r_written = r_text.parse::().map_err(|_| invalid("axis value is not a number"))? * self.post.length_scale(); let written = self.written_sense(sense, plane); let over = match geom::center_from_radius(start, rounded, r_written, written, plane, quantum) { Ok(read_center) => { @@ -1431,11 +1432,11 @@ impl<'a> Writer<'a> { let curve_start = self.exact.unwrap_or(start); let ir_radius = geom::in_plane_len(plane, curve_start, center); if ir_radius > 1e-12 { - let quantum = 10_f64.powi(-i32::from(self.post.decimals.linear)); + let quantum = self.post.linear_quantum(); let mut read_center = start; for (offset, (axis, value)) in read_center.iter_mut().zip(start.iter().zip(center.iter())) { - let text = self.format_axis(value - axis, self.post.decimals.linear)?; - *offset += text.parse::().map_err(|_| invalid("axis value is not a number"))?; + let text = self.format_length(value - axis, self.post.decimals.linear)?; + *offset += text.parse::().map_err(|_| invalid("axis value is not a number"))? * self.post.length_scale(); } let controller = geom::forward_delta( geom::angle_of(plane, start, read_center), @@ -1465,7 +1466,7 @@ impl<'a> Writer<'a> { // block still fits; a semicircle then reads back with its centre on the midpoint. let magnitude = if chord > 0.0 { radius.max(chord * 0.5) } else { radius }; let signed = if sweep > std::f64::consts::PI + 1e-8 { -magnitude } else { magnitude }; - words.push(format!("R{}", self.format_axis(signed, self.post.decimals.linear)?)); + words.push(format!("R{}", self.format_length(signed, self.post.decimals.linear)?)); } ArcMode::Ijk => { let offsets = [center[0] - start[0], center[1] - start[1], center[2] - start[2]]; @@ -1476,7 +1477,7 @@ impl<'a> Writer<'a> { if *letter == "K" && self.mirror_z() { value = -value; } - words.push(format!("{letter}{}", self.format_axis(value, self.post.decimals.linear)?)); + words.push(format!("{letter}{}", self.format_length(value, self.post.decimals.linear)?)); } } ArcMode::Lines => return Err(invalid("line mode reached an arc block")), @@ -1661,16 +1662,25 @@ impl<'a> Writer<'a> { let Some(pos) = self.pos else { return Ok(false); }; - if !xy_text_eq(pos, end, self.post.decimals.linear, self.post.trailing_zeros, self.post.decimal_point)? { + if !xy_text_eq( + pos.map(|v| v / self.post.length_scale()), + end.map(|v| v / self.post.length_scale()), + self.post.decimals.linear, + self.post.trailing_zeros, + self.post.decimal_point, + )? { return Ok(false); } let drop_rapid = code == "G0" && !self.post.process_feeds.rapid_z; Ok(drop_rapid || self.post.drop_z()) } - /// `X` `Y` `Z` `I` `J` `K` `R`, rotary `A`/`B`, and lathe `X`/`Z`. - /// - /// Feed, spindle, dwell, and sequence numbers stay on [`format_fixed`]. + /// Convert an internal millimetre length to the post's output units. + fn format_length(&self, value: f64, decimals: u8) -> Result { + self.format_axis(value / self.post.length_scale(), decimals) + } + + /// Format a dimensional word already in output units, or an angle in degrees. fn format_axis(&self, value: f64, decimals: u8) -> Result { format_dim(value, decimals, self.post.trailing_zeros, self.post.decimal_point) } @@ -1681,8 +1691,8 @@ impl<'a> Writer<'a> { let Some(value) = end.get(index).copied() else { return Err(invalid("axis value is missing")); }; - let text = self.format_axis(value, self.post.decimals.linear)?; - *slot = text.parse::().map_err(|_| invalid("axis value is not a number"))?; + let text = self.format_length(value, self.post.decimals.linear)?; + *slot = text.parse::().map_err(|_| invalid("axis value is not a number"))? * self.post.length_scale(); } Ok(rounded) } @@ -1692,9 +1702,9 @@ impl<'a> Writer<'a> { /// An XY or YZ arc counts as a Y move even when the endpoint Y is unchanged. A move that /// returns Y to 0 is still a Y move. The following centreline move restores `turn_plane`. fn prepare_lathe_motion(&mut self, end: Xyz, c: Option, axis: Vec3, kind: LatheKind) -> Result<()> { - let y_text = self.format_axis(end[1], self.post.decimals.linear)?; + let y_text = self.format_length(end[1], self.post.decimals.linear)?; let y_changes = self.y_text_changes(&y_text)?; - let zero = self.format_axis(0.0, self.post.decimals.linear)?; + let zero = self.format_length(0.0, self.post.decimals.linear)?; match kind { LatheKind::Arc(plane) if plane != Plane::Zx => self.prepare_milling_arc(axis, plane, c), _ if y_changes => { @@ -1800,7 +1810,7 @@ impl<'a> Writer<'a> { /// `None` modal means centreline 0 has not been written, which compares equal to a formatted 0. fn y_text_changes(&self, y_text: &str) -> Result { - let zero = self.format_axis(0.0, self.post.decimals.linear)?; + let zero = self.format_length(0.0, self.post.decimals.linear)?; let current = self.y_modal.as_deref().unwrap_or(zero.as_str()); Ok(y_text != current) } @@ -1821,7 +1831,7 @@ impl<'a> Writer<'a> { let Some(value) = rounded.get(index).copied() else { return Err(invalid("axis value is missing")); }; - let text = self.format_axis(value, self.post.decimals.linear)?; + let text = self.format_length(value, self.post.decimals.linear)?; words.push(format!("{letter}{text}")); } Ok(rounded) @@ -1840,10 +1850,10 @@ impl<'a> Writer<'a> { let radius = end[0] + dr; let z_tip = end[2] + dz; let x_out = if lathe.diameter_mode { radius * 2.0 } else { radius }; - let x_text = self.format_axis(x_out, self.post.decimals.linear)?; - let y_text = self.format_axis(end[1], self.post.decimals.linear)?; + let x_text = self.format_length(x_out, self.post.decimals.linear)?; + let y_text = self.format_length(end[1], self.post.decimals.linear)?; let z_out = if self.mirror_z() { -z_tip } else { z_tip }; - let z_text = self.format_axis(z_out, self.post.decimals.linear)?; + let z_text = self.format_length(z_out, self.post.decimals.linear)?; words.push(format!("{x_letter}{x_text}")); let has_y = lathe.y_axis.is_some(); if has_y && (self.force_axes || self.y_text_changes(&y_text)?) { @@ -1885,9 +1895,9 @@ impl<'a> Writer<'a> { let z_letter = letters.get(2).copied().ok_or_else(|| invalid("axis value is missing"))?; let x_diameter = self.interp_x_diameter(); let x_out = if x_diameter { end[0] * 2.0 } else { end[0] }; - let x_text = self.format_axis(x_out, self.post.decimals.linear)?; - let y_text = self.format_axis(end[1], self.post.decimals.linear)?; - let z_text = self.format_axis(end[2], self.post.decimals.linear)?; + let x_text = self.format_length(x_out, self.post.decimals.linear)?; + let y_text = self.format_length(end[1], self.post.decimals.linear)?; + let z_text = self.format_length(end[2], self.post.decimals.linear)?; words.push(format!("{x_letter}{x_text}")); words.push(format!("{y_letter}{y_text}")); words.push(format!("{z_letter}{z_text}")); @@ -1930,7 +1940,7 @@ impl<'a> Writer<'a> { if !(feed.is_finite() && feed > 0.0) { return Err(invalid("feed must be positive")); } - let text = format_fixed(feed, self.post.decimals.feed, self.post.trailing_zeros)?; + let text = format_fixed(feed / self.post.length_scale(), self.post.decimals.feed, self.post.trailing_zeros)?; // A positive feed can still round to 0 at this post's F precision (`F0`, `F0.0000`). let written: f64 = text.parse().map_err(|_| invalid("feed is not a number"))?; if !(written.is_finite() && written > 0.0) { @@ -2093,11 +2103,11 @@ impl<'a> Writer<'a> { .ok_or_else(|| unsupported(format!("post `{}` has no bar feeder", self.post.id())))?; let mut words = vec![bar.code]; if let Some(length) = part_length { - let text = self.format_axis(length, self.post.decimals.linear)?; + let text = self.format_length(length, self.post.decimals.linear)?; words.push(format!("{}{text}", bar.part_length_word)); } if let Some(clamp) = min_clamp { - let text = self.format_axis(clamp, self.post.decimals.linear)?; + let text = self.format_length(clamp, self.post.decimals.linear)?; words.push(format!("{}{text}", bar.min_clamp_word)); } self.emit_words(&words) @@ -2736,8 +2746,8 @@ impl<'a> Writer<'a> { } if self.prepare_interp(false)? { let lines = self.cylinder_lines(true); - let radius_text = self.format_axis(radius, self.post.decimals.linear)?; - let diameter_text = self.format_axis(radius * 2.0, self.post.decimals.linear)?; + let radius_text = self.format_length(radius, self.post.decimals.linear)?; + let diameter_text = self.format_length(radius * 2.0, self.post.decimals.linear)?; self.templates( "lathe.cylinder_interp.on", &lines, diff --git a/crates/post/tests/golden/inch.router.nc b/crates/post/tests/golden/inch.router.nc new file mode 100644 index 0000000..ef4db75 --- /dev/null +++ b/crates/post/tests/golden/inch.router.nc @@ -0,0 +1,12 @@ +G20 +G90 +G17 +G40 +G49 +G64 P0.0004 +G0 X0.0000 Y0.0000 Z0.0000 +G38.2 X0.0000 Y0.0000 Z-1.0000 F10.0000 +G0 X0.0000 Y0.0000 Z1.0000 +G4 P0.500 +G1 X1.0000 Y-2.0000 Z0.0000 +M2 diff --git a/crates/post/tests/inch.rs b/crates/post/tests/inch.rs new file mode 100644 index 0000000..b2e00b0 --- /dev/null +++ b/crates/post/tests/inch.rs @@ -0,0 +1,224 @@ +//! Synthetic inch programs: physical round trips, modal replay, and precision boundaries. +#![allow(clippy::expect_used)] + +use campanion_ir::{ArcSense, ClBlock, ClMotion, ClPath, CompensationMode, Event, Feeds, MoveRole, Plane, Pose, Program, Vec3}; +use campanion_post::{ + PostDef, PostOptions, ReadMove, UnitMode, audit, audit_five, audit_lathe, builtin, compare, load_post, post_program, read_back, +}; +use proptest::prelude::*; +use serde_json::{Value, json}; + +fn raw_post() -> Value { + let mut raw: Value = serde_json::from_str(include_str!("../posts/linuxcnc_router.json")).expect("valid synthetic fixture"); + raw["units"] = json!("inch"); + raw["line_numbers"]["enabled"] = json!(false); + // Literal template values are in output units, not converted by the writer. + raw["header"] = json!(["G20", "G90", "G17", "G40", "G49", "G64 P0.0004"]); + raw +} +fn post() -> PostDef { + load_post(&raw_post().to_string()).expect("valid synthetic fixture") +} +fn pose(p: [f64; 3]) -> Pose { + Pose::new(Vec3::new(p[0], p[1], p[2]).expect("valid synthetic fixture"), Vec3::Z).expect("valid synthetic fixture") +} +fn rapid(p: [f64; 3]) -> ClBlock { + ClBlock::Motion(ClMotion::Rapid { to: pose(p), feed: None }) +} +fn line(p: [f64; 3], feed: f64) -> ClBlock { + ClBlock::Motion(ClMotion::Linear { to: pose(p), feed, role: MoveRole::Cut }) +} +fn program(blocks: Vec) -> Program { + let feeds = Feeds::try_new(254.0, None, None, None, None).expect("valid synthetic fixture"); + let path = ClPath::try_new(None, None, CompensationMode::Off, feeds, None, vec![], blocks).expect("valid synthetic fixture"); + Program::try_new(None, vec![], None, vec![path], vec![], vec![], None).expect("valid synthetic fixture") +} +fn roundtrip(program: &Program, post: &PostDef) -> String { + let text = post_program(program, post, &PostOptions { arc_tolerance: 0.05 }).expect("valid synthetic fixture"); + let moves = read_back(&text, post).expect("valid synthetic fixture"); + compare(program, &moves, 0.005, post).expect("valid synthetic fixture"); + let report = audit(&text, post).expect("valid synthetic fixture"); + assert!(report.motions.iter().all(|m| m.units == UnitMode::Inch)); + text +} + +#[test] +fn known_lengths_feeds_and_probe_roundtrip() { + let p = program(vec![ + rapid([0.0; 3]), + ClBlock::Event(Event::Probe { toward: Some(pose([0.0, 0.0, -25.4])), feed: Some(254.0) }), + rapid([0.0, 0.0, 25.4]), + ClBlock::Event(Event::Dwell { seconds: 0.5 }), + line([25.4, -50.8, 0.0], 254.0), + ]); + let text = roundtrip(&p, &post()); + assert_eq!(text, include_str!("golden/inch.router.nc")); + assert!(text.contains("G38.2 X0.0000 Y0.0000 Z-1.0000 F10.0000"), "{text}"); + assert!(text.contains("X1.0000 Y-2.0000 Z0.0000"), "{text}"); + assert!(text.contains("G4 P0.500"), "{text}"); + assert!(text.contains("G64 P0.0004"), "{text}"); +} + +#[test] +fn block_order_and_modal_feeds_are_physical() { + let text = "F10 X1 G1 G20 G90 G17 S12000 M3 T7 M6\nY2\nG4 P0.5\nG1 Z1\n"; + let report = audit(text, &post()).expect("valid synthetic fixture"); + assert_eq!(report.motions[0].end, [25.4, 0.0, 0.0]); + assert_eq!(report.motions[1].end, [25.4, 50.8, 0.0]); + assert!(report.motions.iter().all(|m| m.feed == Some(254.0))); + assert_eq!(report.motions[0].spindle.speed, 12000.0); + assert_eq!(report.motions[0].tool, Some(7)); + assert!(read_back(text, &post()).expect("valid synthetic fixture").iter().any(|m| matches!(m, ReadMove::Dwell {seconds} if *seconds == 0.5))); +} + +#[test] +fn inverse_time_is_not_length_scaled() { + let text = "G20 G90 G17\nF2 X1 G1 G93\nF10 G94 X2\nX3\n"; + let report = audit(text, &post()).expect("valid synthetic fixture"); + assert_eq!(report.motions[0].feed, Some(2.0)); + assert_eq!(report.motions[1].feed, Some(254.0)); + assert_eq!(report.motions[2].feed, Some(254.0)); +} + +#[test] +fn feed_mode_changes_require_a_new_dimensional_feed() { + let text = "G20 G90 G17\nG93 G1 X1 F2\nG94\nG1 X2\n"; + assert!(read_back(text, &post()).is_err()); + assert!(audit(text, &post()).is_err()); +} + +#[test] +fn units_must_match_before_lengths_and_cannot_switch() { + for text in ["G90 G1 X1 F10", "G21\nG20 G90 G1 X1 F10", "G20 G90 G1 X1 F10\nG21", "G20 G21", "G1 X1\nG20"] { + assert!(read_back(text, &post()).is_err(), "{text}"); + assert!(audit(text, &post()).is_err(), "{text}"); + } + let metric = builtin("linuxcnc_router").expect("valid synthetic fixture"); + assert!(read_back("G20", &metric).is_err()); + assert!(audit("G20", &metric).is_err()); + for header in + [json!(["G21", "G90"]), json!(["G90"]), json!(["G0 X1", "G20", "G90"]), json!(["G20", "G93"]), json!(["G20", "G95"]), json!(["G20", "G91"])] + { + let mut raw = raw_post(); + raw["header"] = header; + let post = load_post(&raw.to_string()).expect("valid synthetic fixture"); + assert!(post_program(&program(vec![rapid([0.0; 3])]), &post, &PostOptions { arc_tolerance: 0.05 }).is_err()); + } + let mut raw = raw_post(); + raw["footer"] = json!(["G21"]); + assert!( + post_program( + &program(vec![rapid([0.0; 3])]), + &load_post(&raw.to_string()).expect("valid synthetic fixture"), + &PostOptions { arc_tolerance: 0.05 } + ) + .is_err() + ); +} + +#[test] +fn unsupported_families_remain_explicit() { + for source in [include_str!("../posts/linuxcnc.json"), include_str!("../posts/linuxcnc_5ax.json"), include_str!("../posts/linuxcnc_lathe.json")] { + let mut raw: Value = serde_json::from_str(source).expect("valid synthetic fixture"); + raw["units"] = json!("inch"); + let err = load_post(&raw.to_string()).unwrap_err().to_string(); + assert!(err.contains("three-axis"), "{err}"); + } + assert!(audit_five("N10 G20", &builtin("linuxcnc_5ax").expect("valid synthetic fixture")).unwrap_err().to_string().contains("not supported yet")); + let err = audit_lathe("N10 G20", &builtin("linuxcnc_lathe").expect("valid synthetic fixture")).unwrap_err(); + assert!(err.to_string().contains("not supported yet"), "{err}"); + assert!(audit("G20 G90 G0 A90", &post()).is_err()); +} + +#[test] +fn feed_rounding_and_comparison_use_inch_quantum() { + let mut raw = raw_post(); + raw["decimals"]["feed"] = json!(2); + let post = load_post(&raw.to_string()).expect("valid synthetic fixture"); + roundtrip(&program(vec![rapid([0.0; 3]), line([25.4, 0.0, 0.0], 254.1)]), &post); + let err = post_program(&program(vec![rapid([0.0; 3]), line([1.0; 3], 0.01)]), &post, &PostOptions { arc_tolerance: 0.05 }).unwrap_err(); + assert!(err.to_string().contains("rounds to zero")); + let p = program(vec![rapid([0.0; 3]), line([25.4, 0.0, 0.0], 254.1)]); + let text = post_program(&p, &post, &PostOptions { arc_tolerance: 0.05 }).expect("valid synthetic fixture").replace("F10.00", "F10.02"); + assert!(compare(&p, &read_back(&text, &post).expect("valid synthetic fixture"), 0.005, &post).is_err()); +} + +fn arc_program(plane: Plane, radius: f64, sweep: f64, rise: f64, feed: f64) -> Program { + let map = |u, v, w| match plane { + Plane::Xy => [u, v, w], + Plane::Zx => [v, w, u], + Plane::Yz => [w, u, v], + }; + let end = if sweep == std::f64::consts::TAU { map(radius, 0.0, rise) } else { map(radius * sweep.cos(), radius * sweep.sin(), rise) }; + program(vec![ + rapid(map(radius, 0.0, 0.0)), + ClBlock::Motion(ClMotion::Arc { to: pose(end), plane, center: Vec3::ZERO, sense: ArcSense::Ccw, feed, role: MoveRole::Cut }), + ]) +} + +#[test] +fn arcs_helices_full_circles_and_lines_in_every_plane() { + for mode in ["ijk", "r", "lines"] { + let mut raw = raw_post(); + raw["arcs"]["mode"] = json!(mode); + let post = load_post(&raw.to_string()).expect("valid synthetic fixture"); + for plane in [Plane::Xy, Plane::Zx, Plane::Yz] { + for sweep in [std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 4.5, std::f64::consts::TAU] { + for rise in [0.0, 2.54] { + roundtrip(&arc_program(plane, 25.4, sweep, rise, 254.0), &post); + } + } + } + } +} + +#[test] +fn sub_quantum_arc_does_not_turn_into_full_circle() { + let p = arc_program(Plane::Xy, 25.4, 0.00001, 0.0, 254.0); + let text = roundtrip(&p, &post()); + assert!(!text.lines().any(|l| l.starts_with("G2 ") || l.starts_with("G3 ")), "{text}"); +} + +#[test] +fn decimal_point_pins_and_numbering_are_not_scaled() { + let mut raw = raw_post(); + raw["trailing_zeros"] = json!(false); + raw["decimal_point"] = json!(true); + raw["line_numbers"]["enabled"] = json!(true); + raw["header"] = json!(["G20", "G90", "G17", "M64 P1"]); + let text = + roundtrip(&program(vec![rapid([0.0; 3]), line([25.4, 0.0, 0.0], 254.0)]), &load_post(&raw.to_string()).expect("valid synthetic fixture")); + assert!(text.contains("N40 M64 P1"), "{text}"); + assert!(text.contains("X1. Y0. Z0. F10"), "{text}"); +} + +#[test] +fn physical_tolerance_is_not_relaxed_to_hide_rounding_error() { + let p = program(vec![rapid([0.0; 3]), line([25.4008, 0.0, 0.0], 254.0)]); + let text = post_program(&p, &post(), &PostOptions { arc_tolerance: 0.05 }).expect("valid synthetic fixture"); + let moves = read_back(&text, &post()).expect("valid synthetic fixture"); + assert!(compare(&p, &moves, 0.0001, &post()).is_err()); + compare(&p, &moves, 0.002, &post()).expect("valid synthetic fixture"); +} + +#[test] +fn scaled_overflow_and_conflicting_feed_modes_are_rejected() { + let huge = "9".repeat(308); + for text in [format!("G20 G90 G1 X{huge} F10"), format!("G20 G90 G1 X1 F{huge}"), "G20 G90 G1 X1 F2 G93 G94".into()] { + assert!(read_back(&text, &post()).is_err(), "{text}"); + assert!(audit(&text, &post()).is_err(), "{text}"); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + #[test] + fn arbitrary_lines_roundtrip(x in -500.0..500.0, y in -500.0..500.0, z in -50.0..50.0, feed in 1.0..3000.0) { + roundtrip(&program(vec![rapid([0.0; 3]), line([x,y,z],feed)]), &post()); + } + #[test] + fn arbitrary_arcs_roundtrip(radius in 1.0..100.0, sweep in 0.1..6.2, rise in -10.0..10.0, feed in 1.0..3000.0, r_mode in any::()) { + let mut raw = raw_post(); if r_mode { raw["arcs"]["mode"] = json!("r"); } + roundtrip(&arc_program(Plane::Xy,radius,sweep,rise,feed), &load_post(&raw.to_string()).expect("valid synthetic fixture")); + } +}