Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- A diameter tool may set `cutter_sense` (`upcut`, `downcut`, or `compression`) and, with compression, `upcut_length` (millimetres at the tip). Omitted, compensation stays the flat diameter offset. Sense is stored on the job and the IR for later checks; it does not change the 2D offset. Posted G-code is unchanged when the field is omitted. This output has not been proven on a machine.
- A profile, pocket, face, engrave, drill, wrap or live mill may set `onion_skin`, a positive length strictly less than `depth`. Earlier depth passes stop that far short of the floor. The last pass cuts the skin with the same entry as the other passes. Omitting the field leaves the depth plan unchanged. `campanion run` checks that no earlier pass enters the skin and that the last pass reaches the floor. This output has not been proven on a machine.

## 0.9.3 — 2026-11-03

Expand Down
215 changes: 208 additions & 7 deletions crates/cli/src/jobfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ use campanion_ir::{
CutterSense as IrCutterSense, Feeds, FiveAxisSetup, Kinematics, ProcessParams, RotaryAxisKind, RotaryDirection, RotaryLimits, RotarySetup,
SolutionPolicy, Tool as IrTool, ToolPose, Vec3,
};
use campanion_path::{CutterSense, DepthEntry, ProfileSide, TabLayout, TabSpec, Tool as PathTool};
use campanion_path::{CutterSense, DepthEntry, DepthPlan, ProfileSide, TabLayout, TabSpec, Tool as PathTool};
use serde_json::{Map, Number, Value};

