diff --git a/CHANGELOG.md b/CHANGELOG.md index d15cc00..cfe9ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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. +- Pocket operations may set `start_depth`, a length downward from the original stock surface. Depth passes start below that Z and still end at `depth`. Omitted or 0 leaves existing output unchanged. Other operation kinds reject the field. This output has not been proven on a machine. ## 0.12.0 — 2026-11-12 diff --git a/crates/cli/src/jobfile.rs b/crates/cli/src/jobfile.rs index ca654f2..6c46cfd 100644 --- a/crates/cli/src/jobfile.rs +++ b/crates/cli/src/jobfile.rs @@ -67,6 +67,11 @@ //! cutter-location Z. Entry is a plunge. //! //! A tool may set `shape`: `"flat"` (the default), `"ball"`, or `{"bull": corner_radius}`. +//! A pocket may set `start_depth`, a non-negative distance below stock Z0, less than `depth`. +//! Area and wall-finishing passes cover only the remaining interval. The caller asserts that +//! the pocket was already cleared above it. Retract heights and entry-angle limits stay unchanged. +//! Every other operation rejects the field. See `docs/job-file.md`. +//! //! A shape other than flat is only used by a surface, rough, waterline or rotary finish operation. //! Two-dimensional operations keep the flat diameter offset. //! A tool may set `speed`, a spindle speed in rpm: positive, finite, and at most 100000. An @@ -926,6 +931,7 @@ impl Finish { /// drill pecks, and otherwise the drill feeds to depth in one move. /// [`Self::onion_skin`] is the thickness left until the last depth pass. `None` means the file /// omitted it, and the depth plan is unchanged. +/// [`Self::start_depth`] is an already-cleared pocket depth; the other kinds store 0. /// [`Self::index_deg`] is the machine A/B angle for this operation, in degrees. `None` means the /// file omitted it. A job with a rotary setup then runs the operation at 0°. A wrap rejects /// `index_deg`. [`Self::cylinder_diameter`] is set only on a wrap. [`Self::u_offset`] and @@ -941,6 +947,8 @@ pub struct Operation { layers: Vec, tool_id: u32, depth: f64, + /// Already-cleared depth below the stock surface. Zero except on pockets. + start_depth: f64, step_down: f64, /// Thickness left until the last depth pass, in millimetres. `None` when the file omits `onion_skin`. onion_skin: Option, @@ -1113,6 +1121,12 @@ impl Operation { self.depth } + /// Already-cleared pocket depth below the stock surface, in millimetres. + /// Zero when omitted. The final depth remains measured from the stock surface. + pub fn start_depth(&self) -> f64 { + self.start_depth + } + /// Maximum thickness of one depth pass, in millimetres. /// /// A chamfer cuts the tip depth in one pass, so this matches [`Self::depth`]. @@ -2423,6 +2437,7 @@ fn parse_operation(value: &Value, scale: f64, tools: &[JobTool]) -> Result Result Result 0.0 && start_depth >= depth - stock_to_leave.floor() { + return Err(JobFileError::invalid("start_depth must be less than depth - stock_to_leave floor")); + } + let finish = parse_finish(optional(map, "finish"), kind, depth, start_depth, scale)?; let entry = parse_entry(optional(map, "entry"), scale)?; let feeds = parse_operation_feeds(require(map, "feeds").map_err(from_cli)?, scale)?; let params = parse_timing(map, scale)?; @@ -2575,13 +2596,14 @@ fn parse_operation(value: &Value, scale: f64, tools: &[JobTool]) -> Result, scale: f64, tools: &[J layers: Vec::new(), tool_id, depth: 0.0, + start_depth: 0.0, step_down: 0.0, onion_skin: None, dwell: None, @@ -2958,6 +2981,7 @@ fn finish_rough(name: String, map: &Map, scale: f64, tools: &[Job layers: Vec::new(), tool_id, depth: 0.0, + start_depth: 0.0, step_down, onion_skin: None, dwell: None, @@ -3088,6 +3112,7 @@ fn finish_waterline(name: String, map: &Map, scale: f64, tools: & layers: Vec::new(), tool_id, depth: 0.0, + start_depth: 0.0, step_down, onion_skin: None, dwell: None, @@ -3167,8 +3192,8 @@ fn finish_wrap(name: String, map: &Map, scale: f64, tools: &[JobT if !tools.iter().any(|tool| tool.id() == tool_id) { return Err(JobFileError::invalid(format!("unknown tool {tool_id}"))); } - let (depth, step_down) = parse_depth_and_step(map, OperationKind::Wrap, scale, None)?; - let onion_skin = parse_onion_skin(map, OperationKind::Wrap, depth, step_down, None, scale)?; + let (depth, step_down, _) = parse_depth_and_step(map, OperationKind::Wrap, scale, None)?; + let onion_skin = parse_onion_skin(map, OperationKind::Wrap, depth, step_down, None, 0.0, scale)?; let Some(diameter_value) = optional(map, "diameter") else { return Err(JobFileError::invalid("wrap needs a diameter")); }; @@ -3189,6 +3214,7 @@ fn finish_wrap(name: String, map: &Map, scale: f64, tools: &[JobT layers, tool_id, depth, + start_depth: 0.0, step_down, onion_skin, dwell: None, @@ -3316,6 +3342,7 @@ fn finish_rotary(name: String, map: &Map, scale: f64, tools: &[Jo layers: Vec::new(), tool_id, depth: 0.0, + start_depth: 0.0, step_down: 0.0, onion_skin: None, dwell: None, @@ -3357,6 +3384,7 @@ fn reject_laser_raster_foreign_keys(map: &Map) -> Result<(), JobF for key in [ "layers", "depth", + "start_depth", "step_down", "onion_skin", "stepover", @@ -3443,6 +3471,7 @@ fn finish_laser_raster(name: String, map: &Map, scale: f64, tools layers: Vec::new(), tool_id, depth: 0.0, + start_depth: 0.0, step_down: 0.0, onion_skin: None, dwell: None, @@ -3692,19 +3721,29 @@ fn reject_chamfer_cut_keys(map: &Map, kind: OperationKind) -> Res /// File `depth` and `step_down` for every kind except chamfer. A chamfer stores the derived /// tip depth as a single pass. [`reject_chamfer_cut_keys`] has already rejected those keys. -fn parse_depth_and_step(map: &Map, kind: OperationKind, scale: f64, tip_depth: Option) -> Result<(f64, f64), JobFileError> { +fn parse_depth_and_step(map: &Map, kind: OperationKind, scale: f64, tip_depth: Option) -> Result<(f64, f64, f64), JobFileError> { if matches!(kind, OperationKind::Chamfer { .. }) { reject_chamfer_cut_keys(map, kind)?; let Some(depth) = tip_depth else { return Err(JobFileError::invalid("chamfer tip depth is missing")); }; check_depth(depth, depth)?; - return Ok((depth, depth)); + return Ok((depth, depth, 0.0)); } let depth = scale_f64(require(map, "depth").map_err(from_cli)?, scale, "depth")?; let step_down = scale_f64(require(map, "step_down").map_err(from_cli)?, scale, "step_down")?; - check_depth(depth, step_down)?; - Ok((depth, step_down)) + // Check the absolute depth bound independently; the pass limit applies only to + // the remaining pocket interval, not to material the caller says is already clear. + check_depth(depth, depth)?; + let start_depth = match optional(map, "start_depth") { + Some(value) => non_negative_length(value, scale, "start_depth")?, + None => 0.0, + }; + if start_depth >= depth { + return Err(JobFileError::invalid("start_depth must be less than depth")); + } + check_depth(depth - start_depth, step_down)?; + Ok((depth, step_down, start_depth)) } fn positive_length(value: &Value, scale: f64, what: &str) -> Result { @@ -3835,7 +3874,7 @@ fn non_negative_length(value: &Value, scale: f64, what: &str) -> Result, kind: OperationKind, depth: f64, scale: f64) -> Result, JobFileError> { +fn parse_finish(value: Option<&Value>, kind: OperationKind, depth: f64, start_depth: f64, scale: f64) -> Result, JobFileError> { let Some(value) = value else { return Ok(None); }; @@ -3853,7 +3892,7 @@ fn parse_finish(value: Option<&Value>, kind: OperationKind, depth: f64, scale: f if !(step_down > 0.0) { return Err(JobFileError::invalid("finish step_down must be positive")); } - check_depth(depth, step_down)?; + check_depth(depth - start_depth, step_down)?; step_down } None => depth, @@ -4205,6 +4244,7 @@ fn parse_onion_skin( depth: f64, step_down: f64, finish: Option<&Finish>, + start_depth: f64, scale: f64, ) -> Result, JobFileError> { let Some(value) = optional(map, "onion_skin") else { @@ -4225,9 +4265,12 @@ fn parse_onion_skin( if skin >= depth { return Err(JobFileError::invalid("onion_skin must be less than depth")); } - check_onion_plan(depth, step_down, skin)?; + if skin >= depth - start_depth { + return Err(JobFileError::invalid("start_depth must be less than depth - onion_skin")); + } + check_onion_plan(depth - start_depth, step_down, skin)?; if let Some(finish) = finish { - check_onion_plan(depth, finish.step_down(), skin)?; + check_onion_plan(depth - start_depth, finish.step_down(), skin)?; } Ok(Some(skin)) } @@ -4565,6 +4608,73 @@ mod tests { ) } + fn start_depth_text(extra: &str) -> String { + envelope( + "part.svg", + r#"[{"id":1,"diameter":6}]"#, + &format!( + r#"[{{"name":"continue","type":"pocket","layers":["PART"],"tool_id":1,"depth":8,"step_down":2,"feeds":{{"cut":1000}}{extra}}}]"# + ), + ) + } + + #[test] + fn pocket_start_depth_defaults_and_scales_in_job_units() { + let default = load(&write_job("start-default", &start_depth_text(""))).expect("default"); + assert_eq!(default.operations()[0].start_depth(), 0.0); + let inch = start_depth_text(r#", "start_depth":3"#).replace(r#""units":"mm""#, r#""units":"inch""#); + let inch = load(&write_job("start-inch", &inch)).expect("inch"); + near(inch.operations()[0].start_depth(), 3.0 * 25.4); + near(inch.operations()[0].depth(), 8.0 * 25.4); + } + + #[test] + fn pocket_start_depth_rejects_invalid_intervals_and_allowance_conflicts() { + for (extra, needle) in [ + (r#", "start_depth":-1"#, "start_depth must be at least 0"), + (r#", "start_depth":8"#, "start_depth must be less than depth"), + (r#", "start_depth":9"#, "start_depth must be less than depth"), + (r#", "start_depth":"3""#, "start_depth"), + (r#", "start_depth":1e300"#, "start_depth"), + (r#", "start_depth":7,"stock_to_leave":{"floor":1}"#, "stock_to_leave floor"), + (r#", "start_depth":7.5,"stock_to_leave":{"floor":1},"finish":{}"#, "stock_to_leave floor"), + (r#", "start_depth":7,"onion_skin":1"#, "depth - onion_skin"), + (r#", "start_depth":7.5,"onion_skin":1,"finish":{}"#, "depth - onion_skin"), + ] { + let err = load(&write_job("start-invalid", &start_depth_text(extra))).expect_err("invalid pocket"); + assert!(err.to_string().contains(needle), "{extra}: {err}"); + } + for hostile in ["NaN", "Infinity", "1e9999"] { + assert!(load(&write_job("start-hostile", &start_depth_text(&format!(",\"start_depth\":{hostile}")))).is_err()); + } + } + + #[test] + fn start_depth_is_rejected_on_every_other_operation_even_when_zero() { + for kind in ["profile", "drill", "engrave", "face", "chamfer", "surface", "rough", "waterline", "wrap", "rotary_finish", "laser_raster"] { + let mut text = start_depth_text(r#", "start_depth":0"#).replace(r#""type":"pocket""#, &format!("\"type\":\"{kind}\"")); + if matches!(kind, "profile" | "chamfer") { + text = text.replace(r#""layers""#, r#""side":"outside","layers""#); + } + let err = load(&write_job("start-kind", &text)).expect_err("unsupported start_depth"); + assert!(err.to_string().contains("start_depth is only valid on a pocket"), "{kind}: {err}"); + } + } + + #[test] + fn pocket_start_depth_pass_limits_apply_to_remaining_material_and_finish() { + let text = start_depth_text(r#", "start_depth":1999,"finish":{"step_down":0.001}"#) + .replace(r#""depth":8"#, r#""depth":2000"#) + .replace(r#""step_down":2"#, r#""step_down":0.001"#); + load(&write_job("start-limit-ok", &text)).expect("1000 remaining passes"); + for text in [text.replace("1999", "1998"), text.replace("0.001", "0.0001"), text.replace("2000", "1000001")] { + let err = load(&write_job("start-limit-bad", &text)).expect_err("bounded passes/depth"); + assert!(err.to_string().contains("too many depth passes") || err.to_string().contains("1000000"), "{err}"); + } + let text = start_depth_text(r#", "start_depth":3,"onion_skin":1,"finish":{"step_down":0.001}"#); + assert!(load(&write_job("start-finish-limit", &text)).is_err()); + } + #[test] fn fnv1a64_known_vectors() { assert_eq!(hash_bytes(b""), "cbf29ce484222325"); diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 091f5e0..ade9d58 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -1298,7 +1298,7 @@ fn depth_with(cut: &Cut<'_>, contour: &Contour, total: f64, step_down: f64) -> R if !matches!(lead_in, Lead::None) || !matches!(lead_out, Lead::None) { return depth_with_leads(cut, contour, total, step_down, lead_in, lead_out); } - let mut plan = apply_onion(DepthPlan::new(0.0, total, step_down, cut.op.entry()), cut.op, total); + let mut plan = operation_depth_plan(cut.op, total, step_down, cut.op.entry()); // Linking rapids to the retract height and the entry feed starts there. A ramp or helix // planned from the stock surface would be posted from that higher Z over a length sized // for the stock step, and the first descent would be steeper than angle_deg. Size the @@ -1310,7 +1310,7 @@ fn depth_with(cut: &Cut<'_>, contour: &Contour, total: f64, step_down: f64) -> R // through it. if matches!(cut.op.entry(), DepthEntry::Ramp { .. } | DepthEntry::Helix { .. }) { let retract = retract_z(cut.job); - if retract > 0.0 { + if retract > plan.stock_z() { plan = plan.entry_start(retract); } } @@ -1697,12 +1697,21 @@ fn roughing_depth(op: &Operation, target: f64) -> f64 { } } +/// Reuse the bounded depth planner below the already-cleared pocket surface. Other +/// operations store start_depth=0 and keep their original plan. `total` stays measured +/// from stock Z 0, including for shortened roughing links and full-depth wall finishing. +fn operation_depth_plan(op: &Operation, total: f64, step_down: f64, entry: DepthEntry) -> DepthPlan { + let start = op.start_depth(); + let stock_z = if start == 0.0 { 0.0 } else { -start }; + apply_onion(DepthPlan::new(stock_z, total - start, step_down, entry), op, total) +} + fn depth_levels(op: &Operation, total: f64, step_down: f64) -> Result, CliError> { - let plan = apply_onion(DepthPlan::new(0.0, total, step_down, DepthEntry::Plunge), op, total); + let plan = operation_depth_plan(op, total, step_down, DepthEntry::Plunge); let depths = plan.depths().map_err(|err| op_err(op, err))?; let mut levels = Vec::with_capacity(depths.len()); for depth in depths { - let z = -depth; + let z = plan.stock_z() - depth; if !z.is_finite() { return Err(op_err(op, "cut z is not finite")); } @@ -1930,8 +1939,9 @@ fn feed_z(motion: &ClMotion) -> Option<(f64, MoveRole)> { #[cfg(test)] mod tests { - use super::run; + use super::{depth_levels, run}; use crate::jobfile::hash_bytes; + use proptest::prelude::*; use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; @@ -2010,4 +2020,41 @@ mod tests { panic!("{err}"); } } + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn pocket_start_depth_levels_follow_the_remaining_interval( + start_ticks in 0u32..10000, + remaining_ticks in 1u32..1000, + step_ticks in 1u32..500, + ) { + let mut raw: serde_json::Value = serde_json::from_str(include_str!("../tests/fixtures/pocket_start.job.json")).expect("job"); + let start = f64::from(start_ticks) / 10.0; + let total = f64::from(start_ticks + remaining_ticks) / 10.0; + let step = f64::from(step_ticks) / 10.0; + raw["operations"][0]["start_depth"] = serde_json::json!(start); + raw["operations"][0]["depth"] = serde_json::json!(total); + raw["operations"][0]["step_down"] = serde_json::json!(step); + let folder = dir("start-property"); + let file = folder.join("job.json"); + fs::write(&file, raw.to_string()).expect("job file"); + let job = crate::jobfile::load(&file).expect("valid job"); + let op = job.operations().first().expect("pocket"); + let levels = depth_levels(op, op.depth(), op.step_down()).map_err(|err| proptest::test_runner::TestCaseError::fail(err.to_string()))?; + let count = remaining_ticks.div_ceil(step_ticks); + prop_assert_eq!(levels.len(), count as usize); + let mut previous = -start; + for (index, z) in levels.iter().enumerate() { + let offset = ((index as u32 + 1) * step_ticks).min(remaining_ticks); + let expected = -f64::from(start_ticks + offset) / 10.0; + prop_assert!((*z - expected).abs() < 1e-7); + prop_assert!(*z < previous); + prop_assert!(previous - *z <= step + 1e-7); + prop_assert!(*z >= -total - 1e-7); + previous = *z; + } + prop_assert!((previous + total).abs() < 1e-7); + } + } } diff --git a/crates/cli/tests/fixtures/pocket_start.job.json b/crates/cli/tests/fixtures/pocket_start.job.json new file mode 100644 index 0000000..9aedcad --- /dev/null +++ b/crates/cli/tests/fixtures/pocket_start.job.json @@ -0,0 +1,37 @@ +{ + "format": "campanion-job/1", + "source": { + "path": "pocket_start.svg", + "hash": "e257091f3fed9a30" + }, + "units": "mm", + "heights": { + "clearance": 5, + "retract": 2 + }, + "tools": [ + { + "id": 1, + "diameter": 6, + "speed": 12000 + } + ], + "operations": [ + { + "name": "continue", + "type": "pocket", + "layers": [ + "POCKET" + ], + "tool_id": 1, + "start_depth": 3, + "depth": 8, + "step_down": 2, + "stepover": 3, + "feeds": { + "cut": 1000, + "plunge": 300 + } + } + ] +} diff --git a/crates/cli/tests/fixtures/pocket_start.svg b/crates/cli/tests/fixtures/pocket_start.svg new file mode 100644 index 0000000..e3b85b7 --- /dev/null +++ b/crates/cli/tests/fixtures/pocket_start.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/cli/tests/golden/pocket_start.linuxcnc.nc b/crates/cli/tests/golden/pocket_start.linuxcnc.nc new file mode 100644 index 0000000..e320056 --- /dev/null +++ b/crates/cli/tests/golden/pocket_start.linuxcnc.nc @@ -0,0 +1,113 @@ +N10 (pocket_start.job) +N20 G21 +N30 G90 +N40 G17 +N50 G40 +N60 G49 +N70 G64 P0.01 +N80 (toolpath continue) +N90 T1 M6 +N100 G0 X8.0000 Y42.0000 Z5.0000 +N110 X8.0000 Y42.0000 Z2.0000 +N120 M3 S12000 +N130 G1 X8.0000 Y42.0000 Z-5.0000 F300.0000 +N140 X8.0000 Y8.0000 Z-5.0000 F1000.0000 +N150 X42.0000 Y8.0000 Z-5.0000 +N160 X42.0000 Y42.0000 Z-5.0000 +N170 X8.0000 Y42.0000 Z-5.0000 +N180 X11.0000 Y39.0000 Z-5.0000 +N190 X11.0000 Y11.0000 Z-5.0000 +N200 X39.0000 Y11.0000 Z-5.0000 +N210 X39.0000 Y39.0000 Z-5.0000 +N220 X11.0000 Y39.0000 Z-5.0000 +N230 X14.0000 Y36.0000 Z-5.0000 +N240 X14.0000 Y14.0000 Z-5.0000 +N250 X36.0000 Y14.0000 Z-5.0000 +N260 X36.0000 Y36.0000 Z-5.0000 +N270 X14.0000 Y36.0000 Z-5.0000 +N280 X17.0000 Y33.0000 Z-5.0000 +N290 X17.0000 Y17.0000 Z-5.0000 +N300 X33.0000 Y17.0000 Z-5.0000 +N310 X33.0000 Y33.0000 Z-5.0000 +N320 X17.0000 Y33.0000 Z-5.0000 +N330 X20.0000 Y30.0000 Z-5.0000 +N340 X20.0000 Y20.0000 Z-5.0000 +N350 X30.0000 Y20.0000 Z-5.0000 +N360 X30.0000 Y30.0000 Z-5.0000 +N370 X20.0000 Y30.0000 Z-5.0000 +N380 X23.0000 Y27.0000 Z-5.0000 +N390 X23.0000 Y23.0000 Z-5.0000 +N400 X27.0000 Y23.0000 Z-5.0000 +N410 X27.0000 Y27.0000 Z-5.0000 +N420 X23.0000 Y27.0000 Z-5.0000 +N430 G0 X23.0000 Y27.0000 Z5.0000 +N440 (toolpath continue) +N450 X8.0000 Y42.0000 Z5.0000 +N460 X8.0000 Y42.0000 Z2.0000 +N470 G1 X8.0000 Y42.0000 Z-7.0000 F300.0000 +N480 X8.0000 Y8.0000 Z-7.0000 F1000.0000 +N490 X42.0000 Y8.0000 Z-7.0000 +N500 X42.0000 Y42.0000 Z-7.0000 +N510 X8.0000 Y42.0000 Z-7.0000 +N520 X11.0000 Y39.0000 Z-7.0000 +N530 X11.0000 Y11.0000 Z-7.0000 +N540 X39.0000 Y11.0000 Z-7.0000 +N550 X39.0000 Y39.0000 Z-7.0000 +N560 X11.0000 Y39.0000 Z-7.0000 +N570 X14.0000 Y36.0000 Z-7.0000 +N580 X14.0000 Y14.0000 Z-7.0000 +N590 X36.0000 Y14.0000 Z-7.0000 +N600 X36.0000 Y36.0000 Z-7.0000 +N610 X14.0000 Y36.0000 Z-7.0000 +N620 X17.0000 Y33.0000 Z-7.0000 +N630 X17.0000 Y17.0000 Z-7.0000 +N640 X33.0000 Y17.0000 Z-7.0000 +N650 X33.0000 Y33.0000 Z-7.0000 +N660 X17.0000 Y33.0000 Z-7.0000 +N670 X20.0000 Y30.0000 Z-7.0000 +N680 X20.0000 Y20.0000 Z-7.0000 +N690 X30.0000 Y20.0000 Z-7.0000 +N700 X30.0000 Y30.0000 Z-7.0000 +N710 X20.0000 Y30.0000 Z-7.0000 +N720 X23.0000 Y27.0000 Z-7.0000 +N730 X23.0000 Y23.0000 Z-7.0000 +N740 X27.0000 Y23.0000 Z-7.0000 +N750 X27.0000 Y27.0000 Z-7.0000 +N760 X23.0000 Y27.0000 Z-7.0000 +N770 G0 X23.0000 Y27.0000 Z5.0000 +N780 (toolpath continue) +N790 X8.0000 Y42.0000 Z5.0000 +N800 X8.0000 Y42.0000 Z2.0000 +N810 G1 X8.0000 Y42.0000 Z-8.0000 F300.0000 +N820 X8.0000 Y8.0000 Z-8.0000 F1000.0000 +N830 X42.0000 Y8.0000 Z-8.0000 +N840 X42.0000 Y42.0000 Z-8.0000 +N850 X8.0000 Y42.0000 Z-8.0000 +N860 X11.0000 Y39.0000 Z-8.0000 +N870 X11.0000 Y11.0000 Z-8.0000 +N880 X39.0000 Y11.0000 Z-8.0000 +N890 X39.0000 Y39.0000 Z-8.0000 +N900 X11.0000 Y39.0000 Z-8.0000 +N910 X14.0000 Y36.0000 Z-8.0000 +N920 X14.0000 Y14.0000 Z-8.0000 +N930 X36.0000 Y14.0000 Z-8.0000 +N940 X36.0000 Y36.0000 Z-8.0000 +N950 X14.0000 Y36.0000 Z-8.0000 +N960 X17.0000 Y33.0000 Z-8.0000 +N970 X17.0000 Y17.0000 Z-8.0000 +N980 X33.0000 Y17.0000 Z-8.0000 +N990 X33.0000 Y33.0000 Z-8.0000 +N1000 X17.0000 Y33.0000 Z-8.0000 +N1010 X20.0000 Y30.0000 Z-8.0000 +N1020 X20.0000 Y20.0000 Z-8.0000 +N1030 X30.0000 Y20.0000 Z-8.0000 +N1040 X30.0000 Y30.0000 Z-8.0000 +N1050 X20.0000 Y30.0000 Z-8.0000 +N1060 X23.0000 Y27.0000 Z-8.0000 +N1070 X23.0000 Y23.0000 Z-8.0000 +N1080 X27.0000 Y23.0000 Z-8.0000 +N1090 X27.0000 Y27.0000 Z-8.0000 +N1100 X23.0000 Y27.0000 Z-8.0000 +N1110 G0 X23.0000 Y27.0000 Z5.0000 +N1120 M5 +N1130 M2 diff --git a/crates/cli/tests/pocket_start_depth_e2e.rs b/crates/cli/tests/pocket_start_depth_e2e.rs new file mode 100644 index 0000000..e5c9a37 --- /dev/null +++ b/crates/cli/tests/pocket_start_depth_e2e.rs @@ -0,0 +1,221 @@ +//! Pocket continuation below an explicitly declared already-cleared depth. +#![allow(clippy::expect_used)] + +use std::path::PathBuf; +use std::process::Command; + +use campanion_ir::{ClBlock, ClMotion, Document, MoveRole}; +use campanion_post::{audit, builtin, compare, read_back}; +use serde_json::{Value, json}; + +const EPS: f64 = 1e-6; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/pocket_start.job.json")).expect("synthetic job") +} +fn folder(name: &str) -> PathBuf { + std::env::var_os("CARGO_TARGET_TMPDIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join(format!("campanion-pocket-start-{name}")) +} +fn run(name: &str, job: &Value) -> (String, Document) { + let dir = folder(name); + std::fs::create_dir_all(&dir).expect("test dir"); + std::fs::write(dir.join("pocket_start.svg"), include_str!("fixtures/pocket_start.svg")).expect("drawing"); + std::fs::write(dir.join("pocket_start.job.json"), job.to_string()).expect("job"); + let output = Command::new(env!("CARGO_BIN_EXE_campanion")) + .current_dir(&dir) + .args(["run", "pocket_start.job.json", "--post", "linuxcnc", "-o", "out.nc", "--ir", "out.ir.json"]) + .output() + .expect("run CLI"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!String::from_utf8_lossy(&output.stderr).contains("changed")); + let text = std::fs::read_to_string(dir.join("out.nc")).expect("NC"); + let doc = Document::from_json(&std::fs::read_to_string(dir.join("out.ir.json")).expect("IR")).expect("document"); + let post = builtin("linuxcnc").expect("post"); + compare(doc.program(), &read_back(&text, &post).expect("read-back"), 0.001, &post).expect("motion compare"); + audit(&text, &post).expect("modal audit"); + let verify = Command::new(env!("CARGO_BIN_EXE_campanion")).current_dir(&dir).args(["verify", "out.ir.json"]).output().expect("verify CLI"); + assert!(verify.status.success(), "{}{}", String::from_utf8_lossy(&verify.stdout), String::from_utf8_lossy(&verify.stderr)); + (text, doc) +} +fn cut_levels(doc: &Document) -> Vec> { + doc.program() + .toolpaths() + .iter() + .map(|path| { + let mut levels: Vec = Vec::new(); + for block in path.blocks() { + if let ClBlock::Motion( + ClMotion::Linear { to, role: MoveRole::Cut, .. } + | ClMotion::Arc { to, role: MoveRole::Cut, .. } + | ClMotion::Helix { to, role: MoveRole::Cut, .. }, + ) = block + { + let z = to.tip().z(); + if levels.last().is_none_or(|last| (*last - z).abs() > EPS) { + levels.push(z); + } + } + } + levels + }) + .collect() +} +fn check_rapid_heights(doc: &Document, retract: f64) { + let mut at = None; + for path in doc.program().toolpaths() { + for block in path.blocks() { + if let ClBlock::Motion(motion) = block { + let (to, rapid) = match motion { + ClMotion::Rapid { to, .. } => (to.tip(), true), + ClMotion::Linear { to, .. } | ClMotion::Arc { to, .. } | ClMotion::Helix { to, .. } | ClMotion::Thread { to, .. } => { + (to.tip(), false) + } + }; + if rapid { + assert!(to.z() >= retract - EPS, "rapid target below retract: {to:?}"); + if let Some(from) = at { + let from: campanion_ir::Vec3 = from; + if (to.x() - from.x()).hypot(to.y() - from.y()) > EPS { + assert!(from.z() >= retract - EPS); + } + } + } + at = Some(to); + } + } + } +} + +#[test] +fn pocket_start_depth_posts_known_levels_and_matches_golden() { + let (text, doc) = run("golden", &fixture()); + assert_eq!(text, include_str!("golden/pocket_start.linuxcnc.nc")); + assert_eq!(cut_levels(&doc), vec![vec![-5.0], vec![-7.0], vec![-8.0]]); + assert!(text.contains("Z-5.0000 F300.0000")); + check_rapid_heights(&doc, 2.0); +} + +#[test] +fn omitted_start_depth_and_explicit_zero_produce_identical_programs() { + let mut job = fixture(); + job["operations"][0].as_object_mut().expect("op").remove("start_depth"); + let (omitted, _) = run("omitted", &job); + job["operations"][0]["start_depth"] = json!(0); + let (zero, doc) = run("zero", &job); + assert_eq!(omitted, zero); + assert_eq!(cut_levels(&doc), vec![vec![-2.0], vec![-4.0], vec![-6.0], vec![-8.0]]); +} + +#[test] +fn pocket_start_depth_applies_to_roughing_floor_and_wall_finish() { + let mut job = fixture(); + job["operations"][0]["stock_to_leave"] = json!({"floor":1,"wall":0.5}); + job["operations"][0]["finish"] = json!({"step_down":1}); + job["operations"][0]["onion_skin"] = json!(1); + let (_, doc) = run("finish", &job); + assert_eq!(cut_levels(&doc), vec![vec![-5.0], vec![-7.0], vec![-8.0], vec![-4.0, -5.0, -6.0, -7.0, -8.0]]); + check_rapid_heights(&doc, 2.0); + job["operations"][0].as_object_mut().expect("op").remove("finish"); + let (_, rough) = run("rough-only", &job); + assert_eq!(cut_levels(&rough), vec![vec![-5.0], vec![-7.0]]); +} + +#[test] +fn pocket_start_depth_with_onion_skin_keeps_a_separate_final_pass() { + let mut job = fixture(); + job["operations"][0]["onion_skin"] = json!(0.5); + let (_, doc) = run("onion", &job); + assert_eq!(cut_levels(&doc), vec![vec![-5.0], vec![-7.0], vec![-7.5], vec![-8.0]]); +} + +#[test] +fn inch_start_depth_and_step_down_are_converted_once() { + let mut job = fixture(); + job["units"] = json!("inch"); + job["heights"] = json!({"clearance":0.2,"retract":0.1}); + job["tools"][0]["diameter"] = json!(0.25); + job["operations"][0]["start_depth"] = json!(0.125); + job["operations"][0]["depth"] = json!(0.375); + job["operations"][0]["step_down"] = json!(0.125); + job["operations"][0]["stepover"] = json!(0.125); + let (_, doc) = run("inch", &job); + let levels = cut_levels(&doc); + assert_eq!(levels.len(), 2); + assert!((levels[0][0] + 6.35).abs() < EPS); + assert!((levels[1][0] + 9.525).abs() < EPS); + check_rapid_heights(&doc, 2.54); +} + +fn check_entry_slopes(doc: &Document, angle_deg: f64) { + let post = builtin("linuxcnc").expect("post"); + // Read-back expands helices to arcs, with their analytic sweep/radius available. + let nc = campanion_post::post_program(doc.program(), &post, &campanion_post::PostOptions { arc_tolerance: 0.01 }).expect("post"); + let mut descents = 0; + let mut from = [0.0; 3]; + for motion in audit(&nc, &post).expect("audit").motions { + if motion.mode != campanion_post::MotionMode::Rapid && motion.end[2] < from[2] - 0.0001 { + let horizontal = if let Some(arc) = motion.arc { + let radius = (from[0] - arc.center[0]).hypot(from[1] - arc.center[1]); + let first = (from[1] - arc.center[1]).atan2(from[0] - arc.center[0]); + let last = (motion.end[1] - arc.center[1]).atan2(motion.end[0] - arc.center[0]); + let sweep = if arc.sense == campanion_ir::ArcSense::Ccw { last - first } else { first - last }.rem_euclid(std::f64::consts::TAU); + radius * if sweep < 1e-8 { std::f64::consts::TAU } else { sweep } + } else { + (motion.end[0] - from[0]).hypot(motion.end[1] - from[1]) + }; + assert!(horizontal > 0.0, "unexpected plunge"); + let angle = (from[2] - motion.end[2]).atan2(horizontal).to_degrees(); + assert!(angle <= angle_deg + 0.005, "descent angle {angle} exceeds {angle_deg}"); + descents += 1; + } + from = motion.end; + } + assert!(descents > 0); +} + +#[test] +fn ramp_and_helix_cover_the_full_drop_from_retract_for_area_and_wall_finish() { + for (name, entry) in [("ramp", json!({"type":"ramp","angle_deg":10})), ("helix", json!({"type":"helix","radius":3,"angle_deg":10}))] { + for retract in [0.0, 2.0] { + let mut job = fixture(); + job["heights"]["retract"] = json!(retract); + job["operations"][0]["entry"] = entry.clone(); + job["operations"][0]["stock_to_leave"] = json!({"wall":0.5}); + job["operations"][0]["finish"] = json!({"step_down":2}); + let (_, doc) = run(&format!("{name}-{retract}"), &job); + let actual = cut_levels(&doc); + let expected = [vec![-5.0], vec![-7.0], vec![-8.0], vec![-5.0, -7.0, -8.0]]; + assert_eq!(actual.len(), expected.len()); + for (got, want) in actual.iter().zip(expected) { + assert_eq!(got.len(), want.len()); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < EPS), "{got:?}"); + } + check_rapid_heights(&doc, retract); + check_entry_slopes(&doc, 10.0); + } + } +} + +#[test] +fn invalid_start_depth_fails_before_writing_outputs() { + let dir = folder("invalid-no-output"); + std::fs::create_dir_all(&dir).expect("dir"); + let mut job = fixture(); + job["operations"][0]["start_depth"] = json!(8); + std::fs::write(dir.join("pocket_start.job.json"), job.to_string()).expect("job"); + let nc = dir.join("invalid.nc"); + let ir = dir.join("invalid.ir.json"); + for file in [&nc, &ir] { + if file.exists() { + std::fs::remove_file(file).expect("old test output"); + } + } + let output = Command::new(env!("CARGO_BIN_EXE_campanion")) + .current_dir(&dir) + .args(["run", "pocket_start.job.json", "-o", "invalid.nc", "--ir", "invalid.ir.json"]) + .output() + .expect("CLI"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("start_depth must be less than depth")); + assert!(!nc.exists() && !ir.exists()); +} diff --git a/docs/job-file.md b/docs/job-file.md index 166448c..df74fa4 100644 --- a/docs/job-file.md +++ b/docs/job-file.md @@ -136,6 +136,7 @@ unique within that operation. The same layer may appear on two operations. | `tool_id` | yes | `id` of a tool in this file. | | `speed` | no | Spindle speed in rpm: positive, finite, and at most 100000. Not scaled by `units`. When set, it overrides `tools[].speed` for this operation. Omitted, the tool's speed is used. See [Spindle speed](#spindle-speed) and [Tool changes](#tool-changes). | | `depth` | yes, except chamfer, surface, rough, waterline and rotary finish | Positive distance downward, in `units`. Not an absolute Z. On a profile, pocket, drill, engrave or face this is below the stock surface. On a wrap it is below the cylinder surface. A chamfer rejects `depth`; the tip depth follows from the geometry. A surface, a rough, a waterline or a rotary finish rejects `depth`. | +| `start_depth` | pocket only | Already-cleared distance below the stock surface, in `units`, default 0. Must be at least 0 and less than `depth`. Depth passes begin one `step_down` below it, ending at the original `depth`. Applies to pocket area and wall-finishing passes. Rejected on every other operation. See [Pocket start depth](#pocket-start-depth). | | `step_down` | yes, except chamfer, surface and rotary finish | Positive maximum thickness of one pass, in `units`. The last pass of a 2D operation, including a wrap, stops at `depth` and is not deeper. A chamfer rejects `step_down`. A surface or a rotary finish rejects `step_down`. A rough or a waterline requires it: the spacing between Z levels. On a drill it is the peck depth: when it is less than `depth` the drill pecks, and otherwise the drill feeds to depth in one move. A drill with `onion_skin` pecks even when `step_down` is at least `depth`, so the skin is its own last peck. | | `onion_skin` | no | Positive length in `units`, strictly less than `depth`. On a profile, pocket, face, engrave, drill or wrap, earlier depth passes stop this far short of `depth`, and the last pass cuts that skin with the same entry as the other passes. Omitted, the depth plan is unchanged. Rejected on a chamfer, a surface, a rough, a waterline and a rotary finish. Not proven on a machine. | | `dwell` | drill only | Seconds to pause at the bottom of a drill, from 0 to 3600 inclusive. Not scaled by `units`. Rejected on every other kind. Omitted, the drill does not pause. | @@ -177,7 +178,9 @@ An inside profile whose offset disappears is refused. On a kerf tool the error i `depth` and `step_down` must be a legal `campanion-path` depth plan: positive, at most 1e6 mm, and at most 1024 passes. Five millimetres at a two millimetre step is three passes (2, 4, 5). -When `finish.step_down` is set, it must satisfy the same rule against `depth`. +When `finish.step_down` is set, it must satisfy the same rule against `depth`. For a pocket +with `start_depth`, both pass limits apply to the remaining interval `depth - start_depth`; +the absolute final depth and step-down are still capped at 1e6 mm. `onion_skin`, when set, is a positive length strictly less than `depth`. Roughing passes are the plan of `depth - onion_skin`, and one further pass cuts the skin at `depth` with the same entry. @@ -189,6 +192,36 @@ that every earlier cut stays at least `onion_skin` above the floor and that the reaches it. A roughing link that leaves floor stock and has no finish pass stays out of the skin and is not required to reach the sheet floor. This output has not been proven on a machine. +### Pocket start depth + +A `pocket` operation may set `start_depth` to continue cutting an area already cleared by a +previous operation. It is a non-negative distance below the original stock surface, in job +`units`; omitted, it is 0. `depth` remains the final distance below that same surface. +For example, `"start_depth": 3, "depth": 8, "step_down": 2` cuts at Z -5, -7 and -8 mm +when the stock surface is Z0. It does not repeat passes at Z -2 or -4. In an indexed or +tilted pocket, the depths are measured in the operation's local cutting frame. + +The caller must ensure the selected pocket area has already been cleared to `start_depth`. +This field does not inspect previous operations or infer remaining stock. Clearance and +retract heights are unchanged: the tool still rapids only at those safe heights, and feeds +the plunge, ramp or helix to the first cut. A ramp or helix still covers the full drop from +the retract height; an entry that does not fit is rejected. No rapid down to `start_depth` +is added. + +Floor stock and an onion skin remain measured upward from the original final floor. +`start_depth` must be strictly less than both `depth - stock_to_leave.floor` and +`depth - onion_skin` when those allowances are set. A start at or below either roughing +stop is rejected, including when `finish` is set; finishing-only continuation is outside +this scope. Earlier passes keep the skin, and the last pass reaches the existing target. +An optional floor finish still cuts at `-depth`. Wall-finishing passes begin below +`start_depth` using `finish.step_down`, so they also skip the already-cleared interval. +Wall allowance and finishing geometry are unchanged. + +`start_depth` is accepted only on `pocket`, including when its value is 0. Other operation +kinds retain their existing depth behavior. See the synthetic +[pocket continuation fixture](../crates/cli/tests/fixtures/pocket_start.job.json). +Software read-back and verification do not establish physical machining acceptance. + ### Tool changes `campanion run` writes a tool-change event (`Event::ToolChange`) at the start of the first