diff --git a/CHANGELOG.md b/CHANGELOG.md index d938114..b167ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/cli/src/jobfile.rs b/crates/cli/src/jobfile.rs index a1c9ab0..7afb064 100644 --- a/crates/cli/src/jobfile.rs +++ b/crates/cli/src/jobfile.rs @@ -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}; @@ -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 @@ -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, /// Pause at the bottom of a drill, in seconds. `None` except on a drill that sets `dwell`. dwell: Option, stepover: Option, @@ -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 { + 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 @@ -1883,6 +1896,7 @@ fn parse_operation(value: &Value, scale: f64, tools: &[JobTool]) -> Result Result Result) -> Result<(), JobFileEr "layers", "depth", "step_down", + "onion_skin", "top_z", "grid", "min_slope_deg", @@ -2127,6 +2144,7 @@ fn finish_surface(name: String, map: &Map, scale: f64, tools: &[J tool_id, depth: 0.0, step_down: 0.0, + onion_skin: None, dwell: None, stepover, scallop_height, @@ -2273,6 +2291,7 @@ fn reject_rough_foreign_keys(map: &Map) -> Result<(), JobFileErro for key in [ "layers", "depth", + "onion_skin", "scallop_height", "sample_step", "angle_deg", @@ -2363,6 +2382,7 @@ fn finish_rough(name: String, map: &Map, scale: f64, tools: &[Job tool_id, depth: 0.0, step_down, + onion_skin: None, dwell: None, stepover: Some(stepover), scallop_height: None, @@ -2397,6 +2417,7 @@ fn reject_waterline_foreign_keys(map: &Map) -> Result<(), JobFile for key in [ "layers", "depth", + "onion_skin", "stepover", "scallop_height", "sample_step", @@ -2486,6 +2507,7 @@ fn finish_waterline(name: String, map: &Map, scale: f64, tools: & tool_id, depth: 0.0, step_down, + onion_skin: None, dwell: None, stepover: None, scallop_height: None, @@ -2559,6 +2581,7 @@ fn finish_wrap(name: String, map: &Map, 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")); }; @@ -2580,6 +2603,7 @@ fn finish_wrap(name: String, map: &Map, scale: f64, tools: &[JobT tool_id, depth, step_down, + onion_skin, dwell: None, stepover: None, scallop_height: None, @@ -2615,6 +2639,7 @@ fn reject_rotary_foreign_keys(map: &Map) -> Result<(), JobFileErr "layers", "depth", "step_down", + "onion_skin", "scallop_height", "angle_deg", "margin", @@ -2700,6 +2725,7 @@ fn finish_rotary(name: String, map: &Map, scale: f64, tools: &[Jo tool_id, depth: 0.0, step_down: 0.0, + onion_skin: None, dwell: None, stepover, scallop_height: None, @@ -3331,18 +3357,64 @@ fn finite_height(z: f64, what: &str) -> Result { } 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, + kind: OperationKind, + depth: f64, + step_down: f64, + finish: Option<&Finish>, + scale: f64, +) -> Result, 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, key: &str, scale: f64) -> Result<(), JobFileError> { let Some(value) = optional(map, key) else { return Ok(()); @@ -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}"); + } + } } diff --git a/crates/cli/src/lathe.rs b/crates/cli/src/lathe.rs index 5df0f06..f323ec7 100644 --- a/crates/cli/src/lathe.rs +++ b/crates/cli/src/lathe.rs @@ -409,6 +409,7 @@ enum Operation { strategy: MillStrategy, depth: f64, step_down: f64, + onion_skin: Option, z_start: f64, diameter: f64, y_axis: bool, @@ -2315,8 +2316,22 @@ fn parse_live_mill(map: &Map, names: &mut BTreeSet, has_s if face == LiveFace::Front && !y_axis && map.contains_key("c") { return Err(CliError::invalid("c is only valid when y_axis is true")); } - let mut allowed = - vec!["name", "type", "tool_id", "spindle", "channel", "face", "drawing", "layer", "strategy", "depth", "step_down", "rpm", "feed"]; + let mut allowed = vec![ + "name", + "type", + "tool_id", + "spindle", + "channel", + "face", + "drawing", + "layer", + "strategy", + "depth", + "step_down", + "onion_skin", + "rpm", + "feed", + ]; match face { LiveFace::Front => { allowed.push("z_start"); @@ -2344,6 +2359,17 @@ fn parse_live_mill(map: &Map, names: &mut BTreeSet, has_s Some(value) => positive(value, "step_down")?, None => depth, }; + let onion_skin = match optional(map, "onion_skin") { + Some(value) => { + let skin = positive(value, "onion_skin")?; + if skin >= depth { + return Err(CliError::invalid("onion_skin must be less than depth")); + } + crate::live_mill::mill_depths(&name, depth, step_down, Some(skin))?; + Some(skin) + } + None => None, + }; let (z_start, diameter) = match face { LiveFace::Front => (as_f64(require(map, "z_start")?, "z_start")?, 0.0), LiveFace::Od => (0.0, positive(require(map, "diameter")?, "diameter")?), @@ -2383,6 +2409,7 @@ fn parse_live_mill(map: &Map, names: &mut BTreeSet, has_s strategy, depth, step_down, + onion_skin, z_start, diameter, y_axis, @@ -3676,8 +3703,24 @@ fn flat_blocks(name: &str, tool_id: u32, feed: f64, rpm: f64, path: &FlatPath, p } fn mill_toolpath(job_path: &Path, job: &LatheJob, operation: &Operation, previous: &mut Option) -> Result { - let Operation::LiveMill { name, tool_id, rpm, feed, face, drawing, layer, strategy, depth, step_down, z_start, diameter, y_axis, c_deg, .. } = - operation + let Operation::LiveMill { + name, + tool_id, + rpm, + feed, + face, + drawing, + layer, + strategy, + depth, + step_down, + onion_skin, + z_start, + diameter, + y_axis, + c_deg, + .. + } = operation else { return Err(CliError::run(format!("internal: operation {} is not a live mill", operation.name()))); }; @@ -3698,6 +3741,7 @@ fn mill_toolpath(job_path: &Path, job: &LatheJob, operation: &Operation, previou feed: *feed, depth: *depth, step_down: *step_down, + onion_skin: *onion_skin, clearance: job.clearance, tolerance: job.linear, strategy: *strategy, diff --git a/crates/cli/src/live_mill.rs b/crates/cli/src/live_mill.rs index 8b0003b..4cce966 100644 --- a/crates/cli/src/live_mill.rs +++ b/crates/cli/src/live_mill.rs @@ -20,7 +20,9 @@ use std::path::{Path, PathBuf}; use campanion_geom::{BoundingBox, Containment, Contour, Region, Tolerance}; use campanion_import::{ImportOptions, import_dxf, import_svg}; use campanion_ir::{ArcSense, ClMotion, MoveRole, Plane, Pose, Vec3}; -use campanion_path::{Compensation, CutDirection, CutKind, Move, PocketOp, PocketStrategy, ProfileOp, ProfileSide, Tool, pass_depths}; +use campanion_path::{ + Compensation, CutDirection, CutKind, DepthEntry, DepthPlan, Move, PocketOp, PocketStrategy, ProfileOp, ProfileSide, Tool, check_onion_levels, +}; use crate::error::CliError; use crate::job::read_text; @@ -56,6 +58,8 @@ pub(crate) struct MillSpec { pub feed: f64, pub depth: f64, pub step_down: f64, + /// Skin left until the last pass. `None` keeps the ordinary depth plan. + pub onion_skin: Option, pub clearance: f64, pub tolerance: f64, pub strategy: MillStrategy, @@ -210,7 +214,7 @@ fn x_span(name: &str, contours: &[Contour]) -> Result { } fn toolpath_moves(spec: &MillSpec, contours: &[Contour]) -> Result, CliError> { - let depths = pass_depths(spec.depth, spec.step_down).map_err(|err| op_err(&spec.name, err))?; + let depths = mill_depths(&spec.name, spec.depth, spec.step_down, spec.onion_skin)?; let tolerance = Tolerance::new(spec.tolerance, Tolerance::DEFAULT_ANGULAR_RAD).map_err(|err| op_err(&spec.name, err))?; let tool = Tool::diameter(spec.tool_diameter); let mut moves = Vec::new(); @@ -618,6 +622,23 @@ fn resolve(job: &Path, relative: &str) -> PathBuf { } } +/// Depth of each live-mill pass. `d` on the toolpath is the negation of these levels. +/// +/// Only the last pass reaches `depth` when `onion_skin` is set. The check uses that planned +/// list, not the machine pose after the frame mapping. +pub(crate) fn mill_depths(name: &str, depth: f64, step_down: f64, onion_skin: Option) -> Result, CliError> { + let mut plan = DepthPlan::new(0.0, depth, step_down, DepthEntry::Plunge); + if let Some(skin) = onion_skin { + plan = plan.with_onion_skin(skin); + } + let depths = plan.depths().map_err(|err| op_err(name, err))?; + if let Some(skin) = onion_skin { + let levels: Vec = depths.iter().map(|level| -level).collect(); + check_onion_levels(&levels, -depth, skin, 1e-6).map_err(|err| op_err(name, err))?; + } + Ok(depths) +} + fn op_err(name: &str, err: impl std::fmt::Display) -> CliError { let text = err.to_string(); let detail = text.strip_prefix("invalid input: ").unwrap_or(text.as_str()); @@ -636,6 +657,7 @@ mod tests { feed: 100.0, depth: 1.0, step_down: 1.0, + onion_skin: None, clearance: 2.0, tolerance: 1e-4, strategy, @@ -664,6 +686,15 @@ mod tests { pose(motion).tip() } + #[test] + fn onion_skin_levels_stop_short_of_full_depth() { + let skinned = mill_depths("mill", 6.0, 2.0, Some(1.0)).unwrap_or_else(|err| panic!("{err}")); + assert_eq!(skinned, vec![2.0, 4.0, 5.0, 6.0]); + let plain = mill_depths("mill", 6.0, 2.0, None).unwrap_or_else(|err| panic!("{err}")); + assert_eq!(plain, vec![2.0, 4.0, 6.0]); + assert!(mill_depths("mill", 5.0, 2.0, Some(5.0)).is_err()); + } + #[test] fn maps_front_and_od_points() { let front = MillFrame::Front { z_start: 0.0 }; diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 25b1077..994dfc9 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -123,8 +123,8 @@ use std::process::ExitCode; use campanion_geom::{BoundingBox, Containment, Contour, Orientation, Point, Region, Tolerance}; use campanion_import::{Drawing, ImportOptions, import_dxf, import_svg}; use campanion_ir::{ - AssemblySegment, ClBlock, ClPath, CompensationMode, Document, Event, Fixture, Import, ProcessKind, ProcessParams, Program, RotarySetup, Tool, - ToolAssembly, Vec3, hold_spindle, + AssemblySegment, ClBlock, ClMotion, ClPath, CompensationMode, Document, Event, Fixture, Import, MoveRole, ProcessKind, ProcessParams, Program, + RotarySetup, Tool, ToolAssembly, Vec3, hold_spindle, }; use campanion_mesh::Mesh; use campanion_path::{ @@ -599,6 +599,10 @@ fn build(job: &JobFile, drawing: &Drawing) -> Result { wrap::cut(&mut paths, op, tool, contours, &setup, job.tolerance().chord(), safe)?; } } + // Planning-frame Z, before a tilted plane maps the paths. A wrap checks its own levels. + if op.onion_skin().is_some() { + check_posted_onion(op, &paths)?; + } if matches!(op.kind(), OperationKind::Wrap) { let Some(setup) = job.rotary() else { return Err(op_err(op, "wrap needs a rotary setup")); @@ -894,7 +898,9 @@ fn profile_paths(paths: &mut Vec, cut: &Cut<'_>, side: ProfileSide) -> R let radius = tool_radius(cut)?; let stock = cut.op.stock_to_leave(); // Roughing stands off the wall by `wall` and stops `floor` short of the final depth. - link_profile(paths, cut, side, radius + stock.wall(), cut.op.depth() - stock.floor(), cut.op.step_down())?; + // A shortened link must not enter an onion skin. The finish link, at full depth, cuts it. + let rough = roughing_depth(cut.op, cut.op.depth() - stock.floor()); + link_profile(paths, cut, side, radius + stock.wall(), rough, cut.op.step_down())?; if let Some(finish) = cut.op.finish() { link_profile(paths, cut, side, radius, cut.op.depth(), finish.step_down())?; } @@ -921,7 +927,8 @@ fn pocket_paths(paths: &mut Vec, cut: &Cut<'_>) -> Result<(), CliError> let stock = cut.op.stock_to_leave(); // All regions at one depth, then the next depth. Linking each depth on its own keeps that // order; `order_and_link` only chooses the travel order inside the depth. - for z in depth_levels(cut.op, cut.op.depth() - stock.floor(), cut.op.step_down())? { + let area = roughing_depth(cut.op, cut.op.depth() - stock.floor()); + for z in depth_levels(cut.op, area, cut.op.step_down())? { link_pocket_level(paths, cut, ®ions, z, stepover, stock.wall())?; } let Some(finish) = cut.op.finish() else { @@ -1008,14 +1015,16 @@ fn face_paths(paths: &mut Vec, cut: &Cut<'_>) -> Result<(), CliError> { return Err(op_err(cut.op, "face needs a stepover")); }; let area = face_area(cut)?; - let face = FaceOp::new(diameter, stepover) + let mut face = FaceOp::new(diameter, stepover) .angle(cut.op.face_angle()) .stock_z(0.0) .depth(cut.op.depth()) .step_down(cut.op.step_down()) - .retract_z(retract_z(cut.job)) - .generate(&area) - .map_err(|err| op_err(cut.op, err))?; + .retract_z(retract_z(cut.job)); + if let Some(skin) = cut.op.onion_skin() { + face = face.onion_skin(skin); + } + let face = face.generate(&area).map_err(|err| op_err(cut.op, err))?; push_doc(paths, Document::from_toolpath(face.toolpath(), cut.import)?) } @@ -1059,9 +1068,13 @@ fn drill_paths(paths: &mut Vec, cut: &Cut<'_>) -> Result<(), CliError> { let selected = if circles.is_empty() { cut.contours } else { circles.as_slice() }; let mut cycle = DrillCycle::new(); // `step_down` is the peck. A step that reaches the full depth is one feed, not a peck. - if cut.op.step_down() < cut.op.depth() - 1e-9 { + // A skin still needs that peck so the last feed is the only one at full depth. + if cut.op.step_down() < cut.op.depth() - 1e-9 || cut.op.onion_skin().is_some() { cycle = cycle.peck_depth(cut.op.step_down()); } + if let Some(skin) = cut.op.onion_skin() { + cycle = cycle.onion_skin(skin); + } if let Some(seconds) = cut.op.dwell() { cycle = cycle.dwell(seconds); } @@ -1117,7 +1130,7 @@ fn depth_at_retract(cut: &Cut<'_>, contour: &Contour) -> Result, contour: &Contour, total: f64, step_down: f64) -> Result { - let mut plan = DepthPlan::new(0.0, total, step_down, cut.op.entry()); + let mut plan = apply_onion(DepthPlan::new(0.0, total, step_down, cut.op.entry()), cut.op, total); // 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 @@ -1364,13 +1377,32 @@ fn orient_climb(op: &Operation, side: ProfileSide, path: &Contour, tolerance: &T if is_ccw == want_ccw { Ok(path.clone()) } else { Ok(path.reversed()) } } -/// Depths from [`campanion_path::pass_depths`], as absolute Z below the stock surface. +/// Depths from the operation's depth plan, as absolute Z below the stock surface. fn cut_levels(op: &Operation) -> Result, CliError> { depth_levels(op, op.depth(), op.step_down()) } +/// Put the skin on a plan whose target is the operation depth. A shorter link is left alone. +fn apply_onion(plan: DepthPlan, op: &Operation, total: f64) -> DepthPlan { + match op.onion_skin() { + Some(skin) if (total - op.depth()).abs() <= 1e-9 => plan.with_onion_skin(skin), + _ => plan, + } +} + +/// Keep a link that already stops short of `depth` out of the skin. +/// +/// When `onion_skin` is omitted this is `target`, so the ordinary depth is unchanged. +fn roughing_depth(op: &Operation, target: f64) -> f64 { + match op.onion_skin() { + Some(skin) if (target - op.depth()).abs() > 1e-9 => target.min(op.depth() - skin), + _ => target, + } +} + fn depth_levels(op: &Operation, total: f64, step_down: f64) -> Result, CliError> { - let depths = campanion_path::pass_depths(total, step_down).map_err(|err| op_err(op, err))?; + let plan = apply_onion(DepthPlan::new(0.0, total, step_down, DepthEntry::Plunge), op, total); + 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; @@ -1545,6 +1577,60 @@ fn op_err(op: &Operation, err: impl std::fmt::Display) -> CliError { CliError::run(format!("operation {}: {err}", op.name())) } +/// Earlier cut passes stay at least `onion_skin` above the floor. The last pass reaches it. +/// +/// A roughing link that leaves floor stock, and has no finish pass, never reaches the sheet +/// floor. That path must still stay out of the skin. A wrap is checked from its planned levels. +fn check_posted_onion(op: &Operation, paths: &[ClPath]) -> Result<(), CliError> { + if matches!(op.kind(), OperationKind::Wrap) { + return Ok(()); + } + let Some(skin) = op.onion_skin() else { + return Ok(()); + }; + let floor = -op.depth(); + const TOL: f64 = 1e-4; + let heights = onion_feed_heights(paths); + let holds_skin = op.finish().is_none() && op.stock_to_leave().floor() > 1e-9; + if holds_skin { + let skin_z = floor + skin; + if heights.iter().any(|z| *z < skin_z - TOL) { + return Err(op_err(op, "a depth pass cuts into the onion skin")); + } + return Ok(()); + } + campanion_path::check_onion_cuts(&heights, floor, skin, TOL).map_err(|err| op_err(op, err)) +} + +/// Cut heights in order. Side cuts win. A drill, which only plunges, uses those. +fn onion_feed_heights(paths: &[ClPath]) -> Vec { + let mut cuts = Vec::new(); + let mut plunges = Vec::new(); + for path in paths { + for block in path.blocks() { + let ClBlock::Motion(motion) = block else { + continue; + }; + let Some((z, role)) = feed_z(motion) else { + continue; + }; + match role { + MoveRole::Cut => cuts.push(z), + MoveRole::Plunge => plunges.push(z), + _ => {} + } + } + } + if cuts.is_empty() { plunges } else { cuts } +} + +fn feed_z(motion: &ClMotion) -> Option<(f64, MoveRole)> { + match motion { + ClMotion::Linear { to, role, .. } | ClMotion::Arc { to, role, .. } | ClMotion::Helix { to, role, .. } => Some((to.tip().z(), *role)), + _ => None, + } +} + #[cfg(test)] mod tests { use super::run; diff --git a/crates/cli/src/wrap.rs b/crates/cli/src/wrap.rs index c51d736..6a2d1c9 100644 --- a/crates/cli/src/wrap.rs +++ b/crates/cli/src/wrap.rs @@ -7,7 +7,7 @@ use campanion_geom::{Arc, Contour, Point, Segment}; use campanion_ir::{ClBlock, ClMotion, ClPath, CompensationMode, Cylinder, Event, MoveRole, Pose, ProcessKind, RotarySetup, wrap_polyline}; -use campanion_path::{Tool as PathTool, pass_depths}; +use campanion_path::{DepthEntry, DepthPlan, Tool as PathTool, check_onion_levels}; use crate::error::CliError; use crate::jobfile::{JobTool, Operation}; @@ -40,10 +40,7 @@ pub(crate) fn cut( if !(w_safe.is_finite() && diameter * 0.5 + w_safe > 0.0) { return Err(op_err(op, "wrap radius must be positive")); } - let levels = pass_depths(op.depth(), op.step_down()).map_err(|err| op_err(op, err))?; - if levels.is_empty() { - return Err(op_err(op, "depth plan is empty")); - } + let levels = wrap_levels(op)?; let process = match tool.path_tool() { PathTool::Diameter { .. } => ProcessKind::Spindle, PathTool::Kerf { .. } => ProcessKind::Torch, @@ -207,3 +204,22 @@ fn angular_step(radius: f64, tol: f64) -> f64 { fn op_err(op: &Operation, err: impl std::fmt::Display) -> CliError { CliError::run(format!("operation {}: {err}", op.name())) } + +/// Depth of each wrap pass. Only the last pass reaches full depth when `onion_skin` is set. +/// +/// The check uses these planned levels (`w = -level`), not the wrapped machine Z. +fn wrap_levels(op: &Operation) -> Result, CliError> { + let mut plan = DepthPlan::new(0.0, op.depth(), op.step_down(), DepthEntry::Plunge); + if let Some(skin) = op.onion_skin() { + plan = plan.with_onion_skin(skin); + } + let levels = plan.depths().map_err(|err| op_err(op, err))?; + if levels.is_empty() { + return Err(op_err(op, "depth plan is empty")); + } + if let Some(skin) = op.onion_skin() { + let cut_z: Vec = levels.iter().map(|level| -level).collect(); + check_onion_levels(&cut_z, -op.depth(), skin, 1e-4).map_err(|err| op_err(op, err))?; + } + Ok(levels) +} diff --git a/crates/cli/tests/onion_e2e.rs b/crates/cli/tests/onion_e2e.rs new file mode 100644 index 0000000..c103bbd --- /dev/null +++ b/crates/cli/tests/onion_e2e.rs @@ -0,0 +1,198 @@ +//! A small outside profile with `onion_skin` posts the skin as its own last pass. +//! +//! Depth 6 mm at a 2 mm step with a 1 mm skin is Z −2, −4, −5, then −6. The same job +//! without the field is Z −2, −4, −6. `verify` reports no issues. This output has not +//! been proven on a machine. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const EPS: f64 = 1.0e-3; + +fn temp_dir(name: &str) -> Result { + let root = std::env::var_os("CARGO_TARGET_TMPDIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let dir = root.join(name); + std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + Ok(dir) +} + +fn text(bytes: &[u8]) -> Result { + String::from_utf8(bytes.to_vec()).map_err(|err| err.to_string()) +} + +fn fnv1a64(bytes: &[u8]) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +fn square_dxf() -> String { + let pts = [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0), (0.0, 0.0)]; + let mut entities = String::new(); + for pair in pts.windows(2) { + let (x0, y0) = pair[0]; + let (x1, y1) = pair[1]; + entities.push_str(&format!("0\nLINE\n8\nPLATE\n10\n{x0}\n20\n{y0}\n11\n{x1}\n21\n{y1}\n")); + } + format!("0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{entities}0\nENDSEC\n0\nEOF\n") +} + +fn job(hash: &str, onion: &str) -> String { + format!( + r#"{{ + "format": "campanion-job/1", + "source": {{ "path": "plate.dxf", "hash": "{hash}" }}, + "units": "mm", + "tools": [ {{ "id": 1, "diameter": 6, "speed": 12000 }} ], + "operations": [ {{ + "name": "plate", + "type": "profile", + "layers": ["PLATE"], + "side": "outside", + "tool_id": 1, + "depth": 6, + "step_down": 2{onion}, + "feeds": {{ "cut": 1000, "plunge": 300 }} + }} ] +}}"# + ) +} + +fn campanion(dir: &Path, args: &[&str]) -> Result { + Command::new(env!("CARGO_BIN_EXE_campanion")).args(args).current_dir(dir).output().map_err(|err| err.to_string()) +} + +fn words(line: &str) -> Result, String> { + let bytes = line.as_bytes(); + let mut index = 0; + let mut out = Vec::new(); + while index < bytes.len() { + let ch = bytes[index]; + if ch.is_ascii_alphabetic() { + let letter = (ch as char).to_ascii_uppercase(); + index += 1; + let start = index; + if index < bytes.len() && (bytes[index] == b'+' || bytes[index] == b'-') { + index += 1; + } + let digits = index; + while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') { + index += 1; + } + if index == digits { + continue; + } + let token = std::str::from_utf8(&bytes[start..index]).map_err(|_| "word is not utf-8".to_string())?; + let value = token.parse::().map_err(|_| format!("bad number {token}"))?; + out.push((letter, value)); + } else { + index += 1; + } + } + Ok(out) +} + +fn strip_comment(line: &str) -> String { + let mut out = String::new(); + let mut skipping = false; + for ch in line.chars() { + if ch == '(' { + skipping = true; + } else if ch == ')' { + skipping = false; + } else if !skipping { + out.push(ch); + } + } + out.trim().to_string() +} + +/// Distinct feed Z levels, in order. A later feed that stays at the same Z is one level. +fn feed_levels(ngc: &str) -> Result, String> { + let mut modal: Option = None; + let mut z = 0.0; + let mut levels: Vec = Vec::new(); + for (index, raw) in ngc.lines().enumerate() { + let code = strip_comment(raw); + if code.is_empty() { + continue; + } + let parsed = words(&code).map_err(|err| format!("line {}: {err}", index + 1))?; + let mut group = modal; + let mut moved = false; + let mut line_z = None; + for (letter, value) in &parsed { + if *letter == 'G' && *value >= 0.0 && *value <= 3.0 && (value - value.round()).abs() < 1e-6 { + group = Some(*value as i32); + modal = group; + } + if *letter == 'Z' { + z = *value; + line_z = Some(*value); + moved = true; + } + if *letter == 'X' || *letter == 'Y' { + moved = true; + } + } + if moved && group == Some(1) { + let level = line_z.unwrap_or(z); + let fresh = match levels.last() { + None => true, + Some(&prev) => (level - prev).abs() > EPS, + }; + if fresh { + levels.push(level); + } + } + } + Ok(levels) +} + +fn near_list(actual: &[f64], expected: &[f64]) -> bool { + actual.len() == expected.len() && actual.iter().zip(expected).all(|(a, b)| (a - b).abs() <= EPS) +} + +fn post(dir: &Path, onion: &str) -> Result, String> { + let drawing = square_dxf(); + let hash = fnv1a64(drawing.as_bytes()); + std::fs::write(dir.join("plate.dxf"), drawing).map_err(|err| err.to_string())?; + std::fs::write(dir.join("job.json"), job(&hash, onion)).map_err(|err| err.to_string())?; + let ngc = dir.join("out.ngc"); + let ir = dir.join("out.json"); + let ngc_arg = ngc.to_str().ok_or("ngc path")?; + let ir_arg = ir.to_str().ok_or("ir path")?; + let output = campanion(dir, &["run", "job.json", "-o", ngc_arg, "--ir", ir_arg, "--post", "linuxcnc"])?; + let stderr = text(&output.stderr)?; + let stdout = text(&output.stdout)?; + if output.status.code() != Some(0) { + return Err(format!("run exit {:?}\n{stdout}\n{stderr}", output.status.code())); + } + let posted = std::fs::read_to_string(&ngc).map_err(|err| err.to_string())?; + let verify = campanion(dir, &["verify", ir_arg])?; + let verify_out = text(&verify.stdout)?; + let verify_err = text(&verify.stderr)?; + if verify.status.code() != Some(0) { + return Err(format!("verify exit {:?}\n{verify_out}\n{verify_err}", verify.status.code())); + } + if !verify_out.lines().any(|line| line == "status ok") || !verify_out.lines().any(|line| line == "issues 0") { + return Err(format!("verify reported errors:\n{verify_out}{verify_err}")); + } + feed_levels(&posted) +} + +#[test] +fn profile_onion_skin_posts_the_skin_last() -> Result<(), String> { + let skinned = post(&temp_dir("campanion-onion-skin")?, r#","onion_skin":1"#)?; + if !near_list(&skinned, &[-2.0, -4.0, -5.0, -6.0]) { + return Err(format!("skinned levels {skinned:?}")); + } + let plain = post(&temp_dir("campanion-onion-plain")?, "")?; + if !near_list(&plain, &[-2.0, -4.0, -6.0]) { + return Err(format!("plain levels {plain:?}")); + } + Ok(()) +} diff --git a/crates/path/src/depth.rs b/crates/path/src/depth.rs index 8f18e39..4264f5f 100644 --- a/crates/path/src/depth.rs +++ b/crates/path/src/depth.rs @@ -4,6 +4,11 @@ //! at `stock_z - d`. Passes are spaced by the step-down. The last pass is the total depth and //! is not deeper: 5 mm at a 2 mm step is 2, 4, 5. //! +//! [`DepthPlan::with_onion_skin`] leaves a skin. Earlier passes stop short of the total depth +//! by that thickness, and only the last pass reaches it. The final pass uses the same entry as +//! the others. Five millimetres at a two millimetre step with a one millimetre skin is 2, 4, 5. +//! Six millimetres at a two millimetre step with a one millimetre skin is 2, 4, 5, 6. +//! //! A ramp follows the contour. Its angle with the XY plane never exceeds the given maximum. //! A helix is a counter-clockwise circle about the contour's bounding-box centre; each full //! turn lowers Z by the pitch. A plunge is a vertical move at the contour start. The contour @@ -94,6 +99,8 @@ impl DepthTabs { /// limit when the posted move actually starts at the retract height. Later passes start /// at the previous level. A plunge ignores it and still starts on the stock. Tabs, when /// set, lift a ramp across each bridge instead of letting that descent cut through it. +/// `onion_skin`, when set, is the thickness left above the floor until the last pass. +/// Only that last pass reaches `total_depth`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct DepthPlan { stock_z: f64, @@ -102,15 +109,16 @@ pub struct DepthPlan { entry: DepthEntry, entry_start: f64, tabs: Option, + onion_skin: Option, } impl DepthPlan { /// `total_depth` and `step_down` are millimetres downward from `stock_z`. /// /// The first ramp or helix starts on the stock surface. Values are checked when the plan is used. - /// Tabs are off until [`Self::with_tabs`]. + /// Tabs are off until [`Self::with_tabs`]. Onion skin is off until [`Self::with_onion_skin`]. pub fn new(stock_z: f64, total_depth: f64, step_down: f64, entry: DepthEntry) -> Self { - Self { stock_z, total_depth, step_down, entry, entry_start: stock_z, tabs: None } + Self { stock_z, total_depth, step_down, entry, entry_start: stock_z, tabs: None, onion_skin: None } } /// Start a ramp or helix at `z` instead of the stock surface. @@ -128,6 +136,17 @@ impl DepthPlan { self } + /// Leave `mm` millimetres of skin above the floor. + /// + /// Earlier passes are [`pass_depths`] of `total_depth - mm`. Only the last pass reaches + /// [`Self::total_depth`], and it uses the same entry as the other passes. `mm` must be + /// positive and strictly less than the total depth. Checked when the plan is used. + /// Replaces a skin already set. + pub fn with_onion_skin(mut self, mm: f64) -> Self { + self.onion_skin = Some(mm); + self + } + pub fn stock_z(self) -> f64 { self.stock_z } @@ -152,6 +171,22 @@ impl DepthPlan { pub fn tabs(self) -> Option { self.tabs } + + /// Skin left above the floor, in millimetres. `None` when the plan has no onion skin. + pub fn onion_skin(self) -> Option { + self.onion_skin + } + + /// Depth of each pass, in millimetres downward from the stock surface. + /// + /// Without onion skin this is [`pass_depths`]. With onion skin, roughing stops at + /// `total_depth - onion_skin` and only the last pass is the full depth. + pub fn depths(self) -> Result> { + match self.onion_skin { + None => pass_depths(self.total_depth, self.step_down), + Some(skin) => onion_pass_depths(self.total_depth, self.step_down, skin), + } + } } /// Depth of each pass, in millimetres downward from the stock surface. @@ -185,6 +220,133 @@ pub fn pass_depths(total_depth: f64, step_down: f64) -> Result> { Ok(depths) } +/// Roughing passes for `total_depth - skin`, then one pass at `total_depth`. +/// +/// Only that last pass reaches full depth. `skin` must be positive and strictly less than +/// `total_depth`. +fn onion_pass_depths(total_depth: f64, step_down: f64, skin: f64) -> Result> { + let total_depth = capped_positive(total_depth, "total depth")?; + let skin = capped_positive(skin, "onion skin")?; + if skin >= total_depth { + return Err(check::invalid("onion skin must be less than total depth")); + } + let roughing = total_depth - skin; + if !(roughing > 0.0) || roughing >= total_depth { + return Err(check::invalid("onion skin must be less than total depth")); + } + let mut depths = pass_depths(roughing, step_down)?; + if depths.last().is_some_and(|depth| *depth + 1e-9 >= total_depth) { + return Err(check::invalid("onion skin must be less than total depth")); + } + if depths.len() >= MAX_PASSES { + return Err(check::invalid(format!("too many depth passes: limit is {MAX_PASSES}"))); + } + depths.push(total_depth); + Ok(depths) +} + +/// Confirm that only the last level in `levels` reaches `floor_z`. +/// +/// `levels` are cut heights in order. Z is up, so a deeper pass is a smaller Z. Every level +/// before the last is strictly above `floor_z` and at least `onion_skin` above it, within +/// `tolerance` millimetres. The last level is the floor within that tolerance. +pub fn check_onion_levels(levels: &[f64], floor_z: f64, onion_skin: f64, tolerance: f64) -> Result<()> { + if levels.is_empty() { + return Err(check::invalid("onion skin produced no depth pass")); + } + if !(floor_z.is_finite() && onion_skin.is_finite() && tolerance.is_finite() && tolerance >= 0.0) { + return Err(check::invalid("onion skin check is not finite")); + } + if onion_skin <= 0.0 { + return Err(check::invalid("onion skin must be positive")); + } + let last = levels[levels.len() - 1]; + if !last.is_finite() || (last - floor_z).abs() > tolerance { + return Err(check::invalid("onion skin last pass does not reach the floor")); + } + let skin_z = floor_z + onion_skin; + for z in &levels[..levels.len() - 1] { + if !z.is_finite() || *z <= floor_z + tolerance { + return Err(check::invalid("a depth pass before the last reaches full depth")); + } + if *z + tolerance < skin_z { + return Err(check::invalid("a depth pass cuts into the onion skin")); + } + } + Ok(()) +} + +/// Same check as [`check_onion_levels`], over each descent in `cut_z`. +/// +/// Consecutive heights within `tolerance` are one level. A later height that is higher starts +/// another descent, which is how a second contour or a finish link begins. At least one descent +/// reaches `floor_z`. A descent that stays up must not enter the skin. +pub fn check_onion_cuts(cut_z: &[f64], floor_z: f64, onion_skin: f64, tolerance: f64) -> Result<()> { + if !(tolerance.is_finite() && tolerance >= 0.0) { + return Err(check::invalid("onion skin check is not finite")); + } + let levels = collapse_heights(cut_z, tolerance); + let descents = split_descents(&levels, tolerance); + if descents.is_empty() { + return Err(check::invalid("onion skin produced no depth pass")); + } + let mut reached = false; + for descent in &descents { + let Some(last) = descent.last().copied() else { + continue; + }; + if (last - floor_z).abs() <= tolerance { + reached = true; + check_onion_levels(descent, floor_z, onion_skin, tolerance)?; + } else if last < floor_z + onion_skin - tolerance { + return Err(check::invalid("a depth pass cuts into the onion skin")); + } + } + if !reached { + return Err(check::invalid("onion skin last pass does not reach the floor")); + } + Ok(()) +} + +fn collapse_heights(heights: &[f64], tolerance: f64) -> Vec { + let mut levels = Vec::new(); + for z in heights { + if levels.last().is_none_or(|prev: &f64| (*z - prev).abs() > tolerance) { + levels.push(*z); + } + } + levels +} + +fn split_descents(levels: &[f64], tolerance: f64) -> Vec> { + let mut descents = Vec::new(); + let mut current = Vec::new(); + for z in levels { + if current.last().is_some_and(|prev: &f64| *z > *prev + tolerance) { + descents.push(std::mem::take(&mut current)); + } + current.push(*z); + } + if !current.is_empty() { + descents.push(current); + } + descents +} + +/// Cut heights of a toolpath, in order. Side cuts win. A drill, which only plunges, uses those. +pub fn onion_cut_heights(moves: &[Move]) -> Vec { + let mut cuts = Vec::new(); + let mut plunges = Vec::new(); + for mv in moves { + match *mv { + Move::Linear { to, kind: CutKind::Cut } | Move::Arc { to, kind: CutKind::Cut, .. } => cuts.push(to.z()), + Move::Linear { to, kind: CutKind::Plunge } | Move::Arc { to, kind: CutKind::Plunge, .. } => plunges.push(to.z()), + _ => {} + } + } + if cuts.is_empty() { plunges } else { cuts } +} + /// Cut `contour` once at each depth, entering with `plan.entry`. /// /// Tabs allow an open contour. Without them the contour must be closed. @@ -203,7 +365,7 @@ pub fn depth_toolpath(contour: &Contour, plan: &DepthPlan, tolerance: &Tolerance if !contour.is_closed() { return Err(check::invalid("depth passes need a closed contour")); } - let depths = pass_depths(plan.total_depth, plan.step_down)?; + let depths = plan.depths()?; let length = contour.length()?; if !length.is_finite() || length <= tolerance.linear() { return Err(check::invalid("contour length is too small")); @@ -561,7 +723,7 @@ impl TabCut { fn tabbed_toolpath(contour: &Contour, plan: &DepthPlan, tolerance: &Tolerance, entry_start: f64) -> Result { let cut = prepare_tabs(contour, plan, tolerance)?; - let depths = pass_depths(plan.total_depth, plan.step_down)?; + let depths = plan.depths()?; let floor = cut.stock_z - plan.total_depth; finite(floor, "final depth")?; match plan.entry { @@ -977,6 +1139,84 @@ mod tests { near(plunges[2], -5.0); } + #[test] + fn onion_skin_five_at_two_with_one_is_two_four_then_five() { + let plan = DepthPlan::new(0.0, 5.0, 2.0, DepthEntry::Plunge).with_onion_skin(1.0); + let depths = plan.depths().unwrap(); + assert_eq!(depths, vec![2.0, 4.0, 5.0]); + assert_eq!(depths.last().copied(), Some(5.0)); + // Stock Z is 0, so the same passes sit at -2, -4, then -5. + let shape = rect(10.0, 10.0); + let path = depth_toolpath(&shape, &plan, &Tolerance::default()).unwrap(); + let heights = onion_cut_heights(path.moves()); + check_onion_cuts(&heights, -5.0, 1.0, 1e-6).unwrap(); + let levels = collapse_heights(&heights, 1e-6); + assert_eq!(levels, vec![-2.0, -4.0, -5.0]); + + // A skin that does not land on the ordinary last step inserts the floor after it. + let deeper = DepthPlan::new(0.0, 6.0, 2.0, DepthEntry::Plunge).with_onion_skin(1.0); + assert_eq!(deeper.depths().unwrap(), vec![2.0, 4.0, 5.0, 6.0]); + assert_eq!(pass_depths(6.0, 2.0).unwrap(), vec![2.0, 4.0, 6.0]); + let path = depth_toolpath(&shape, &deeper, &Tolerance::default()).unwrap(); + check_onion_cuts(&onion_cut_heights(path.moves()), -6.0, 1.0, 1e-6).unwrap(); + + // Omitted skin is the ordinary plan. + assert_eq!(DepthPlan::new(0.0, 5.0, 2.0, DepthEntry::Plunge).depths().unwrap(), pass_depths(5.0, 2.0).unwrap()); + assert!(DepthPlan::new(0.0, 5.0, 2.0, DepthEntry::Plunge).onion_skin().is_none()); + } + + #[test] + fn onion_skin_final_pass_uses_the_same_entry() { + let shape = rect(40.0, 10.0); + let angle = 0.2; + let plan = DepthPlan::new(0.0, 6.0, 2.0, DepthEntry::Ramp { max_angle: angle }).with_onion_skin(1.0); + let path = depth_toolpath(&shape, &plan, &Tolerance::default()).unwrap(); + check_onion_cuts(&onion_cut_heights(path.moves()), -6.0, 1.0, 1e-6).unwrap(); + let mut lead_to_floor = false; + for mv in path.moves() { + if let Move::Linear { to, kind: CutKind::LeadIn } = *mv + && (to.z() + 6.0).abs() < 1e-6 + { + lead_to_floor = true; + } + } + assert!(lead_to_floor, "the last pass did not ramp to the floor"); + + let helix = DepthPlan::new(0.0, 6.0, 2.0, DepthEntry::Helix { radius: 2.0, pitch: 1.5 }).with_onion_skin(1.0); + let path = depth_toolpath(&shape, &helix, &Tolerance::default()).unwrap(); + check_onion_cuts(&onion_cut_heights(path.moves()), -6.0, 1.0, 1e-6).unwrap(); + let mut helix_to_floor = false; + for mv in path.moves() { + if let Move::Arc { to, kind: CutKind::LeadIn, .. } = *mv + && (to.z() + 6.0).abs() < 1e-6 + { + helix_to_floor = true; + } + } + assert!(helix_to_floor, "the last pass did not helix to the floor"); + } + + #[test] + fn onion_skin_rejects_a_skin_that_is_not_inside_the_depth() { + let plunge = DepthEntry::Plunge; + for skin in [0.0, -1.0, 5.0, 5.1, f64::NAN, f64::INFINITY] { + let plan = DepthPlan::new(0.0, 5.0, 2.0, plunge).with_onion_skin(skin); + assert!(plan.depths().is_err(), "accepted skin {skin}"); + } + let shape = rect(10.0, 10.0); + let bad = DepthPlan::new(0.0, 5.0, 2.0, plunge).with_onion_skin(5.0); + assert!(depth_toolpath(&shape, &bad, &Tolerance::default()).is_err()); + } + + #[test] + fn onion_skin_with_tabs_still_reaches_the_floor_last() { + let shape = rect(80.0, 40.0); + let spec = crate::TabSpec::count(4, 8.0).height(2.0); + let plan = DepthPlan::new(0.0, 6.0, 2.0, DepthEntry::Plunge).with_onion_skin(1.0).with_tabs(crate::DepthTabs::new(spec, 6.0, 2.0, 0.0)); + let path = depth_toolpath(&shape, &plan, &Tolerance::default()).unwrap(); + check_onion_cuts(&onion_cut_heights(path.moves()), -6.0, 1.0, 1e-6).unwrap(); + } + #[test] fn ramp_angle_is_never_exceeded() { let shape = rect(30.0, 10.0); @@ -1332,6 +1572,39 @@ mod tests { Contour::try_new(vec![arc(0.0), arc(std::f64::consts::PI)], true, &Tolerance::default()).unwrap() } + proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + #[test] + fn random_onion_skin_only_the_last_pass_reaches_full_depth( + depth in 0.5f64..30.0, + step_frac in 0.2f64..1.2, + skin_frac in 0.05f64..0.8, + ) { + let step = (depth * step_frac).max(0.05); + let skin = (depth * skin_frac).clamp(0.02, depth - 0.02); + prop_assume!(skin + 1e-6 < depth); + prop_assume!(step <= depth * 2.0); + let plan = DepthPlan::new(0.0, depth, step, DepthEntry::Plunge).with_onion_skin(skin); + let levels = plan.depths().unwrap(); + let mut expect = pass_depths(depth - skin, step).unwrap(); + expect.push(depth); + prop_assert_eq!(&levels[..], &expect[..]); + let floor = -depth; + let zs: Vec = levels.iter().map(|level| -level).collect(); + check_onion_levels(&zs, floor, skin, 1e-6).unwrap(); + check_onion_cuts(&zs, floor, skin, 1e-6).unwrap(); + prop_assert!((levels.last().copied().unwrap_or(0.0) - depth).abs() < 1e-9); + for earlier in &levels[..levels.len() - 1] { + prop_assert!(*earlier < depth - 1e-9); + prop_assert!(*earlier <= depth - skin + 1e-6); + } + let shape = rect(12.0, 8.0); + let path = depth_toolpath(&shape, &plan, &Tolerance::default()).unwrap(); + check_onion_cuts(&onion_cut_heights(path.moves()), floor, skin, 1e-6).unwrap(); + } + } + proptest! { #![proptest_config(ProptestConfig::with_cases(40))] diff --git a/crates/path/src/drill.rs b/crates/path/src/drill.rs index b230fd9..4482681 100644 --- a/crates/path/src/drill.rs +++ b/crates/path/src/drill.rs @@ -13,6 +13,7 @@ use crate::Result; use crate::check::{self, finite, non_negative, positive}; use crate::pass_depths; use crate::toolpath::{CutKind, Move, Point3, Toolpath}; +use crate::{DepthEntry, DepthPlan}; const TAU: f64 = std::f64::consts::TAU; @@ -24,11 +25,13 @@ pub struct DrillCycle { peck_depth: Option, retract: Option, dwell: Option, + /// Skin left until the last peck. `None` keeps the ordinary peck list. + onion_skin: Option, } impl DrillCycle { pub fn new() -> Self { - Self { peck_depth: None, retract: None, dwell: None } + Self { peck_depth: None, retract: None, dwell: None, onion_skin: None } } /// Depth of one peck, in millimetres. Validated when the operation runs. @@ -49,6 +52,15 @@ impl DrillCycle { self } + /// Leave `mm` millimetres above the hole bottom until the last peck. + /// + /// Earlier pecks are the ordinary plan of `depth - mm`. Only the last peck reaches the + /// bottom. Replaces a skin already set. + pub fn onion_skin(mut self, mm: f64) -> Self { + self.onion_skin = Some(mm); + self + } + pub fn peck(&self) -> Option { self.peck_depth } @@ -61,6 +73,11 @@ impl DrillCycle { self.dwell } + /// Skin left above the hole bottom, in millimetres. `None` when the cycle has no onion skin. + pub fn onion(&self) -> Option { + self.onion_skin + } + fn validate(self) -> Result<()> { if let Some(depth) = self.peck_depth { positive(depth, "peck depth")?; @@ -71,6 +88,9 @@ impl DrillCycle { if let Some(seconds) = self.dwell { non_negative(seconds, "dwell")?; } + if let Some(skin) = self.onion_skin { + positive(skin, "onion skin")?; + } Ok(()) } } @@ -152,7 +172,7 @@ impl Drilling { if r_plane <= bottom { return Err(check::invalid("R plane must be above the hole bottom")); } - let levels = hole_levels(self.cycle.peck(), top_z, bottom)?; + let levels = hole_levels(self.cycle.peck(), top_z, bottom, self.cycle.onion())?; let last = levels.len().saturating_sub(1); check::push(&mut moves, Move::Rapid { to: Point3::new(x, y, clearance)? })?; check::push(&mut moves, Move::Rapid { to: Point3::new(x, y, r_plane)? })?; @@ -275,7 +295,26 @@ impl DrillOp { } /// Peck Z levels for one hole. The last entry is exactly `bottom`. -fn hole_levels(peck: Option, top_z: f64, bottom: f64) -> Result> { +/// +/// With no onion skin this is the ordinary peck list. With a skin, earlier pecks stop +/// `onion` short of the hole and only the last peck reaches `bottom`. +fn hole_levels(peck: Option, top_z: f64, bottom: f64, onion: Option) -> Result> { + let Some(skin) = onion else { + return hole_levels_plain(peck, top_z, bottom); + }; + let depth = top_z - bottom; + let step = peck.unwrap_or(depth); + let plan = DepthPlan::new(top_z, depth, step, DepthEntry::Plunge).with_onion_skin(skin); + let mut levels: Vec = plan.depths()?.into_iter().map(|level| top_z - level).collect(); + match levels.last_mut() { + Some(last) => *last = bottom, + None => levels.push(bottom), + } + Ok(levels) +} + +/// Peck Z levels with no onion skin. The last entry is exactly `bottom`. +fn hole_levels_plain(peck: Option, top_z: f64, bottom: f64) -> Result> { let Some(peck) = peck else { return Ok(vec![bottom]); }; @@ -366,6 +405,16 @@ mod tests { assert!((a - b).abs() < 1e-9, "{a} != {b}"); } + fn plunge_z(path: &Toolpath) -> Vec { + path.moves() + .iter() + .filter_map(|mv| match mv { + Move::Linear { to, kind: CutKind::Plunge } => Some(to.z()), + _ => None, + }) + .collect() + } + #[test] fn three_circles_keep_their_centres_in_order() { let circles = [circle(0.0, 0.0, 3.0), circle(10.0, 0.0, 5.0), circle(25.0, 7.0, 2.0)]; @@ -534,6 +583,18 @@ mod tests { assert!(exceptions.is_empty(), "exceptions: none, found {exceptions:?}"); } + #[test] + fn onion_skin_pecks_stop_short_of_the_floor() { + // Depth 6 mm, peck 2 mm, skin 1 mm: -2, -4, -5, then the floor at -6. + // The same peck without a skin is -2, -4, -6. + let skinned = DrillCycle::new().peck_depth(2.0).onion_skin(1.0); + let path = one_hole(skinned, -6.0).expand(0.0, 2.0, 5.0, CutKind::Plunge).unwrap(); + let feeds = plunge_z(&path); + assert_eq!(feeds, vec![-2.0, -4.0, -5.0, -6.0]); + let plain = one_hole(DrillCycle::new().peck_depth(2.0), -6.0).expand(0.0, 2.0, 5.0, CutKind::Plunge).unwrap(); + assert_eq!(plunge_z(&plain), vec![-2.0, -4.0, -6.0]); + } + #[test] fn expand_rejects_a_low_r_plane_a_low_clearance_and_a_non_finite_height() { let drilling = one_hole(DrillCycle::new(), -3.0); diff --git a/crates/path/src/face.rs b/crates/path/src/face.rs index 952d14f..a8e4ac4 100644 --- a/crates/path/src/face.rs +++ b/crates/path/src/face.rs @@ -3,6 +3,8 @@ //! Passes run parallel to the angle and step over outside the area, so the link between two //! lines does not cross the rectangle. Each depth repeats that pattern. [`crate::pass_depths`] //! chooses the levels: the last one is the total depth and is not deeper. +//! [`FaceOp::onion_skin`] stops every earlier level short of that depth. Only the last +//! level reaches it. //! //! An angle of `0` runs along +X. `π/2` runs along +Y. Any other angle is //! [`crate::PathError::NotSupported`]. @@ -10,7 +12,7 @@ use campanion_geom::BoundingBox; use crate::check::{self, finite, invalid, non_negative, positive}; -use crate::pass_depths; +use crate::depth::{DepthEntry, DepthPlan}; use crate::toolpath::{CutKind, Move, Point3, ProcessEvent, Toolpath}; use crate::{PathError, Result}; @@ -52,6 +54,7 @@ pub struct FaceOp { depth: f64, step_down: f64, retract_z: f64, + onion_skin: Option, } impl FaceOp { @@ -59,7 +62,7 @@ impl FaceOp { /// /// It must be positive and no larger than `tool_diameter`. pub fn new(tool_diameter: f64, stepover: f64) -> Self { - Self { tool_diameter, stepover, angle: 0.0, overrun: None, stock_z: 0.0, depth: 0.0, step_down: 0.0, retract_z: 2.0 } + Self { tool_diameter, stepover, angle: 0.0, overrun: None, stock_z: 0.0, depth: 0.0, step_down: 0.0, retract_z: 2.0, onion_skin: None } } /// Direction of the passes, in radians from +X. @@ -98,6 +101,15 @@ impl FaceOp { self } + /// Leave `mm` millimetres of skin. Earlier levels stop at `depth - mm`. + /// + /// Only the last level reaches [`Self::depth`]. `mm` must be positive and strictly less + /// than the depth. Checked when [`Self::generate`] runs. + pub fn onion_skin(mut self, mm: f64) -> Self { + self.onion_skin = Some(mm); + self + } + /// Safe height for the rapid into each pass and the retract out. Defaults to 2 mm. pub fn retract_z(mut self, z: f64) -> Self { self.retract_z = z; @@ -125,7 +137,11 @@ impl FaceOp { let along_x = along_x(self.angle)?; let stock_z = finite(self.stock_z, "stock z")?; let retract_z = finite(self.retract_z, "retract z")?; - let depths = pass_depths(self.depth, self.step_down)?; + let mut plan = DepthPlan::new(stock_z, self.depth, self.step_down, DepthEntry::Plunge); + if let Some(skin) = self.onion_skin { + plan = plan.with_onion_skin(skin); + } + let depths = plan.depths()?; let min_x = finite(area.min().x(), "area")?; let min_y = finite(area.min().y(), "area")?; @@ -249,6 +265,7 @@ mod tests { use super::*; use crate::PathError; use crate::pass_depths; + use crate::{check_onion_cuts, onion_cut_heights}; use campanion_geom::Point; use proptest::prelude::*; use std::f64::consts::{FRAC_PI_2, PI}; @@ -420,6 +437,18 @@ mod tests { assert!(FaceOp::new(10.0, 10.0).depth(1.0).step_down(1.0).generate(&area).is_ok()); } + #[test] + fn onion_skin_stops_short_until_the_last_level() { + let area = rectangle(0.0, 0.0, 30.0, 20.0); + let face = FaceOp::new(10.0, 6.0).depth(6.0).step_down(2.0).onion_skin(1.0).generate(&area).expect("onion"); + let plunge_z: Vec = plunges(face.toolpath()).into_iter().map(|point| point.z()).collect(); + assert_eq!(plunge_z, vec![-2.0, -4.0, -5.0, -6.0]); + check_onion_cuts(&onion_cut_heights(face.toolpath().moves()), -6.0, 1.0, 1e-6).expect("skin"); + let plain = FaceOp::new(10.0, 6.0).depth(6.0).step_down(2.0).generate(&area).expect("plain"); + let plain_z: Vec = plunges(plain.toolpath()).into_iter().map(|point| point.z()).collect(); + assert_eq!(plain_z, vec![-2.0, -4.0, -6.0]); + } + #[test] fn levels_follow_pass_depths() { let area = rectangle(0.0, 0.0, 30.0, 20.0); diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 187ce8a..2cd37be 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -23,7 +23,7 @@ mod tool; mod toolpath; pub use chamfer::{Chamfer, ChamferOp}; -pub use depth::{DepthEntry, DepthPlan, DepthTabs, depth_toolpath, pass_depths}; +pub use depth::{DepthEntry, DepthPlan, DepthTabs, check_onion_cuts, check_onion_levels, depth_toolpath, onion_cut_heights, pass_depths}; pub use drill::{DrillCycle, DrillHit, DrillOp, Drilling}; pub use engrave::{Engrave, EngraveOp}; pub use face::{Face, FaceOp}; diff --git a/docs/job-file.md b/docs/job-file.md index 66aed6c..c00a8b4 100644 --- a/docs/job-file.md +++ b/docs/job-file.md @@ -126,7 +126,8 @@ 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`. | -| `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. | +| `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. | | `stepover` | pocket, face, surface, rough and rotary finish | Positive step between passes, in `units`. At most the tool diameter on a pocket, face, surface or rough. Rejected on a kerf tool. A face that omits it uses 0.75 times the tool diameter. A pocket that omits it leaves the choice to the path generator (the tool radius). A surface requires exactly one of `stepover` and `scallop_height`. A rough that omits it uses 0.4 times the tool diameter. A waterline rejects `stepover`. On a rotary finish it is the arc length between angle passes at the largest radius, and it is not capped at the tool diameter. When `angle_step_deg` is also set, the run uses `stepover`. | | `scallop_height` | surface only | Positive length in `units`. Flat-floor cusp height used instead of `stepover`. See below. Rejected on every other kind. | @@ -162,6 +163,16 @@ unique within that operation. The same layer may appear on two operations. 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`. +`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. +Five millimetres at a two millimetre step with a one millimetre skin is 2, 4, then 5. Six +millimetres at a two millimetre step with a one millimetre skin is 2, 4, 5, then 6. The extra +pass counts toward the 1024-pass limit. `finish.step_down`, when set, must form a legal plan +with the same skin. Omitting the field leaves the depth plan unchanged. `campanion run` checks +that every earlier cut stays at least `onion_skin` above the floor and that the last pass +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. + ### Tool changes `campanion run` writes a tool-change event (`Event::ToolChange`) at the start of the first @@ -235,7 +246,7 @@ the tips are at −0.5 mm and −0.3 mm. `diameter` is the cylinder surface diameter, not the tool diameter. -`depth` is a positive distance below that surface. `step_down` is the maximum thickness of one pass, the same plan as a profile. A depth of 0.5 mm at a step of 0.25 mm is two passes. +`depth` is a positive distance below that surface. `step_down` is the maximum thickness of one pass, the same plan as a profile. A depth of 0.5 mm at a step of 0.25 mm is two passes. Optional `onion_skin` leaves that thickness until the last pass, which is the only pass at `depth`. The check uses those planned levels (`w = -level`). Flat frame, in millimetres after `load`: @@ -532,7 +543,7 @@ The safe radius is at least the swept radius plus `heights.clearance`, and at le `examples/rotary.job.json` finishes `examples/rotary.stl` with a 3 mm ball, zigzag, a 30° angle step and a 0.25 mm sample step, about axis A through the origin. The mesh is a vase revolved about X, 60 mm long and about 30 mm in diameter, with closed ends. `crates/mesh/tests/rotary_vase.rs` builds that STL. -A drill is expanded into moves: each hole is a rapid to clearance over the hole, a rapid to the job retract height (the R plane), a plunge feed to depth (pecking back to the R plane when `step_down` is less than `depth`), an optional `dwell`, and a rapid out to clearance. No canned cycle is written. `step_down` on a drill is the peck depth. Pocket strategy, leads and tabs are not part of this version. +A drill is expanded into moves: each hole is a rapid to clearance over the hole, a rapid to the job retract height (the R plane), a plunge feed to depth (pecking back to the R plane when `step_down` is less than `depth`), an optional `dwell`, and a rapid out to clearance. No canned cycle is written. `step_down` on a drill is the peck depth. Optional `onion_skin` stops every peck but the last short of the hole bottom by that thickness, and the drill pecks even when `step_down` is at least `depth`. Pocket strategy, leads and tabs are not part of this version. ### Timing @@ -1294,7 +1305,7 @@ Front clearance is Z `z_start + retract` at the hole radius. Moves from one fron | `stepover` | no | Z step as a fraction of the live-tool diameter, in `(0, 1]`. Default `0.6`. | | `rpm`, `feed` | yes | Same as `live_drill`. | -`live_mill` cuts a drawing with the same profile and pocket operations a mill job uses (`ProfileOp` and `PocketOp`). It does not invent a second offsetter. Lengths in the drawing are millimetres. A DXF is millimetres when `$INSUNITS` is millimetres or unitless. An SVG is converted to millimetres; a unitless SVG is 96 dpi. `drawing` is a `.dxf` or `.svg` path relative to the job file, with the same path rules as `source`. Optional `layer` keeps one layer, compared as a string. `strategy` is `profile` or `pocket`. A profile needs `side` (`outside`, `inside` or `on`). A pocket rejects `side` and takes `stepover`, a fraction of the live-tool diameter in `(0, 1]`, default `0.4`. `depth` is positive. `step_down` is the maximum thickness of one pass and defaults to `depth`. `rpm` and `feed` match `live_drill`. +`live_mill` cuts a drawing with the same profile and pocket operations a mill job uses (`ProfileOp` and `PocketOp`). It does not invent a second offsetter. Lengths in the drawing are millimetres. A DXF is millimetres when `$INSUNITS` is millimetres or unitless. An SVG is converted to millimetres; a unitless SVG is 96 dpi. `drawing` is a `.dxf` or `.svg` path relative to the job file, with the same path rules as `source`. Optional `layer` keeps one layer, compared as a string. `strategy` is `profile` or `pocket`. A profile needs `side` (`outside`, `inside` or `on`). A pocket rejects `side` and takes `stepover`, a fraction of the live-tool diameter in `(0, 1]`, default `0.4`. `depth` is positive. `step_down` is the maximum thickness of one pass and defaults to `depth`. Optional `onion_skin` is a positive length strictly less than `depth`. Earlier passes stop that far short of `depth`, and only the last pass reaches it. The check uses those planned levels, not the machine pose after the frame mapping. Omitting it leaves the depth plan unchanged. `rpm` and `feed` match `live_drill`. This output has not been proven on a machine. The toolpath is built in the drawing plane. Its Z is 0 on the surface and negative into the part, and the job `clearance` is the positive retract height. Rapids from one feature to the next stay at that clearance. @@ -1313,6 +1324,7 @@ A pocket, and a profile with `side` `inside`, need a closed contour. None is `op | `side` | profile | `outside`, `inside` or `on`. Rejected on a pocket. | | `stepover` | pocket | Fraction of the cutter diameter, in `(0, 1]`. Default `0.4`. Rejected on a profile. | | `step_down` | no | Maximum pass thickness. Default `depth`. | +| `onion_skin` | no | Positive length strictly less than `depth`. Earlier passes stop short by this thickness. Only the last pass reaches `depth`. Omitted, the depth plan is unchanged. | | `z_start` | front | Z of the face. Rejected on an OD mill. | | `y_axis` | no | Front only. Default false. Direct X/Y at the indexed C, with no face interpolation. | | `c` | no | Front `y_axis` only. Index angle in degrees. Default `0`. | diff --git a/docs/processes-plan.md b/docs/processes-plan.md index a82e023..6e3b27b 100644 --- a/docs/processes-plan.md +++ b/docs/processes-plan.md @@ -41,7 +41,8 @@ and so do `linuxcnc`, `fanuc` and `haas`. 1. **IR.** No new process kind, event or axis. The diameter tool gains an optional cutter sense: upcut, downcut or compression. A compression tool also names the upcut length at the tip. Omitted, the offset stays the flat diameter offset used today. Onion-skin is an operation - thickness: earlier passes stop short of the sheet, and the last pass cuts the skin. Nesting + thickness: earlier passes stop short of the sheet, and the last pass cuts the skin. The job + field is `onion_skin` on a profile, pocket, face, engrave, drill, wrap or live mill. Nesting and hold-down are setup data: a sheet rectangle, part spacing, and keep-out rectangles for clamps. The IR still stores ordinary toolpaths. Tool-length probing uses the existing `Probe` event (expected contact and feed). Tabs stay the profile tabs already in the job file: bridge @@ -53,7 +54,8 @@ and so do `linuxcnc`, `fanuc` and `haas`. 3. **Simulation.** The mill verifier already checks gouges, low rapids and tabs. Nesting checks that every part lies on the sheet, that parts clear each other and the keep-outs by the tool radius plus the stated gap, and that the same part list always packs the same way. Onion-skin - checks that only the last pass reaches full depth. The probe move is a feed toward the named + checks that only the last pass reaches full depth: every earlier cut stays at least + `onion_skin` above the floor. The probe move is a feed toward the named contact, with the spindle off. In-process stock stays unmodelled: the sheet top is Z 0, the same rapid rule as a drawing. This process deposits nothing, so the deposited-material model stays unused. @@ -279,8 +281,8 @@ in `docs/plan.md`). ### 1. Router gaps Mill operations, tabs, leads, `grbl`, `mach3` and the probe event are already shipped. This step -finishes that gantry path: cutter-sense data, a deterministic sheet packer, onion-skin passes and -a LinuxCNC router dialect. Containment, spacing, skin height and tab bridges are geometry tests +finishes that gantry path: cutter-sense data, a deterministic sheet packer, onion-skin passes +(`onion_skin`) and a LinuxCNC router dialect. Containment, spacing, skin height and tab bridges are geometry tests and need no machine. The risk is the probe cycle, which read-back against a cited manual covers. ### 2. Laser raster