use crate::error::{CliError, MAX_JSON_BYTES};
Expand Down Expand Up @@ -695,6 +695,8 @@ impl Finish {
/// [`Self::dwell`] is the pause at the bottom of a drill, in seconds. Other kinds store `None`.
/// On a drill, [`Self::step_down`] is the peck depth: when it is less than [`Self::depth`] the
/// 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::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
Expand All @@ -711,6 +713,8 @@ pub struct Operation {
tool_id: u32,
depth: f64,
step_down: f64,
/// Thickness left until the last depth pass, in millimetres. `None` when the file omits `onion_skin`.
onion_skin: Option<f64>,
/// Pause at the bottom of a drill, in seconds. `None` except on a drill that sets `dwell`.
dwell: Option<f64>,
stepover: Option<f64>,
Expand Down Expand Up @@ -874,6 +878,15 @@ impl Operation {
self.step_down
}

/// Thickness left until the last depth pass, in millimetres.
///
/// `None` when the file omits `onion_skin`. Earlier passes then follow the ordinary depth
/// plan. When set, those passes stop this far short of [`Self::depth`] and only the last
/// pass reaches that depth.
pub fn onion_skin(&self) -> Option<f64> {
self.onion_skin
}

/// Pause at the bottom of a drilled hole, in seconds.
///
/// `None` when the file omits `dwell`, and on every kind other than a drill. Not scaled by
Expand Down Expand Up @@ -1883,6 +1896,7 @@ fn parse_operation(value: &Value, scale: f64, tools: &[JobTool]) -> Result<Opera
"depth",
"dwell",
"step_down",
"onion_skin",
"stepover",
"scallop_height",
"sample_step",
Expand Down Expand Up @@ -1994,13 +2008,15 @@ fn parse_operation(value: &Value, scale: f64, tools: &[JobTool]) -> Result<Opera
let feeds = parse_operation_feeds(require(map, "feeds").map_err(from_cli)?, scale)?;
let params = parse_timing(map, scale)?;
let tabs = if matches!(kind, OperationKind::Profile { .. }) { parse_profile_tabs(optional(map, "tabs"), depth, scale)? } else { None };
let onion_skin = parse_onion_skin(map, kind, depth, step_down, finish.as_ref(), scale)?;
Ok(Operation {
name,
kind,
layers,
tool_id,
depth,
step_down,
onion_skin,
dwell,
stepover,
scallop_height: None,
Expand Down Expand Up @@ -2036,6 +2052,7 @@ fn reject_surface_foreign_keys(map: &Map<String, Value>) -> Result<(), JobFileEr
"layers",
"depth",
"step_down",
"onion_skin",
"top_z",
"grid",
"min_slope_deg",
Expand Down Expand Up @@ -2127,6 +2144,7 @@ fn finish_surface(name: String, map: &Map<String, Value>, scale: f64, tools: &[J
tool_id,
depth: 0.0,
step_down: 0.0,
onion_skin: None,
dwell: None,
stepover,
scallop_height,
Expand Down Expand Up @@ -2273,6 +2291,7 @@ fn reject_rough_foreign_keys(map: &Map<String, Value>) -> Result<(), JobFileErro
for key in [
"layers",
"depth",
"onion_skin",
"scallop_height",
"sample_step",
"angle_deg",
Expand Down Expand Up @@ -2363,6 +2382,7 @@ fn finish_rough(name: String, map: &Map<String, Value>, scale: f64, tools: &[Job
tool_id,
depth: 0.0,
step_down,
onion_skin: None,
dwell: None,
stepover: Some(stepover),
scallop_height: None,
Expand Down Expand Up @@ -2397,6 +2417,7 @@ fn reject_waterline_foreign_keys(map: &Map<String, Value>) -> Result<(), JobFile
for key in [
"layers",
"depth",
"onion_skin",
"stepover",
"scallop_height",
"sample_step",
Expand Down Expand Up @@ -2486,6 +2507,7 @@ fn finish_waterline(name: String, map: &Map<String, Value>, scale: f64, tools: &
tool_id,
depth: 0.0,
step_down,
onion_skin: None,
dwell: None,
stepover: None,
scallop_height: None,
Expand Down Expand Up @@ -2559,6 +2581,7 @@ fn finish_wrap(name: String, map: &Map<String, Value>, scale: f64, tools: &[JobT
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 Some(diameter_value) = optional(map, "diameter") else {
return Err(JobFileError::invalid("wrap needs a diameter"));
};
Expand All @@ -2580,6 +2603,7 @@ fn finish_wrap(name: String, map: &Map<String, Value>, scale: f64, tools: &[JobT
tool_id,
depth,
step_down,
onion_skin,
dwell: None,
stepover: None,
scallop_height: None,
Expand Down Expand Up @@ -2615,6 +2639,7 @@ fn reject_rotary_foreign_keys(map: &Map<String, Value>) -> Result<(), JobFileErr
"layers",
"depth",
"step_down",
"onion_skin",
"scallop_height",
"angle_deg",
"margin",
Expand Down Expand Up @@ -2700,6 +2725,7 @@ fn finish_rotary(name: String, map: &Map<String, Value>, scale: f64, tools: &[Jo
tool_id,
depth: 0.0,
step_down: 0.0,
onion_skin: None,
dwell: None,
stepover,
scallop_height: None,
Expand Down Expand Up @@ -3331,18 +3357,64 @@ fn finite_height(z: f64, what: &str) -> Result<f64, JobFileError> {
}

fn check_depth(depth: f64, step_down: f64) -> Result<(), JobFileError> {
let depths = campanion_path::pass_depths(depth, step_down).map_err(|err| {
let message = err.to_string();
let detail = message.strip_prefix("invalid input: ").unwrap_or(message.as_str());
let detail = detail.replacen("total depth", "depth", 1);
JobFileError::invalid(detail.replacen("step-down", "step_down", 1))
})?;
let depths = campanion_path::pass_depths(depth, step_down).map_err(depth_plan_error)?;
if depths.is_empty() {
return Err(JobFileError::invalid("depth plan is empty"));
}
Ok(())
}

/// Optional skin on a profile, pocket, face, engrave, drill or wrap.
///
/// The length is positive and strictly less than `depth`. Roughing passes use
/// `depth - onion_skin`, then one pass at `depth`. An omitted field stays `None`.
fn parse_onion_skin(
map: &Map<String, Value>,
kind: OperationKind,
depth: f64,
step_down: f64,
finish: Option<&Finish>,
scale: f64,
) -> Result<Option<f64>, JobFileError> {
let Some(value) = optional(map, "onion_skin") else {
return Ok(None);
};
if !matches!(
kind,
OperationKind::Profile { .. }
| OperationKind::Pocket
| OperationKind::Face
| OperationKind::Engrave
| OperationKind::Drill
| OperationKind::Wrap
) {
return Err(JobFileError::invalid("onion_skin is only valid on a profile, a pocket, a face, an engrave, a drill or a wrap"));
}
let skin = positive_length(value, scale, "onion_skin")?;
if skin >= depth {
return Err(JobFileError::invalid("onion_skin must be less than depth"));
}
check_onion_plan(depth, step_down, skin)?;
if let Some(finish) = finish {
check_onion_plan(depth, finish.step_down(), skin)?;
}
Ok(Some(skin))
}

fn check_onion_plan(depth: f64, step_down: f64, skin: f64) -> Result<(), JobFileError> {
let plan = DepthPlan::new(0.0, depth, step_down, DepthEntry::Plunge).with_onion_skin(skin);
plan.depths().map_err(depth_plan_error)?;
Ok(())
}

fn depth_plan_error(err: impl std::fmt::Display) -> JobFileError {
let message = err.to_string();
let detail = message.strip_prefix("invalid input: ").unwrap_or(message.as_str());
let detail = detail.replacen("total depth", "depth", 1);
let detail = detail.replacen("step-down", "step_down", 1);
JobFileError::invalid(detail.replacen("onion skin", "onion_skin", 1))
}

fn scale_key(map: &mut Map<String, Value>, key: &str, scale: f64) -> Result<(), JobFileError> {
let Some(value) = optional(map, key) else {
return Ok(());
Expand Down Expand Up @@ -6531,4 +6603,133 @@ mod tests {
assert!(err.to_string().contains(needle), "{needle}: {err}");
}
}

#[test]
fn onion_skin_parses_in_mm_and_inches_and_rejects_a_bad_value() {
let tools = r#"[{"id":1,"diameter":6}]"#;
let mm = load(&write_job(
"onion-mm",
&op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":6,"step_down":2,"onion_skin":1,"feeds":{"cut":1}}"#,
),
))
.expect("mm");
near(mm.operations()[0].onion_skin().expect("skin"), 1.0);

let inch = load(&write_job(
"onion-inch",
&op_job(
"inch",
r#"[{"id":1,"diameter":0.25}]"#,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":1,"step_down":0.5,"onion_skin":0.1,"feeds":{"cut":1}}"#,
),
))
.expect("inch");
near(inch.operations()[0].onion_skin().expect("skin"), 2.54);
near(inch.operations()[0].depth(), 25.4);

let plain = load(&write_job(
"onion-omit",
&op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":6,"step_down":2,"feeds":{"cut":1}}"#,
),
))
.expect("omit");
assert!(plain.operations()[0].onion_skin().is_none());

for kind in ["pocket", "face", "engrave", "drill"] {
let op =
format!(r#"{{"name":"a","type":"{kind}","layers":["L"],"tool_id":1,"depth":5,"step_down":2,"onion_skin":1,"feeds":{{"cut":1}}}}"#);
let job = load(&write_job(&format!("onion-{kind}"), &op_job("mm", tools, &op))).unwrap_or_else(|err| panic!("{kind}: {err}"));
near(job.operations()[0].onion_skin().expect("skin"), 1.0);
}

let letters =
r#"{"name":"letters","type":"wrap","layers":["L"],"tool_id":1,"depth":6,"step_down":2,"onion_skin":1,"diameter":40,"feeds":{"cut":1}}"#;
let wrap = load(&write_job("onion-wrap", &rotary_job("mm", tools, &format!("[{letters}]")))).expect("wrap");
near(wrap.operations()[0].onion_skin().expect("skin"), 1.0);

let cases = [
(
op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":6,"step_down":2,"onion_skin":0,"feeds":{"cut":1}}"#,
),
"onion_skin must be greater than 0",
),
(
op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":6,"step_down":2,"onion_skin":-1,"feeds":{"cut":1}}"#,
),
"onion_skin must be greater than 0",
),
(
op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":6,"step_down":2,"onion_skin":6,"feeds":{"cut":1}}"#,
),
"onion_skin must be less than depth",
),
(
op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":6,"step_down":2,"onion_skin":6.1,"feeds":{"cut":1}}"#,
),
"onion_skin must be less than depth",
),
(
op_job(
"mm",
tools,
r#"{"name":"a","type":"profile","layers":["L"],"side":"outside","tool_id":1,"depth":1024,"step_down":1,"onion_skin":0.5,"feeds":{"cut":1}}"#,
),
"too many depth passes",
),
(
op_job(
"mm",
tools,
r#"{"name":"a","type":"chamfer","layers":["L"],"side":"outside","tool_id":1,"width":0.5,"v_angle_deg":90,"onion_skin":0.1,"feeds":{"cut":1}}"#,
),
"onion_skin is only valid on a profile, a pocket, a face, an engrave, a drill or a wrap",
),
(
envelope("dome.stl", r#"[{"id":1,"diameter":6}]"#, r#"[{"name":"a","type":"surface","tool_id":1,"onion_skin":1,"feeds":{"cut":1}}]"#),
"onion_skin is not valid on a surface",
),
(
envelope("dome.stl", r#"[{"id":1,"diameter":6}]"#, r#"[{"name":"a","type":"rough","tool_id":1,"onion_skin":1,"feeds":{"cut":1}}]"#),
"onion_skin is not valid on a rough",
),
(
envelope(
"dome.stl",
r#"[{"id":1,"diameter":6}]"#,
r#"[{"name":"a","type":"waterline","tool_id":1,"onion_skin":1,"feeds":{"cut":1}}]"#,
),
"onion_skin is not valid on a waterline",
),
(
envelope(
"dome.stl",
r#"[{"id":1,"diameter":6}]"#,
r#"[{"name":"a","type":"rotary_finish","tool_id":1,"onion_skin":1,"feeds":{"cut":1}}]"#,
),
"onion_skin is not valid on a rotary finish",
),
];
for (index, (text, needle)) in cases.iter().enumerate() {
let err = load(&write_job(&format!("onion-bad-{index}"), text)).expect_err(needle);
assert!(err.to_string().contains(needle), "{needle}: {err}");
}
}
}
Loading
Loading