diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5e153..470abd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 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. - `linuxcnc_router` is a millimetre LinuxCNC gantry post: the same RS274 motion as `linuxcnc`, with no rotary axis. A Probe event with a contact pose and a feed is `G38.2` (LinuxCNC straight probe; ). `vacuum` and `dust` are immediate digital outputs `M64`/`M65` (); pin numbers default to 0 and 1. Any other Other name is refused. Existing posts still fall back to `process.other`. This output has not been proven on a machine. +- Mill jobs may set `sheet` (width and height in the XY plane, origin at (0, 0)) and `nest` (gap, margin, clamp keep-outs and parts). When both are set, `campanion run` packs each part's axis-aligned bounds onto the sheet before building toolpaths. Parts are placed in input order. The first feasible pose wins: ascending Y, then ascending X, then rotation index (0°, then 90° when the step is 90°). The same parts always pack the same way. The packer is not a yield optimiser. It packs rectangles; contour packing can follow. A part lies inside the sheet inset by the margin, clears other parts by the gap, and clears each keep-out by the tool radius plus the gap. A part that does not fit, or that fails a check, exits 1 and nothing is written. Placements are printed on stderr. `--nest-out FILE.json` writes `campanion-nest/1` after the program is accepted. The IR stays ordinary toolpaths in the sheet frame. This output has not been proven on a machine. ## 0.9.3 — 2026-11-03 diff --git a/README.md b/README.md index 3b054d6..533909a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ cargo run -p campanion-cli -- run examples/sample.job.json -o out.ngc --ir out.j - `examples/sample.job.json` describes the source drawing (`examples/sample.dxf`), tools, heights and operations (inside/outside profiles by layer). +- `examples/nest.job.json` packs two 20 mm squares from `examples/nest.dxf` onto a 50 by 30 mm sheet. See [Sheet nesting](docs/job-file.md#sheet-nesting). - `-o out.ngc` writes reference G-code; `--ir out.json` writes the machine-neutral toolpath. Pass `--post` to write a controller dialect instead of the test sink. See [Posts](#posts). - The toolpath is verified before anything is written; errors stop the run. @@ -96,6 +97,7 @@ that post. The G-code is read back and compared to the toolpath at the job chord ```sh campanion run examples/sample.job.json -o out.ngc --post linuxcnc +campanion run examples/nest.job.json -o out.ngc --nest-out placements.json campanion run examples/sample.job.json -o out.ngc --ir out.json --post path/to/my-post.json campanion post out.json --post grbl -o out.nc campanion post out.json --post grbl diff --git a/crates/cli/src/jobfile.rs b/crates/cli/src/jobfile.rs index 7afb064..9e153f6 100644 --- a/crates/cli/src/jobfile.rs +++ b/crates/cli/src/jobfile.rs @@ -121,6 +121,10 @@ //! A diameter tool may set `cutter_sense`: `"upcut"`, `"downcut"`, or `"compression"`. //! `"compression"` requires `upcut_length`, a positive length in `units` at the tip. Omitted, //! compensation stays the flat diameter offset. Sense does not change that offset. +//! Optional `sheet` is the router sheet in the XY plane, origin at (0, 0), size `width` by +//! `height`. Optional `nest` packs rectangles on that sheet: a gap, a margin, clamp keep-outs, +//! and parts. A part is an explicit width and height, the contours of named operations, or the +//! bounds of a DXF or SVG. Absent `sheet` and `nest`, a job is unchanged. See `docs/job-file.md`. use std::fmt; use std::fs::File; @@ -181,6 +185,8 @@ const MAX_OPERATIONS: usize = 1_024; const MAX_LAYERS: usize = 64; const MAX_SOURCE_PATH: usize = 4_096; const MAX_FIXTURES: usize = 64; +const MAX_NEST_PARTS: usize = 256; +const MAX_KEEP_OUTS: usize = 64; const MAX_HOLDER_SEGMENTS: usize = 32; const DEFAULT_COLLISION_SAMPLE_MM: f64 = 1.0; const DEFAULT_COLLISION_ANGLE_DEG: f64 = 1.0; @@ -461,6 +467,140 @@ impl StockModelOptions { } } +/// Router sheet in the XY plane, millimetres. The origin is (0, 0). The sheet is `[0, width]` by `[0, height]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sheet { + width: f64, + height: f64, +} + +impl Sheet { + /// Sheet size along X, millimetres. + pub fn width(self) -> f64 { + self.width + } + + /// Sheet size along Y, millimetres. + pub fn height(self) -> f64 { + self.height + } +} + +/// How a nest part's rectangle is measured. The packer uses the axis-aligned bounds, not the contour. +#[derive(Clone, Debug, PartialEq)] +pub enum NestPartGeom { + /// Rectangle `[0, width]` by `[0, height]` in the part frame. Nothing is cut for this part. + Rectangle { + /// Width along X before rotation, millimetres. + width: f64, + /// Height along Y before rotation, millimetres. + height: f64, + }, + /// Union of these operations' layer contours on the job drawing. Those operations are moved onto the sheet. + Operations { + /// Operation names, in file order. + names: Vec, + }, + /// Bounds of a DXF or SVG. `layers` is `None` for every layer. This part reserves space and is not cut. + Source { + /// Path relative to the job file. + path: String, + /// FNV-1a 64 of the drawing bytes. + hash: String, + /// Layers to measure. `None` measures every contour. + layers: Option>, + }, +} + +/// One rectangle on the sheet. +#[derive(Clone, Debug, PartialEq)] +pub struct NestPart { + name: String, + geom: NestPartGeom, + /// Degrees. 90 tries 0° and 90°. 0 and 180 try 0° only. + rotation_step_deg: f64, +} + +impl NestPart { + pub fn name(&self) -> &str { + &self.name + } + + pub fn geom(&self) -> &NestPartGeom { + &self.geom + } + + /// File value. The packer turns it into [`crate::nest::rotation_angles`]. + pub fn rotation_step_deg(&self) -> f64 { + self.rotation_step_deg + } +} + +/// Clamp rectangle in the sheet frame, millimetres. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NestKeepOut { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, +} + +impl NestKeepOut { + pub fn min_x(self) -> f64 { + self.min_x + } + + pub fn min_y(self) -> f64 { + self.min_y + } + + pub fn max_x(self) -> f64 { + self.max_x + } + + pub fn max_y(self) -> f64 { + self.max_y + } +} + +/// Part spacing and clamp rectangles for one sheet. +/// +/// Lengths are millimetres. `tool_radius` is `None` when the file omits it; the run then uses +/// half the largest tool diameter or kerf. +#[derive(Clone, Debug, PartialEq)] +pub struct Nest { + gap: f64, + margin: f64, + tool_radius: Option, + keep_out: Vec, + parts: Vec, +} + +impl Nest { + /// Minimum space between part rectangles, millimetres. + pub fn gap(&self) -> f64 { + self.gap + } + + /// Inset from the sheet edge, millimetres. + pub fn margin(&self) -> f64 { + self.margin + } + + /// Explicit cutter radius for keep-out clearance. `None` selects the largest tool. + pub fn tool_radius(&self) -> Option { + self.tool_radius + } + + pub fn keep_outs(&self) -> &[NestKeepOut] { + &self.keep_out + } + + pub fn parts(&self) -> &[NestPart] { + &self.parts + } +} + /// Job-level collision sampling. Lengths are millimetres, the angle is degrees. #[derive(Clone, Copy, Debug, PartialEq)] pub struct CollisionSettings { @@ -1110,6 +1250,8 @@ pub struct JobFile { collision: CollisionSettings, stock: Option, stock_model: StockModelOptions, + sheet: Option, + nest: Option, } impl JobFile { @@ -1201,6 +1343,16 @@ impl JobFile { pub fn has_assembly(&self) -> bool { self.tools.iter().any(|tool| tool.assembly.is_some()) } + + /// Router sheet. `None` when the file omits `sheet`. + pub fn sheet(&self) -> Option { + self.sheet + } + + /// Nesting setup. `None` when the file omits `nest`. Present only together with [`Self::sheet`]. + pub fn nest(&self) -> Option<&Nest> { + self.nest.as_ref() + } } struct Body { @@ -1217,6 +1369,8 @@ struct Body { collision: CollisionSettings, stock: Option, stock_model: StockModelOptions, + sheet: Option, + nest: Option, } /// Read a `campanion-job/1` file. Lengths in the result are millimetres. @@ -1238,6 +1392,8 @@ pub fn load(path: &Path) -> Result { collision: body.collision, stock: body.stock, stock_model: body.stock_model, + sheet: body.sheet, + nest: body.nest, }) } @@ -1325,6 +1481,8 @@ fn parse_text(text: &str, job_path: &Path) -> Result { "collision", "stock", "stock_model", + "sheet", + "nest", ], "job", ) @@ -1384,8 +1542,38 @@ fn parse_text(text: &str, job_path: &Path) -> Result { let collision = parse_collision(optional(map, "collision"), scale)?; let stock = parse_stock(optional(map, "stock"), scale)?; let stock_model = parse_stock_model(optional(map, "stock_model"), scale)?; + let sheet = parse_sheet(optional(map, "sheet"), scale)?; + let nest = parse_nest(optional(map, "nest"), scale, &operations)?; + if nest.is_some() && sheet.is_none() { + return Err(JobFileError::invalid("nest needs a sheet")); + } + if nest.is_some() && rotary.is_some() { + return Err(JobFileError::invalid("nest cannot be combined with rotary")); + } + if nest.is_some() && kinematics.is_some() { + return Err(JobFileError::invalid("nest cannot be combined with kinematics")); + } + if nest.is_some() && source.is_stl() { + return Err(JobFileError::invalid("nest is not valid on an STL source")); + } let five_axis = kinematics.map(|kinematics| FiveAxisSetup { kinematics, policy }); - Ok(Body { source, units, tolerance, tools, operations, clearance, retract, rotary, five_axis, fixtures, collision, stock, stock_model }) + Ok(Body { + source, + units, + tolerance, + tools, + operations, + clearance, + retract, + rotary, + five_axis, + fixtures, + collision, + stock, + stock_model, + sheet, + nest, + }) } /// An STL part only accepts `surface`, `rough`, `waterline` and `rotary_finish`. Each only accepts an STL part. @@ -1870,6 +2058,177 @@ fn parse_stock_model(value: Option<&Value>, scale: f64) -> Result, scale: f64) -> Result, JobFileError> { + let Some(value) = value else { + return Ok(None); + }; + let map = object(value, "sheet").map_err(from_cli)?; + check_keys(map, &["width", "height"], "sheet").map_err(from_cli)?; + let width = positive_length(require(map, "width").map_err(from_cli)?, scale, "sheet width")?; + let height = positive_length(require(map, "height").map_err(from_cli)?, scale, "sheet height")?; + Ok(Some(Sheet { width, height })) +} + +fn parse_nest(value: Option<&Value>, scale: f64, operations: &[Operation]) -> Result, JobFileError> { + let Some(value) = value else { + return Ok(None); + }; + let map = object(value, "nest").map_err(from_cli)?; + check_keys(map, &["gap", "margin", "tool_radius", "keep_out", "parts"], "nest").map_err(from_cli)?; + let gap = match optional(map, "gap") { + Some(value) => cap_under(non_negative_length(value, scale, "nest gap")?, MAX_MM, "nest gap")?, + None => 0.0, + }; + let margin = match optional(map, "margin") { + Some(value) => cap_under(non_negative_length(value, scale, "nest margin")?, MAX_MM, "nest margin")?, + None => 0.0, + }; + let tool_radius = match optional(map, "tool_radius") { + Some(value) => Some(cap_under(non_negative_length(value, scale, "nest tool_radius")?, MAX_MM, "nest tool_radius")?), + None => None, + }; + let keep_out = parse_keep_outs(optional(map, "keep_out"), scale)?; + let parts = parse_nest_parts(optional(map, "parts"), scale, operations)?; + Ok(Some(Nest { gap, margin, tool_radius, keep_out, parts })) +} + +fn parse_keep_outs(value: Option<&Value>, scale: f64) -> Result, JobFileError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let list = array(value, "keep_out", MAX_KEEP_OUTS)?; + let mut keep_out = Vec::with_capacity(list.len()); + for (index, item) in list.iter().enumerate() { + let rect = parse_keep_out(item, scale).map_err(|err| at_index("keep-out", index, err))?; + keep_out.push(rect); + } + Ok(keep_out) +} + +fn parse_keep_out(value: &Value, scale: f64) -> Result { + let map = object(value, "keep-out").map_err(from_cli)?; + check_keys(map, &["min", "max"], "keep-out").map_err(from_cli)?; + let (min_x, min_y) = parse_xy(require(map, "min").map_err(from_cli)?, scale, "keep-out min")?; + let (max_x, max_y) = parse_xy(require(map, "max").map_err(from_cli)?, scale, "keep-out max")?; + if !(max_x > min_x && max_y > min_y) { + return Err(JobFileError::invalid("keep-out max must be greater than min")); + } + Ok(NestKeepOut { min_x, min_y, max_x, max_y }) +} + +fn parse_xy(value: &Value, scale: f64, what: &str) -> Result<(f64, f64), JobFileError> { + let list = array(value, what, 2)?; + if list.len() != 2 { + return Err(JobFileError::invalid(format!("{what} must be exactly 2 numbers"))); + } + let Some(x_value) = list.first() else { + return Err(JobFileError::invalid(format!("{what} must be exactly 2 numbers"))); + }; + let Some(y_value) = list.get(1) else { + return Err(JobFileError::invalid(format!("{what} must be exactly 2 numbers"))); + }; + let x = finite_height(scale_f64(x_value, scale, what)?, what)?; + let y = finite_height(scale_f64(y_value, scale, what)?, what)?; + Ok((x, y)) +} + +fn parse_nest_parts(value: Option<&Value>, scale: f64, operations: &[Operation]) -> Result, JobFileError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let list = array(value, "nest parts", MAX_NEST_PARTS)?; + let mut parts = Vec::with_capacity(list.len()); + let mut used_operations: Vec = Vec::new(); + for (index, item) in list.iter().enumerate() { + let part = parse_nest_part(item, scale, operations, &mut used_operations).map_err(|err| at_index("nest part", index, err))?; + if parts.iter().any(|existing: &NestPart| existing.name == part.name) { + return Err(JobFileError::invalid(format!("nest part name `{}` is repeated", part.name))); + } + parts.push(part); + } + Ok(parts) +} + +fn parse_nest_part(value: &Value, scale: f64, operations: &[Operation], used_operations: &mut Vec) -> Result { + let map = object(value, "nest part").map_err(from_cli)?; + check_keys(map, &["name", "source", "hash", "layers", "operations", "width", "height", "rotation_step_deg"], "nest part").map_err(from_cli)?; + let name = + required_name(as_str(require(map, "name").map_err(from_cli)?, "nest part name").map_err(from_cli)?, "nest part name").map_err(from_cli)?; + let rotation_step_deg = parse_rotation_step(optional(map, "rotation_step_deg"))?; + let has_size = map.contains_key("width") || map.contains_key("height"); + let has_operations = map.contains_key("operations"); + let has_source = map.contains_key("source") || map.contains_key("hash"); + let modes = usize::from(has_size) + usize::from(has_operations) + usize::from(has_source); + if modes != 1 { + return Err(JobFileError::invalid(format!("nest part `{name}` needs a source, operations, or width and height"))); + } + if map.contains_key("layers") && !has_source { + return Err(JobFileError::invalid(format!("nest part `{name}`: layers need a source"))); + } + let geom = if has_size { + let width = positive_length(require(map, "width").map_err(from_cli)?, scale, "nest part width")?; + let height = positive_length(require(map, "height").map_err(from_cli)?, scale, "nest part height")?; + NestPartGeom::Rectangle { width, height } + } else if has_operations { + let names = parse_name_list(require(map, "operations").map_err(from_cli)?, "operations")?; + for op_name in &names { + let Some(operation) = operations.iter().find(|operation| operation.name() == op_name) else { + return Err(JobFileError::invalid(format!("nest part `{name}`: unknown operation `{op_name}`"))); + }; + if is_mesh_op(operation.kind()) { + return Err(JobFileError::invalid(format!("nest part `{name}`: operation `{op_name}` has no layer geometry"))); + } + if used_operations.iter().any(|existing| existing == op_name) { + return Err(JobFileError::invalid(format!("nest part `{name}`: operation `{op_name}` is already nested"))); + } + used_operations.push(op_name.clone()); + } + NestPartGeom::Operations { names } + } else { + let path_value = require(map, "source").map_err(from_cli)?; + let Some(path) = path_value.as_str() else { + return Err(JobFileError::invalid(format!("nest part `{name}`: source must be a string"))); + }; + check_source_path(path)?; + let hash_text = as_str(require(map, "hash").map_err(from_cli)?, "nest part hash").map_err(from_cli)?; + let layers = match optional(map, "layers") { + Some(value) => Some(parse_name_list(value, "layers")?), + None => None, + }; + NestPartGeom::Source { path: path.to_string(), hash: normalize_hash(hash_text)?, layers } + }; + Ok(NestPart { name, geom, rotation_step_deg }) +} + +fn parse_rotation_step(value: Option<&Value>) -> Result { + let Some(value) = value else { + return Ok(90.0); + }; + let degrees = as_f64(value, "rotation_step_deg").map_err(from_cli)?; + if degrees == 0.0 || degrees == 90.0 || degrees == 180.0 { + Ok(degrees) + } else { + Err(JobFileError::invalid("rotation_step_deg must be 0, 90 or 180")) + } +} + +fn parse_name_list(value: &Value, what: &str) -> Result, JobFileError> { + let list = array(value, what, MAX_LAYERS)?; + if list.is_empty() { + return Err(JobFileError::invalid(format!("{what} must not be empty"))); + } + let mut names = Vec::with_capacity(list.len()); + for item in list { + let text = as_str(item, what).map_err(from_cli)?; + let name = required_name(text, what).map_err(from_cli)?; + if names.iter().any(|existing| existing == &name) { + return Err(JobFileError::invalid(format!("{what} `{name}` is repeated"))); + } + names.push(name); + } + Ok(names) +} + fn parse_operations(value: &Value, scale: f64, tools: &[JobTool]) -> Result, JobFileError> { let list = array(value, "operations", MAX_OPERATIONS)?; let mut operations: Vec = Vec::with_capacity(list.len()); @@ -6732,4 +7091,152 @@ mod tests { assert!(err.to_string().contains(needle), "{needle}: {err}"); } } + + fn nest_job(units: &str, extra: &str) -> String { + format!( + r#"{{"format":"campanion-job/1","source":{{"path":"plate.dxf","hash":"{EMPTY_HASH}"}},"units":"{units}","tools":[{{"id":1,"diameter":6}}],"operations":[{{"name":"mark","type":"engrave","layers":["L"],"tool_id":1,"depth":1,"step_down":1,"feeds":{{"cut":1}}}}]{extra}}}"# + ) + } + + #[test] + fn sheet_and_nest_round_trip() { + let nest = r#"{"gap":0.1,"margin":0.2,"tool_radius":0.05,"keep_out":[{"min":[1,0],"max":[2,1]}],"parts":[{"name":"pad","width":0.5,"height":0.25},{"name":"mark","operations":["mark"],"rotation_step_deg":0}]}"#; + let job = load(&write_job("nest-inch", &nest_job("inch", &format!(r#","sheet":{{"width":1,"height":2}},"nest":{nest}"#)))).expect("load"); + let sheet = job.sheet().expect("sheet"); + near(sheet.width(), 25.4); + near(sheet.height(), 50.8); + let nest = job.nest().expect("nest"); + near(nest.gap(), 2.54); + near(nest.margin(), 5.08); + near(nest.tool_radius().expect("radius"), 1.27); + let clamp = nest.keep_outs()[0]; + near(clamp.min_x(), 25.4); + near(clamp.min_y(), 0.0); + near(clamp.max_x(), 50.8); + near(clamp.max_y(), 25.4); + assert_eq!(nest.parts()[0].name(), "pad"); + match nest.parts()[0].geom() { + NestPartGeom::Rectangle { width, height } => { + near(*width, 12.7); + near(*height, 6.35); + } + other => panic!("{other:?}"), + } + near(nest.parts()[0].rotation_step_deg(), 90.0); + assert_eq!(nest.parts()[1].name(), "mark"); + match nest.parts()[1].geom() { + NestPartGeom::Operations { names } => assert_eq!(names.as_slice(), ["mark".to_string()]), + other => panic!("{other:?}"), + } + near(nest.parts()[1].rotation_step_deg(), 0.0); + + let defaults = nest_job( + "mm", + r#","sheet":{"width":40,"height":20},"nest":{"parts":[{"name":"src","source":"other.dxf","hash":"ABCDEF0123456789","layers":["L"]}]}"#, + ); + let job = load(&write_job("nest-defaults", &defaults)).expect("defaults"); + let nest = job.nest().expect("nest"); + near(nest.gap(), 0.0); + near(nest.margin(), 0.0); + assert!(nest.tool_radius().is_none()); + assert!(nest.keep_outs().is_empty()); + match &nest.parts()[0].geom() { + NestPartGeom::Source { path, hash, layers } => { + assert_eq!(path, "other.dxf"); + assert_eq!(hash, "abcdef0123456789"); + assert_eq!(layers.as_deref(), Some(["L".to_string()].as_slice())); + } + other => panic!("{other:?}"), + } + near(nest.parts()[0].rotation_step_deg(), 90.0); + + let sheet_only = format!( + r#"{{"format":"campanion-job/1","source":{{"path":"plate.dxf","hash":"{EMPTY_HASH}"}},"units":"mm","tools":[],"operations":[],"sheet":{{"width":10,"height":20}}}}"# + ); + let job = load(&write_job("sheet-only", &sheet_only)).expect("sheet"); + near(job.sheet().expect("sheet").width(), 10.0); + near(job.sheet().expect("sheet").height(), 20.0); + assert!(job.nest().is_none()); + } + + #[test] + fn sheet_and_nest_errors_name_the_failure() { + let cases = [ + (nest_job("mm", r#","sheet":{"width":10,"height":10,"pad":1}"#), "unknown sheet field `pad`"), + (nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"gap":0,"pad":1}"#), "unknown nest field `pad`"), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","width":1,"height":1,"pad":1}]}"#), + "unknown nest part field `pad`", + ), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"keep_out":[{"min":[0,0],"max":[1,1],"pad":1}]}"#), + "unknown keep-out field `pad`", + ), + (nest_job("mm", r#","nest":{"parts":[{"name":"a","width":1,"height":1}]}"#), "nest needs a sheet"), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","width":1,"height":1,"rotation_step_deg":45}]}"#), + "rotation_step_deg must be 0, 90 or 180", + ), + (nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"gap":-1}"#), "nest gap must be at least 0"), + (nest_job("mm", r#","sheet":{"width":0,"height":10}"#), "sheet width must be greater than 0"), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"keep_out":[{"min":[1,0],"max":[1,1]}]}"#), + "keep-out max must be greater than min", + ), + (nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"tool_radius":-1}"#), "nest tool_radius must be at least 0"), + ( + nest_job( + "mm", + r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","width":1,"height":1},{"name":"a","width":1,"height":1}]}"#, + ), + "nest part name `a` is repeated", + ), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","operations":["missing"]}]}"#), + "unknown operation `missing`", + ), + ( + nest_job( + "mm", + r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","operations":["mark"]},{"name":"b","operations":["mark"]}]}"#, + ), + "operation `mark` is already nested", + ), + (nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","width":1}]}"#), "missing `height`"), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","width":1,"height":1,"layers":["L"]}]}"#), + "layers need a source", + ), + ( + nest_job("mm", r#","sheet":{"width":10,"height":10},"nest":{"parts":[{"name":"a","width":1,"height":1,"operations":["mark"]}]}"#), + "needs a source, operations, or width and height", + ), + ( + format!( + r#"{{"format":"campanion-job/1","source":{{"path":"plate.dxf","hash":"{EMPTY_HASH}"}},"units":"mm","rotary":{{"axis":"a","center":[0,0,0],"direction":"positive"}},"tools":[{{"id":1,"diameter":6}}],"operations":[{{"name":"mark","type":"engrave","layers":["L"],"tool_id":1,"depth":1,"step_down":1,"feeds":{{"cut":1}}}}],"sheet":{{"width":40,"height":40}},"nest":{{"parts":[{{"name":"a","operations":["mark"]}}]}}}}"# + ), + "nest cannot be combined with rotary", + ), + ( + format!( + r#"{{"format":"campanion-job/1","source":{{"path":"dome.stl","hash":"{EMPTY_HASH}"}},"units":"mm","tools":[{{"id":1,"diameter":6}}],"operations":[{{"name":"cap","type":"surface","tool_id":1,"stepover":2,"feeds":{{"cut":1}}}}],"sheet":{{"width":40,"height":40}},"nest":{{"parts":[{{"name":"a","width":10,"height":10}}]}}}}"# + ), + "nest is not valid on an STL source", + ), + ]; + for (index, (text, needle)) in cases.iter().enumerate() { + let err = load(&write_job(&format!("nest-bad-{index}"), text)).expect_err(needle); + assert!(invalid_detail(&err).contains(needle), "{needle}: {err}"); + } + + let kin = example_kinematics(); + let text = nest_job( + "mm", + r#","kinematics":"table_table_bc.kin.json","sheet":{"width":40,"height":40},"nest":{"parts":[{"name":"a","width":10,"height":10}]}"#, + ); + let path = write_job("nest-kin", &text); + write_beside(&path, "table_table_bc.kin.json", &kin); + let err = load(&path).expect_err("kinematics"); + assert!(invalid_detail(&err).contains("nest cannot be combined with kinematics"), "{err}"); + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1848c57..4115b48 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -18,6 +18,7 @@ mod job; mod lathe; mod live_mill; mod machine; +mod nest; mod plane; // `run` reads the file and does not rewrite the source hash. Tests cover the rest of the API. #[allow(dead_code)] @@ -49,7 +50,7 @@ const HELP: &str = "\ Usage: campanion ir [--job FILE] campanion ngc [--job FILE] - campanion run JOB -o OUT [--ir OUT.json] [--post ID|FILE.json] [--up-axis z|y|auto] [--stock-report FILE.csv] [--stock-out FILE.json] [--stock-snapshots N] + campanion run JOB -o OUT [--ir OUT.json] [--post ID|FILE.json] [--up-axis z|y|auto] [--stock-report FILE.csv] [--stock-out FILE.json] [--stock-snapshots N] [--nest-out FILE.json] campanion lathe JOB -o OUT [--ir OUT.json] [--post ID|FILE.json] [--up-axis z|y|auto] campanion post IR.json --post ID|FILE.json [-o OUT] campanion posts @@ -76,6 +77,11 @@ run Import the DXF, SVG, STEP or STL (by extension) named by a campanion-job also stores a map at the start and after every Nth motion; it needs --stock-out. Those files are written when the replay succeeds. They must differ from -o and --ir. The flags are an error when the stock model is off. See docs/job-file.md. + When the job sets sheet and nest, each part is packed on the sheet before the toolpaths + are built. Placements are printed on stderr. --nest-out FILE.json writes them after the + program is accepted. That file must differ from -o, --ir and the stock outputs. + A part that does not fit, or that fails a clearance check, exits 1 and nothing is written. + See docs/job-file.md. Verify errors are printed and the status is 1. Warnings stay on stderr. An STL job checks each cutting move against the mesh with that operation's cutter. A gouge is a verify error and the status is 1. diff --git a/crates/cli/src/nest.rs b/crates/cli/src/nest.rs new file mode 100644 index 0000000..1e0ea91 --- /dev/null +++ b/crates/cli/src/nest.rs @@ -0,0 +1,963 @@ +//! Deterministic sheet packer for a router job. +//! +//! Each part is an axis-aligned rectangle: the bounds of named operations, the bounds of a +//! DXF or SVG, or an explicit width and height. The packer does not look at the contour, and +//! it makes no claim of optimal yield. A later contour packer can replace the rectangle. +//! +//! Parts are placed in input order. For each part the candidate translations are the sheet +//! inset (`margin`) and the far side of every part already placed and of every keep-out. +//! That is exact bottom-left. A uniform grid of `max(1 mm, gap)` is not used, because a part +//! whose size is not a multiple of that step can fit and still miss every grid point. +//! +//! The first feasible candidate wins. Order is ascending Y, then ascending X, then ascending +//! rotation index. Index 0 is 0°. Index 1 is 90° when `rotation_step_deg` is 90. A step of +//! 0° or 180° tries 0° only: a rectangle's axis-aligned bounds repeat every 180°. +//! +//! Clearance, measured between the rectangles: +//! - A part lies inside the sheet inset by `margin`. The tool radius is not added to the margin. +//! - Two parts must not overlap once each rectangle is expanded by `gap / 2`. The gap between +//! them is at least `gap` along X or along Y. The tool radius is not added again; put it in +//! `gap` when two cuts face each other. +//! - A part clears each keep-out by `tool_radius + gap` along X or along Y. `tool_radius` is +//! `nest.tool_radius` when the file sets it, otherwise half the largest tool diameter or kerf. +//! No tools means 0. +//! +//! A fit that is short by at most 1e-6 mm counts as touching. The same input produces the same +//! placements. The IR is unchanged: `campanion run` applies the placement as a transform while +//! it builds the paths. + +use std::cmp::Ordering; +use std::path::Path; + +use campanion_geom::{Arc, BoundingBox, Contour, Line, Point, Segment, Tolerance, cmp_float_key}; +use campanion_import::{Drawing, ImportOptions, import_dxf, import_svg}; + +use crate::error::CliError; +use crate::jobfile::{self, JobFile, Nest, NestPartGeom, Sheet}; + +/// A shortfall of this size, in millimetres, still counts as touching. +const FIT_EPS_MM: f64 = 1e-6; +const MAX_PACK_PARTS: usize = 256; +const MAX_PACK_KEEPOUTS: usize = 64; +const MAX_PACK_MM: f64 = 1.0e6; + +/// Packer or clearance failure. The text is the run error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NestError(String); + +impl std::fmt::Display for NestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Axis-aligned rectangle in the sheet frame, millimetres. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PackRect { + pub(crate) min_x: f64, + pub(crate) min_y: f64, + pub(crate) max_x: f64, + pub(crate) max_y: f64, +} + +/// One rectangle to place. `rotations_deg` is tried in order; the index is the tie-break. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PackPart { + pub(crate) name: String, + pub(crate) width: f64, + pub(crate) height: f64, + pub(crate) rotations_deg: Vec, +} + +/// Sheet, spacing and the parts in input order. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PackInput { + pub(crate) sheet_width: f64, + pub(crate) sheet_height: f64, + pub(crate) gap: f64, + pub(crate) margin: f64, + pub(crate) tool_radius: f64, + pub(crate) keep_out: Vec, + pub(crate) parts: Vec, +} + +/// Where a part's rectangle sits on the sheet. `x` and `y` are the minimum corner. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Placement { + pub(crate) name: String, + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) rotation_deg: f64, + pub(crate) width: f64, + pub(crate) height: f64, +} + +/// Rigid move from a part's drawing frame into the sheet frame. +/// +/// Rotation is about the part AABB centre, counter-clockwise, then `translate` is added. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PartFrame { + center_x: f64, + center_y: f64, + rotation_deg: f64, + translate_x: f64, + translate_y: f64, +} + +/// Contours of one operation, already limited to that operation's layers. +pub(crate) struct OpGeometry<'a> { + pub(crate) name: &'a str, + pub(crate) contours: &'a [Contour], +} + +/// Placements, the stderr lines, and the `campanion-nest/1` JSON. +pub(crate) struct Report { + frames: Vec<(String, PartFrame)>, + pub(crate) summary: String, + pub(crate) json: String, +} + +impl Report { + /// Transform for an operation that belongs to a placed part. `None` when the operation is not nested. + pub(crate) fn frame(&self, operation: &str) -> Option<&PartFrame> { + self.frames.iter().find(|(name, _)| name == operation).map(|(_, frame)| frame) + } +} + +/// Angles the packer tries, in rotation-index order. +/// +/// 90° tries 0° then 90°. 0° and 180° try 0° only. +pub(crate) fn rotation_angles(step_deg: f64) -> Vec { + if (step_deg - 90.0).abs() <= 1e-9 { vec![0.0, 90.0] } else { vec![0.0] } +} + +/// `explicit` wins. Otherwise the largest radius, or 0 when there are no tools. +pub(crate) fn choose_tool_radius(explicit: Option, radii: impl IntoIterator) -> f64 { + if let Some(radius) = explicit { + return radius; + } + let mut best = 0.0; + for radius in radii { + if radius > best { + best = radius; + } + } + best +} + +/// Place every part. The same input returns the same placements, or the same error. +pub(crate) fn pack(input: &PackInput) -> Result, NestError> { + validate(input)?; + let mut placed = Vec::with_capacity(input.parts.len()); + for part in &input.parts { + let Some(pose) = first_pose(input, &placed, part) else { + return Err(NestError(format!( + "nest: part `{}` does not fit on the sheet ({} x {} mm)", + part.name, input.sheet_width, input.sheet_height + ))); + }; + placed.push(pose); + } + check_placements(input, &placed)?; + Ok(placed) +} + +/// Sheet inset, part gap and keep-out clearance. The first failure is the error. +pub(crate) fn check_placements(input: &PackInput, placements: &[Placement]) -> Result<(), NestError> { + if placements.len() != input.parts.len() { + return Err(NestError("nest: placement count does not match the parts".to_string())); + } + for (place, part) in placements.iter().zip(input.parts.iter()) { + if place.name != part.name { + return Err(NestError(format!("nest: placement `{}` is out of order", place.name))); + } + if !part.rotations_deg.iter().any(|angle| (*angle - place.rotation_deg).abs() <= 1e-6) { + return Err(NestError(format!("nest: part `{}` uses a rotation the job did not allow", part.name))); + } + let (width, height) = placed_size(part.width, part.height, place.rotation_deg); + if (width - place.width).abs() > FIT_EPS_MM || (height - place.height).abs() > FIT_EPS_MM { + return Err(NestError(format!("nest: part `{}` placement size does not match its rotation", part.name))); + } + if !inside_margin(input, place.x, place.y, place.width, place.height) { + return Err(NestError(format!("nest: part `{}` lies outside the sheet", part.name))); + } + } + for (index, place) in placements.iter().enumerate() { + for other in placements.iter().skip(index + 1) { + if !separated( + place.x, + place.y, + place.x + place.width, + place.y + place.height, + other.x, + other.y, + other.x + other.width, + other.y + other.height, + input.gap, + ) { + return Err(NestError(format!("nest: parts `{}` and `{}` are closer than the gap ({} mm)", place.name, other.name, input.gap))); + } + } + let need = input.tool_radius + input.gap; + for rect in &input.keep_out { + if !separated(place.x, place.y, place.x + place.width, place.y + place.height, rect.min_x, rect.min_y, rect.max_x, rect.max_y, need) { + return Err(NestError(format!("nest: part `{}` is closer than {need} mm to a keep-out", place.name))); + } + } + } + Ok(()) +} + +/// Pack when the job has `sheet` and `nest`. `None` when either is absent. +pub(crate) fn plan(job: &JobFile, drawing: &Drawing, ops: &[OpGeometry<'_>]) -> Result, CliError> { + let Some(nest) = job.nest() else { + return Ok(None); + }; + let Some(sheet) = job.sheet() else { + return Err(CliError::run("nest needs a sheet")); + }; + let tool_radius = choose_tool_radius(nest.tool_radius(), tool_radii(job)?); + let resolved = resolve_parts(job, drawing, ops, nest)?; + let input = PackInput { + sheet_width: sheet.width(), + sheet_height: sheet.height(), + gap: nest.gap(), + margin: nest.margin(), + tool_radius, + keep_out: nest + .keep_outs() + .iter() + .map(|rect| PackRect { min_x: rect.min_x(), min_y: rect.min_y(), max_x: rect.max_x(), max_y: rect.max_y() }) + .collect(), + parts: resolved.iter().map(|part| part.pack_part()).collect(), + }; + let placements = pack(&input).map_err(|err| CliError::run(err.to_string()))?; + check_placements(&input, &placements).map_err(|err| CliError::run(err.to_string()))?; + let mut frames = Vec::new(); + if resolved.len() != placements.len() { + return Err(CliError::run("nest: placement count does not match the parts")); + } + for (part, placement) in resolved.iter().zip(placements.iter()) { + if part.operations.is_empty() { + continue; + } + let frame = frame_for(part.min_x, part.min_y, part.min_x + part.width, part.min_y + part.height, placement); + for name in &part.operations { + frames.push((name.clone(), frame)); + } + } + let summary = summary_lines(&placements); + let json = placements_json(sheet, nest, tool_radius, &placements)?; + Ok(Some(Report { frames, summary, json })) +} + +/// Move contours into the sheet frame. Lines stay exact on a quarter turn. An arc keeps its sweep. +pub(crate) fn transform_contours(contours: &[Contour], frame: &PartFrame, tolerance: Tolerance) -> Result, CliError> { + let mut out = Vec::with_capacity(contours.len()); + for contour in contours { + let mut segments = Vec::with_capacity(contour.segments().len()); + for segment in contour.segments() { + segments.push(transform_segment(*segment, frame)?); + } + let built = Contour::try_new(segments, contour.is_closed(), &tolerance).map_err(|err| CliError::run(format!("nest: {err}")))?; + out.push(built); + } + Ok(out) +} + +struct ResolvedPart { + name: String, + width: f64, + height: f64, + min_x: f64, + min_y: f64, + rotations_deg: Vec, + operations: Vec, +} + +impl ResolvedPart { + fn pack_part(&self) -> PackPart { + PackPart { name: self.name.clone(), width: self.width, height: self.height, rotations_deg: self.rotations_deg.clone() } + } +} + +fn tool_radii(job: &JobFile) -> Result, CliError> { + let mut radii = Vec::with_capacity(job.tools().len()); + for tool in job.tools() { + let radius = tool.path_tool().compensation_distance().map_err(|err| CliError::run(err.to_string()))?; + radii.push(radius); + } + Ok(radii) +} + +fn resolve_parts(job: &JobFile, drawing: &Drawing, ops: &[OpGeometry<'_>], nest: &Nest) -> Result, CliError> { + let mut resolved = Vec::with_capacity(nest.parts().len()); + for part in nest.parts() { + let (min_x, min_y, width, height, operations) = match part.geom() { + NestPartGeom::Rectangle { width, height } => (0.0, 0.0, *width, *height, Vec::new()), + NestPartGeom::Operations { names } => { + let (min_x, min_y, width, height) = bounds_of_ops(part.name(), names, ops)?; + (min_x, min_y, width, height, names.clone()) + } + NestPartGeom::Source { path, hash, layers } => { + let (min_x, min_y, width, height) = bounds_of_source(job, drawing, part.name(), path, hash, layers.as_deref())?; + (min_x, min_y, width, height, Vec::new()) + } + }; + resolved.push(ResolvedPart { + name: part.name().to_string(), + width, + height, + min_x, + min_y, + rotations_deg: rotation_angles(part.rotation_step_deg()), + operations, + }); + } + Ok(resolved) +} + +fn bounds_of_ops(part_name: &str, names: &[String], ops: &[OpGeometry<'_>]) -> Result<(f64, f64, f64, f64), CliError> { + let mut bounds: Option = None; + for name in names { + let Some(op) = ops.iter().find(|op| op.name == name) else { + return Err(CliError::run(format!("nest part `{part_name}`: operation `{name}` has no geometry"))); + }; + for contour in op.contours { + let next = contour.bounding_box().map_err(|err| CliError::run(format!("nest part `{part_name}`: {err}")))?; + bounds = Some(match bounds { + Some(acc) => acc.union(next), + None => next, + }); + } + } + box_size(part_name, bounds) +} + +fn bounds_of_source( + job: &JobFile, + job_drawing: &Drawing, + part_name: &str, + path: &str, + hash: &str, + layers: Option<&[String]>, +) -> Result<(f64, f64, f64, f64), CliError> { + if path == job.source().path() { + if hash != job.source().hash() { + crate::run::warn(&format!("nest part `{part_name}` source hash {hash} does not match the job source hash {}", job.source().hash()))?; + } + return box_size(part_name, layer_bounds(part_name, job_drawing, layers)?); + } + if is_step_or_stl(path) { + return Err(CliError::run(format!("nest part `{part_name}`: source must be DXF or SVG"))); + } + let full = jobfile::join_job_path(job, path); + let text = crate::job::read_text(&full).map_err(|err| CliError::run(format!("nest part `{part_name}`: {err}")))?; + let actual = jobfile::hash_bytes(text.as_bytes()); + if actual != hash { + crate::run::warn(&format!("nest part `{part_name}` source {path} changed: stored hash {hash}, file hash {actual}"))?; + } + let options = ImportOptions::new(job.tolerance()); + let imported = if is_svg(path) { import_svg(&text, &options) } else { import_dxf(&text, &options) } + .map_err(|err| CliError::run(format!("nest part `{part_name}`: {err}")))?; + box_size(part_name, layer_bounds(part_name, &imported, layers)?) +} + +fn layer_bounds(part_name: &str, drawing: &Drawing, layers: Option<&[String]>) -> Result, CliError> { + if let Some(layers) = layers { + for layer in layers { + if !drawing.contours().iter().any(|item| item.layer() == layer) { + return Err(CliError::run(format!("nest part `{part_name}`: no geometry on layer `{layer}`"))); + } + } + } + let mut bounds: Option = None; + for item in drawing.contours() { + let take = match layers { + None => true, + Some(layers) => layers.iter().any(|layer| layer == item.layer()), + }; + if !take { + continue; + } + let next = item.contour().bounding_box().map_err(|err| CliError::run(format!("nest part `{part_name}`: {err}")))?; + bounds = Some(match bounds { + Some(acc) => acc.union(next), + None => next, + }); + } + if bounds.is_none() { + return Err(CliError::run(format!("nest part `{part_name}`: no geometry"))); + } + Ok(bounds) +} + +fn box_size(name: &str, bounds: Option) -> Result<(f64, f64, f64, f64), CliError> { + let Some(bounds) = bounds else { + return Err(CliError::run(format!("nest part `{name}`: no geometry"))); + }; + let width = bounds.width(); + let height = bounds.height(); + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(CliError::run(format!("nest part `{name}` has no area"))); + } + if width > MAX_PACK_MM || height > MAX_PACK_MM { + return Err(CliError::run(format!("nest part `{name}` is larger than {MAX_PACK_MM} mm"))); + } + Ok((bounds.min().x(), bounds.min().y(), width, height)) +} + +fn is_svg(path: &str) -> bool { + Path::new(path).extension().and_then(|extension| extension.to_str()).is_some_and(|text| text.eq_ignore_ascii_case("svg")) +} + +fn is_step_or_stl(path: &str) -> bool { + let Some(text) = Path::new(path).extension().and_then(|extension| extension.to_str()) else { + return false; + }; + text.eq_ignore_ascii_case("step") || text.eq_ignore_ascii_case("stp") || text.eq_ignore_ascii_case("stl") +} + +fn frame_for(min_x: f64, min_y: f64, max_x: f64, max_y: f64, placement: &Placement) -> PartFrame { + let center_x = (min_x + max_x) * 0.5; + let center_y = (min_y + max_y) * 0.5; + let (rot_min_x, rot_min_y) = rotated_aabb_min(min_x, min_y, max_x, max_y, placement.rotation_deg); + PartFrame { center_x, center_y, rotation_deg: placement.rotation_deg, translate_x: placement.x - rot_min_x, translate_y: placement.y - rot_min_y } +} + +fn rotated_aabb_min(min_x: f64, min_y: f64, max_x: f64, max_y: f64, rotation_deg: f64) -> (f64, f64) { + let center_x = (min_x + max_x) * 0.5; + let center_y = (min_y + max_y) * 0.5; + let corners = [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]; + let mut out_x = f64::MAX; + let mut out_y = f64::MAX; + for (x, y) in corners { + let (px, py) = rotate_about(x, y, center_x, center_y, rotation_deg); + out_x = out_x.min(px); + out_y = out_y.min(py); + } + (out_x, out_y) +} + +fn rotate_about(x: f64, y: f64, center_x: f64, center_y: f64, rotation_deg: f64) -> (f64, f64) { + let (rx, ry) = rotate_xy(x - center_x, y - center_y, rotation_deg); + (center_x + rx, center_y + ry) +} + +fn apply_frame(frame: &PartFrame, point: Point) -> Result { + let (rx, ry) = rotate_xy(point.x() - frame.center_x, point.y() - frame.center_y, frame.rotation_deg); + Point::new(frame.center_x + rx + frame.translate_x, frame.center_y + ry + frame.translate_y).map_err(|err| CliError::run(format!("nest: {err}"))) +} + +fn transform_segment(segment: Segment, frame: &PartFrame) -> Result { + match segment { + Segment::Line(line) => { + let start = apply_frame(frame, line.start())?; + let end = apply_frame(frame, line.end())?; + Ok(Segment::Line(Line::new(start, end).map_err(|err| CliError::run(format!("nest: {err}")))?)) + } + Segment::Arc(arc) => { + let start = apply_frame(frame, arc.start_point())?; + let end = apply_frame(frame, arc.end_point())?; + if arc.is_full_turn() { + let center = apply_frame(frame, arc.center())?; + let start_angle = (start - center).angle(); + let built = Arc::new(center, arc.radius(), start_angle, arc.sweep()).map_err(|err| CliError::run(format!("nest: {err}")))?; + return Ok(Segment::Arc(built)); + } + match arc.bulge() { + Ok(bulge) => { + let built = Arc::from_bulge(start, end, bulge).map_err(|err| CliError::run(format!("nest: {err}")))?; + Ok(Segment::Arc(built)) + } + Err(_) => { + let center = apply_frame(frame, arc.center())?; + let start_angle = (start - center).angle(); + let built = Arc::new(center, arc.radius(), start_angle, arc.sweep()).map_err(|err| CliError::run(format!("nest: {err}")))?; + Ok(Segment::Arc(built)) + } + } + } + } +} + +fn summary_lines(placements: &[Placement]) -> String { + let mut text = String::new(); + for placement in placements { + text.push_str(&format!( + "nest: `{}` at ({}, {}) mm, {} x {} mm, rotation {} deg\n", + placement.name, placement.x, placement.y, placement.width, placement.height, placement.rotation_deg + )); + } + text +} + +fn placements_json(sheet: Sheet, nest: &Nest, tool_radius: f64, placements: &[Placement]) -> Result { + let mut keep_out = Vec::with_capacity(nest.keep_outs().len()); + for rect in nest.keep_outs() { + keep_out.push(serde_json::json!({ + "min": [rect.min_x(), rect.min_y()], + "max": [rect.max_x(), rect.max_y()], + })); + } + let mut placed = Vec::with_capacity(placements.len()); + for placement in placements { + placed.push(serde_json::json!({ + "name": placement.name, + "x": placement.x, + "y": placement.y, + "rotation_deg": placement.rotation_deg, + "width": placement.width, + "height": placement.height, + })); + } + let value = serde_json::json!({ + "format": "campanion-nest/1", + "sheet": { "width": sheet.width(), "height": sheet.height() }, + "gap": nest.gap(), + "margin": nest.margin(), + "tool_radius": tool_radius, + "keep_out": keep_out, + "placements": placed, + }); + let text = serde_json::to_string_pretty(&value).map_err(|err| CliError::run(format!("nest: {err}")))?; + Ok(text) +} + +fn validate(input: &PackInput) -> Result<(), NestError> { + if !(input.sheet_width.is_finite() && input.sheet_height.is_finite() && input.sheet_width > 0.0 && input.sheet_height > 0.0) { + return Err(NestError("nest: sheet width and height must be greater than 0".to_string())); + } + if input.sheet_width > MAX_PACK_MM || input.sheet_height > MAX_PACK_MM { + return Err(NestError(format!("nest: sheet is larger than {MAX_PACK_MM} mm"))); + } + if !(input.gap.is_finite() && input.gap >= 0.0) { + return Err(NestError("nest: gap must be at least 0".to_string())); + } + if !(input.margin.is_finite() && input.margin >= 0.0) { + return Err(NestError("nest: margin must be at least 0".to_string())); + } + if !(input.tool_radius.is_finite() && input.tool_radius >= 0.0) { + return Err(NestError("nest: tool_radius must be at least 0".to_string())); + } + if input.parts.len() > MAX_PACK_PARTS { + return Err(NestError(format!("nest: too many parts: {} > {MAX_PACK_PARTS}", input.parts.len()))); + } + if input.keep_out.len() > MAX_PACK_KEEPOUTS { + return Err(NestError(format!("nest: too many keep-outs: {} > {MAX_PACK_KEEPOUTS}", input.keep_out.len()))); + } + for rect in &input.keep_out { + if !(rect.min_x.is_finite() && rect.min_y.is_finite() && rect.max_x.is_finite() && rect.max_y.is_finite()) { + return Err(NestError("nest: keep-out must be finite".to_string())); + } + if !(rect.max_x > rect.min_x && rect.max_y > rect.min_y) { + return Err(NestError("nest: keep-out max must be greater than min".to_string())); + } + } + for part in &input.parts { + if part.name.is_empty() { + return Err(NestError("nest: part name is empty".to_string())); + } + if !(part.width.is_finite() && part.height.is_finite() && part.width > 0.0 && part.height > 0.0) { + return Err(NestError(format!("nest: part `{}` needs a positive width and height", part.name))); + } + if part.rotations_deg.is_empty() { + return Err(NestError(format!("nest: part `{}` has no rotation", part.name))); + } + for angle in &part.rotations_deg { + if !angle.is_finite() { + return Err(NestError(format!("nest: part `{}` rotation must be finite", part.name))); + } + } + } + Ok(()) +} + +fn first_pose(input: &PackInput, placed: &[Placement], part: &PackPart) -> Option { + let xs = candidate_axis( + input.margin, + placed.iter().map(|place| place.x + place.width), + input.keep_out.iter().map(|rect| rect.max_x), + input.gap, + input.tool_radius, + ); + let ys = candidate_axis( + input.margin, + placed.iter().map(|place| place.y + place.height), + input.keep_out.iter().map(|rect| rect.max_y), + input.gap, + input.tool_radius, + ); + for y in &ys { + for x in &xs { + for rotation in &part.rotations_deg { + let (width, height) = placed_size(part.width, part.height, *rotation); + if pose_fits(input, placed, *x, *y, width, height) { + return Some(Placement { name: part.name.clone(), x: *x, y: *y, rotation_deg: *rotation, width, height }); + } + } + } + } + None +} + +fn candidate_axis(origin: f64, placed_far: impl Iterator, keep_far: impl Iterator, gap: f64, tool_radius: f64) -> Vec { + let mut values = vec![origin]; + for far in placed_far { + values.push(far + gap); + } + let keep = tool_radius + gap; + for far in keep_far { + values.push(far + keep); + } + unique_sorted(values) +} + +fn pose_fits(input: &PackInput, placed: &[Placement], x: f64, y: f64, width: f64, height: f64) -> bool { + if !inside_margin(input, x, y, width, height) { + return false; + } + for place in placed { + if !separated(x, y, x + width, y + height, place.x, place.y, place.x + place.width, place.y + place.height, input.gap) { + return false; + } + } + let need = input.tool_radius + input.gap; + for rect in &input.keep_out { + if !separated(x, y, x + width, y + height, rect.min_x, rect.min_y, rect.max_x, rect.max_y, need) { + return false; + } + } + true +} + +fn inside_margin(input: &PackInput, x: f64, y: f64, width: f64, height: f64) -> bool { + x >= input.margin - FIT_EPS_MM + && y >= input.margin - FIT_EPS_MM + && x + width <= input.sheet_width - input.margin + FIT_EPS_MM + && y + height <= input.sheet_height - input.margin + FIT_EPS_MM +} + +/// True when the two rectangles are at least `need` apart along X or along Y. +fn separated(ax0: f64, ay0: f64, ax1: f64, ay1: f64, bx0: f64, by0: f64, bx1: f64, by1: f64, need: f64) -> bool { + ax1 + need <= bx0 + FIT_EPS_MM || bx1 + need <= ax0 + FIT_EPS_MM || ay1 + need <= by0 + FIT_EPS_MM || by1 + need <= ay0 + FIT_EPS_MM +} + +fn placed_size(width: f64, height: f64, rotation_deg: f64) -> (f64, f64) { + match quarter_turns(rotation_deg) { + Some(0 | 2) => (width, height), + Some(1 | 3) => (height, width), + _ => { + let (sin_t, cos_t) = rotation_deg.to_radians().sin_cos(); + let (sin_abs, cos_abs) = (sin_t.abs(), cos_t.abs()); + (width * cos_abs + height * sin_abs, width * sin_abs + height * cos_abs) + } + } +} + +fn rotate_xy(dx: f64, dy: f64, rotation_deg: f64) -> (f64, f64) { + match quarter_turns(rotation_deg) { + Some(0) => (dx, dy), + Some(1) => (-dy, dx), + Some(2) => (-dx, -dy), + Some(3) => (dy, -dx), + _ => { + let (sin_t, cos_t) = rotation_deg.to_radians().sin_cos(); + (cos_t * dx - sin_t * dy, sin_t * dx + cos_t * dy) + } + } +} + +/// 0, 1, 2 or 3 when `rotation_deg` is a quarter turn. `None` otherwise. +fn quarter_turns(rotation_deg: f64) -> Option { + if !rotation_deg.is_finite() { + return None; + } + let turns = rotation_deg / 90.0; + let rounded = turns.round(); + if (turns - rounded).abs() > 1e-6 { + return None; + } + let wrapped = rounded.rem_euclid(4.0); + if (wrapped - 0.0).abs() <= 1e-6 { + Some(0) + } else if (wrapped - 1.0).abs() <= 1e-6 { + Some(1) + } else if (wrapped - 2.0).abs() <= 1e-6 { + Some(2) + } else if (wrapped - 3.0).abs() <= 1e-6 { + Some(3) + } else { + None + } +} + +fn unique_sorted(mut values: Vec) -> Vec { + values.sort_by(|left, right| cmp_float_key(*left, *right)); + let mut out = Vec::with_capacity(values.len()); + for value in values { + if out.last().is_some_and(|prev| cmp_float_key(*prev, value) == Ordering::Equal) { + continue; + } + out.push(value); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use campanion_geom::Tolerance; + use proptest::prelude::*; + + fn near(actual: f64, expected: f64) { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}"); + } + + fn part(name: &str, width: f64, height: f64, rotations: &[f64]) -> PackPart { + PackPart { name: name.to_string(), width, height, rotations_deg: rotations.to_vec() } + } + + fn sheet(width: f64, height: f64, gap: f64, margin: f64, tool_radius: f64, keep_out: Vec, parts: Vec) -> PackInput { + PackInput { sheet_width: width, sheet_height: height, gap, margin, tool_radius, keep_out, parts } + } + + fn placed(input: &PackInput) -> Vec { + pack(input).unwrap_or_else(|err| panic!("{err}")) + } + + fn square_contour(x: f64, y: f64, size: f64) -> Contour { + rect_contour(x, y, size, size) + } + + fn rect_contour(x: f64, y: f64, width: f64, height: f64) -> Contour { + let point = |px: f64, py: f64| Point::new(px, py).expect("point"); + let line = |a: Point, b: Point| Segment::Line(Line::new(a, b).expect("line")); + let a = point(x, y); + let b = point(x + width, y); + let c = point(x + width, y + height); + let d = point(x, y + height); + Contour::try_new(vec![line(a, b), line(b, c), line(c, d), line(d, a)], true, &Tolerance::new(1e-6, 1e-6).expect("tol")).expect("contour") + } + + #[test] + fn two_squares_sit_side_by_side() { + let input = sheet(40.0, 20.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("a", 20.0, 20.0, &[0.0]), part("b", 20.0, 20.0, &[0.0])]); + let poses = placed(&input); + assert_eq!(poses.len(), 2); + near(poses[0].x, 0.0); + near(poses[0].y, 0.0); + near(poses[1].x, 20.0); + near(poses[1].y, 0.0); + check_placements(&input, &poses).expect("check"); + } + + #[test] + fn gap_separates_identical_squares() { + let input = sheet(50.0, 20.0, 6.0, 0.0, 0.0, Vec::new(), vec![part("a", 20.0, 20.0, &[0.0]), part("b", 20.0, 20.0, &[0.0])]); + let poses = placed(&input); + near(poses[1].x, 26.0); + near(poses[1].y, 0.0); + } + + #[test] + fn margin_insets_the_first_corner() { + let input = sheet(30.0, 20.0, 0.0, 4.0, 0.0, Vec::new(), vec![part("a", 10.0, 8.0, &[0.0])]); + let poses = placed(&input); + near(poses[0].x, 4.0); + near(poses[0].y, 4.0); + } + + #[test] + fn keep_out_blocks_the_side_slot() { + // The second square would sit at x = 20. The clamp occupies that slot, so it goes to x = 40. + let keep = PackRect { min_x: 20.0, min_y: 0.0, max_x: 40.0, max_y: 20.0 }; + let input = sheet(60.0, 20.0, 0.0, 0.0, 0.0, vec![keep], vec![part("a", 20.0, 20.0, &[0.0]), part("b", 20.0, 20.0, &[0.0])]); + let poses = placed(&input); + near(poses[0].x, 0.0); + near(poses[0].y, 0.0); + near(poses[1].x, 40.0); + near(poses[1].y, 0.0); + check_placements(&input, &poses).expect("check"); + } + + #[test] + fn tool_radius_pushes_a_part_off_a_clamp() { + let keep = PackRect { min_x: 0.0, min_y: 0.0, max_x: 10.0, max_y: 20.0 }; + let input = sheet(50.0, 20.0, 0.0, 0.0, 5.0, vec![keep], vec![part("a", 10.0, 10.0, &[0.0])]); + let poses = placed(&input); + near(poses[0].x, 15.0); + near(poses[0].y, 0.0); + } + + #[test] + fn lower_row_wins_over_a_higher_row() { + let input = sheet(40.0, 30.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("a", 20.0, 20.0, &[0.0]), part("b", 20.0, 10.0, &[0.0])]); + let poses = placed(&input); + near(poses[1].x, 20.0); + near(poses[1].y, 0.0); + } + + #[test] + fn input_order_not_size_order() { + let large_first = sheet(40.0, 10.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("large", 30.0, 10.0, &[0.0]), part("small", 10.0, 10.0, &[0.0])]); + let small_first = sheet(40.0, 10.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("small", 10.0, 10.0, &[0.0]), part("large", 30.0, 10.0, &[0.0])]); + let large = placed(&large_first); + let small = placed(&small_first); + near(large[0].x, 0.0); + near(large[1].x, 30.0); + near(small[0].x, 0.0); + near(small[1].x, 10.0); + } + + #[test] + fn quarter_turn_when_the_long_side_does_not_fit() { + let input = sheet(15.0, 40.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("strip", 30.0, 10.0, &[0.0, 90.0])]); + let poses = placed(&input); + near(poses[0].rotation_deg, 90.0); + near(poses[0].width, 10.0); + near(poses[0].height, 30.0); + near(poses[0].x, 0.0); + near(poses[0].y, 0.0); + } + + #[test] + fn zero_rotation_wins_when_both_fit_at_the_same_corner() { + let input = sheet(40.0, 40.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("strip", 30.0, 10.0, &[0.0, 90.0])]); + let poses = placed(&input); + near(poses[0].rotation_deg, 0.0); + near(poses[0].x, 0.0); + near(poses[0].y, 0.0); + } + + #[test] + fn part_larger_than_the_sheet_names_the_part() { + let input = sheet(40.0, 40.0, 0.0, 0.0, 0.0, Vec::new(), vec![part("big", 50.0, 10.0, &[0.0, 90.0])]); + let err = pack(&input).expect_err("too big"); + assert!(err.to_string().contains("part `big`"), "{err}"); + assert!(err.to_string().contains("does not fit"), "{err}"); + } + + #[test] + fn margin_can_make_a_part_not_fit() { + let input = sheet(20.0, 20.0, 0.0, 2.0, 0.0, Vec::new(), vec![part("wide", 18.0, 10.0, &[0.0])]); + let err = pack(&input).expect_err("margin"); + assert!(err.to_string().contains("part `wide`"), "{err}"); + } + + #[test] + fn check_rejects_an_overlap_and_a_clamp_hit() { + let input = sheet( + 40.0, + 20.0, + 4.0, + 0.0, + 3.0, + vec![PackRect { min_x: 0.0, min_y: 0.0, max_x: 5.0, max_y: 5.0 }], + vec![part("a", 10.0, 10.0, &[0.0]), part("b", 10.0, 10.0, &[0.0])], + ); + let overlapping = vec![ + Placement { name: "a".to_string(), x: 0.0, y: 0.0, rotation_deg: 0.0, width: 10.0, height: 10.0 }, + Placement { name: "b".to_string(), x: 10.0, y: 0.0, rotation_deg: 0.0, width: 10.0, height: 10.0 }, + ]; + let err = check_placements(&input, &overlapping).expect_err("gap"); + assert!(err.to_string().contains("closer than the gap"), "{err}"); + let clamped = vec![Placement { name: "a".to_string(), x: 6.0, y: 0.0, rotation_deg: 0.0, width: 10.0, height: 10.0 }]; + let one = + sheet(40.0, 20.0, 0.0, 0.0, 3.0, vec![PackRect { min_x: 0.0, min_y: 0.0, max_x: 5.0, max_y: 5.0 }], vec![part("a", 10.0, 10.0, &[0.0])]); + let err = check_placements(&one, &clamped).expect_err("clamp"); + assert!(err.to_string().contains("keep-out"), "{err}"); + let outside = vec![Placement { name: "a".to_string(), x: 35.0, y: 0.0, rotation_deg: 0.0, width: 10.0, height: 10.0 }]; + let err = check_placements(&one, &outside).expect_err("sheet"); + assert!(err.to_string().contains("outside the sheet"), "{err}"); + } + + #[test] + fn translation_moves_a_square_onto_its_placement() { + let contour = square_contour(0.0, 0.0, 20.0); + let placement = Placement { name: "a".to_string(), x: 24.0, y: 0.0, rotation_deg: 0.0, width: 20.0, height: 20.0 }; + let frame = frame_for(0.0, 0.0, 20.0, 20.0, &placement); + let moved = transform_contours(&[contour], &frame, Tolerance::new(1e-6, 1e-6).expect("tol")).unwrap_or_else(|err| panic!("{err}")); + let bounds = moved.first().expect("contour").bounding_box().expect("bounds"); + near(bounds.min().x(), 24.0); + near(bounds.min().y(), 0.0); + near(bounds.max().x(), 44.0); + near(bounds.max().y(), 20.0); + } + + #[test] + fn quarter_turn_maps_the_rectangle_aabb() { + let contour = rect_contour(0.0, 0.0, 30.0, 10.0); + let placement = Placement { name: "strip".to_string(), x: 0.0, y: 0.0, rotation_deg: 90.0, width: 10.0, height: 30.0 }; + let frame = frame_for(0.0, 0.0, 30.0, 10.0, &placement); + let moved = transform_contours(&[contour], &frame, Tolerance::new(1e-6, 1e-6).expect("tol")).unwrap_or_else(|err| panic!("{err}")); + let bounds = moved.first().expect("contour").bounding_box().expect("bounds"); + near(bounds.min().x(), 0.0); + near(bounds.min().y(), 0.0); + near(bounds.max().x(), 10.0); + near(bounds.max().y(), 30.0); + } + + #[test] + fn tool_radius_choice() { + near(choose_tool_radius(Some(1.0), [5.0, 2.0]), 1.0); + near(choose_tool_radius(None, [2.0, 5.0, 1.0]), 5.0); + near(choose_tool_radius(None, Vec::::new()), 0.0); + } + + fn pack_config() -> ProptestConfig { + ProptestConfig { cases: 32, rng_seed: proptest::test_runner::RngSeed::Fixed(0x25C0_5E57), ..ProptestConfig::default() } + } + + proptest! { + #![proptest_config(pack_config())] + #[test] + fn same_input_same_placements( + sheet_w in 40.0_f64..160.0, + sheet_h in 40.0_f64..160.0, + gap in 0.0_f64..5.0, + margin in 0.0_f64..4.0, + tool_radius in 0.0_f64..3.0, + count in 1_usize..5, + seed in 0_u64..20_000, + ) { + let mut state = seed | 1; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + state + }; + let mut parts = Vec::new(); + for index in 0..count { + let width = 1.0 + f64::from((next() % 25) as u32); + let height = 1.0 + f64::from((next() % 25) as u32); + let rotations = if next() % 2 == 0 { vec![0.0] } else { vec![0.0, 90.0] }; + parts.push(part(&format!("p{index}"), width, height, &rotations)); + } + let keep_out = if next() % 2 == 0 { + Vec::new() + } else { + let x = f64::from((next() % 30) as u32); + let y = f64::from((next() % 30) as u32); + vec![PackRect { min_x: x, min_y: y, max_x: x + 8.0, max_y: y + 6.0 }] + }; + let input = sheet(sheet_w, sheet_h, gap, margin, tool_radius, keep_out, parts); + let first = pack(&input); + let second = pack(&input); + match (&first, &second) { + (Ok(left), Ok(right)) => { + prop_assert_eq!(left, right); + prop_assert_eq!(left.len(), input.parts.len()); + for (pose, part) in left.iter().zip(input.parts.iter()) { + prop_assert_eq!(&pose.name, &part.name); + } + prop_assert!(check_placements(&input, left).is_ok()); + } + (Err(left), Err(right)) => { + prop_assert_eq!(left, right); + prop_assert!(left.to_string().contains("does not fit"), "{}", left); + } + _ => prop_assert!(false, "pack changed between calls"), + } + } + } +} diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 994dfc9..30432e1 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -112,6 +112,12 @@ //! so Z is up. `z` and `y` skip that probe. Any other source ignores the flag. The face point //! is in the frame after the turn. A file point `(x, y, z)` is `(x, -z, y)` once Y up has been //! turned. +//! +//! A job with `sheet` and `nest` packs each part before the paths are built. The placement is a +//! translation and an optional quarter-turn of that part's contours. The IR stays a list of +//! ordinary toolpaths in the sheet frame. Placements are printed on stderr. `--nest-out` writes +//! them after the program is accepted. A part that does not fit, or that fails a clearance +//! check, exits 1 and nothing is written. See `docs/job-file.md`. use std::borrow::Cow; use std::ffi::{OsStr, OsString}; @@ -161,6 +167,7 @@ struct RunArgs { stock_report: Option, stock_out: Option, stock_snapshots: Option, + nest_out: Option, } struct Cut<'a> { @@ -179,6 +186,9 @@ pub fn run(args: &[OsString]) -> Result { None => None, }; let job = jobfile::load(&args.job).map_err(show)?; + if args.nest_out.is_some() && job.nest().is_none() { + return Err(CliError::run("--nest-out needs a job with sheet and nest")); + } let source = jobfile::source_path(&job); if job.source().is_stl() { let bytes = read_bytes(&source)?; @@ -188,7 +198,7 @@ pub fn run(args: &[OsString]) -> Result { let gouges = crate::gouge::check_job(&job, &mesh, doc.program())?; crate::collision::check_job(&job, Some(&mesh), doc.program())?; apply_stock(&job, Some(&mesh), doc.program(), None, &args)?; - return emit_program(&args, &job, doc, &gouges, post.as_ref()); + return emit_program(&args, &job, doc, &gouges, post.as_ref(), None); } let text = read_text(&source)?; warn_if_changed(&job, text.as_bytes())?; @@ -221,11 +231,18 @@ pub fn run(args: &[OsString]) -> Result { let kinds = drawing.skipped_types().iter().map(|(name, count)| format!("{name} x{count}")).collect::>().join(", "); warn(&format!("skipped {format} entities: {kinds}"))?; } - let doc = build(&job, &drawing)?; + let (doc, nest_report) = build(&job, &drawing)?; + if let Some(report) = nest_report.as_ref() + && !report.summary.is_empty() + { + let mut err_out = io::stderr().lock(); + write_all(&mut err_out, &report.summary)?; + err_out.flush()?; + } crate::collision::check_job(&job, None, doc.program())?; let implicit = if job.stock_model_enabled() && job.stock().is_none() { Some(drawing_stock_bounds(&job, &drawing)?) } else { None }; apply_stock(&job, None, doc.program(), implicit, &args)?; - emit_program(&args, &job, doc, &[], post.as_ref()) + emit_program(&args, &job, doc, &[], post.as_ref(), nest_report.as_ref()) } /// Replay the program when the stock model is on, and print the STL leftover summary. @@ -368,7 +385,14 @@ fn build_surface(job: &JobFile, mesh: &Mesh) -> Result { finish_program(job, groups, None, &sweep) } -fn emit_program(args: &RunArgs, job: &JobFile, doc: Document, gouges: &[report::GougeNote], post: Option<&PostDef>) -> Result { +fn emit_program( + args: &RunArgs, + job: &JobFile, + doc: Document, + gouges: &[report::GougeNote], + post: Option<&PostDef>, + nest_report: Option<&crate::nest::Report>, +) -> Result { if let Some(post) = post { require_spindle_speeds(job, post)?; } @@ -397,6 +421,10 @@ fn emit_program(args: &RunArgs, job: &JobFile, doc: Document, gouges: &[report:: if let Some(ir_path) = &args.ir { write_output(ir_path, &doc.to_json_pretty()?)?; } + if let Some(path) = &args.nest_out { + let report = nest_report.ok_or_else(|| CliError::run("--nest-out needs a job with sheet and nest"))?; + write_output(path, &report.json)?; + } Ok(ExitCode::SUCCESS) } @@ -409,6 +437,7 @@ fn parse_run(args: &[OsString]) -> Result { let mut stock_report: Option<&OsStr> = None; let mut stock_out: Option<&OsStr> = None; let mut stock_snapshots: Option = None; + let mut nest_out: Option<&OsStr> = None; let mut index = 0; while index < args.len() { let Some(arg) = args.get(index) else { @@ -470,6 +499,18 @@ fn parse_run(args: &[OsString]) -> Result { return Err(CliError::usage(format!("{flag} is already set"))); } *slot = Some(value.as_os_str()); + } else if arg.as_os_str() == "--nest-out" { + let Some(value) = args.get(index) else { + return Err(CliError::usage("--nest-out needs a file")); + }; + index += 1; + if value.is_empty() { + return Err(CliError::usage("--nest-out needs a file")); + } + if nest_out.is_some() { + return Err(CliError::usage("--nest-out is already set")); + } + nest_out = Some(value.as_os_str()); } else if arg.as_os_str() == "--stock-snapshots" { let Some(value) = args.get(index) else { return Err(CliError::usage("--stock-snapshots needs a positive integer")); @@ -503,11 +544,16 @@ fn parse_run(args: &[OsString]) -> Result { let ir_path = ir.map(PathBuf::from); let report_path = stock_report.map(PathBuf::from); let stock_path = stock_out.map(PathBuf::from); + let nest_path = nest_out.map(PathBuf::from); distinct_pair("-o", Some(&output_path), "--stock-report", report_path.as_deref())?; distinct_pair("-o", Some(&output_path), "--stock-out", stock_path.as_deref())?; + distinct_pair("-o", Some(&output_path), "--nest-out", nest_path.as_deref())?; distinct_pair("--ir", ir_path.as_deref(), "--stock-report", report_path.as_deref())?; distinct_pair("--ir", ir_path.as_deref(), "--stock-out", stock_path.as_deref())?; + distinct_pair("--ir", ir_path.as_deref(), "--nest-out", nest_path.as_deref())?; distinct_pair("--stock-report", report_path.as_deref(), "--stock-out", stock_path.as_deref())?; + distinct_pair("--stock-report", report_path.as_deref(), "--nest-out", nest_path.as_deref())?; + distinct_pair("--stock-out", stock_path.as_deref(), "--nest-out", nest_path.as_deref())?; Ok(RunArgs { job: PathBuf::from(job), output: output_path, @@ -517,6 +563,7 @@ fn parse_run(args: &[OsString]) -> Result { stock_report: report_path, stock_out: stock_path, stock_snapshots, + nest_out: nest_path, }) } @@ -546,7 +593,7 @@ fn starts_with_dash(arg: &OsStr) -> bool { arg.to_string_lossy().starts_with('-') } -fn build(job: &JobFile, drawing: &Drawing) -> Result { +fn build(job: &JobFile, drawing: &Drawing) -> Result<(Document, Option), CliError> { // One nesting pass for the drawing. Operations then pick OUTER / INNER instead of layer "0". // Contours are collected first so the swept radius, including every wrap cylinder, is known // before a wrap path is built. A wrap's drawing is not stock: its X/Y are cylinder coordinates. @@ -566,6 +613,9 @@ fn build(job: &JobFile, drawing: &Drawing) -> Result { } selected.push((op, contours)); } + let nest_ops: Vec> = + selected.iter().map(|(op, contours)| crate::nest::OpGeometry { name: op.name(), contours }).collect(); + let nest_report = crate::nest::plan(job, drawing, &nest_ops)?; let corners = if job.rotary().is_some() { Some(stock.corners()?) } else { None }; let safe_z = job.rotary().map(|setup| swept_clearance(job, &setup, corners.as_deref(), &[])); let plane_job = job.five_axis().is_some(); @@ -576,7 +626,12 @@ fn build(job: &JobFile, drawing: &Drawing) -> Result { return Err(op_err(op, format!("unknown tool {}", op.tool_id()))); }; let import = import_for(op, tool)?; - let cut = Cut { op, tool, import: &import, tolerance: job.tolerance(), contours, job }; + let transformed = match nest_report.as_ref().and_then(|report| report.frame(op.name())) { + Some(frame) => Some(crate::nest::transform_contours(contours, frame, job.tolerance())?), + None => None, + }; + let view = transformed.as_deref().unwrap_or(contours.as_slice()); + let cut = Cut { op, tool, import: &import, tolerance: job.tolerance(), contours: view, job }; let mut paths = Vec::new(); match op.kind() { OperationKind::Profile { side } => profile_paths(&mut paths, &cut, side)?, @@ -596,7 +651,7 @@ fn build(job: &JobFile, drawing: &Drawing) -> Result { let Some(safe) = safe_z else { return Err(op_err(op, "wrap needs a rotary setup")); }; - wrap::cut(&mut paths, op, tool, contours, &setup, job.tolerance().chord(), safe)?; + wrap::cut(&mut paths, op, tool, view, &setup, job.tolerance().chord(), safe)?; } } // Planning-frame Z, before a tilted plane maps the paths. A wrap checks its own levels. @@ -610,7 +665,7 @@ fn build(job: &JobFile, drawing: &Drawing) -> Result { safety::check_radial(op.name(), &paths, setup.center(), setup.axis_direction())?; } else { let grow = tool.diameter().map_or(0.0, |diameter| diameter * 0.5); - let footprint = safety::Footprint::from_contours(contours, grow).map_err(|err| op_err(op, err))?; + let footprint = safety::Footprint::from_contours(view, grow).map_err(|err| op_err(op, err))?; safety::check_planar(op.name(), &paths, &footprint, job.retract())?; } if plane_job { @@ -630,9 +685,9 @@ fn build(job: &JobFile, drawing: &Drawing) -> Result { } if plane_job { let paths = plane::link_frames(plane_groups, job.clearance())?; - return finish_paths(job, paths, None); + return Ok((finish_paths(job, paths, None)?, nest_report)); } - finish_program(job, groups, corners.as_deref(), &[]) + Ok((finish_program(job, groups, corners.as_deref(), &[])?, nest_report)) } /// Axis-aligned stock for a 2D job: contour bounds in XY, and Z from `-depth` up to the stock surface. diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs index cd69b76..7939bc6 100644 --- a/crates/cli/tests/cli.rs +++ b/crates/cli/tests/cli.rs @@ -64,6 +64,7 @@ fn help_lists_commands() { assert!(stdout.contains(word), "help missing {word}: {stdout}"); } assert!(stdout.contains("campanion run JOB -o OUT [--ir OUT.json] [--post ID|FILE.json] [--up-axis z|y|auto]"), "{stdout}"); + assert!(stdout.contains("--nest-out"), "{stdout}"); assert!(stdout.contains("campanion lathe JOB -o OUT [--ir OUT.json] [--post ID|FILE.json] [--up-axis z|y|auto]"), "{stdout}"); assert!(stdout.contains("campanion post IR.json --post ID|FILE.json [-o OUT]"), "{stdout}"); assert!(stdout.contains("campanion posts"), "{stdout}"); diff --git a/crates/cli/tests/nest_e2e.rs b/crates/cli/tests/nest_e2e.rs new file mode 100644 index 0000000..0764df9 --- /dev/null +++ b/crates/cli/tests/nest_e2e.rs @@ -0,0 +1,140 @@ +//! Sheet nesting on `campanion run`. +//! +//! `examples/nest.job.json` engraves two 20 mm squares onto a 50 by 30 mm sheet with a 4 mm gap. +//! The placements file is the bytes `--nest-out` writes for that job. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn text(bytes: &[u8]) -> Result { + String::from_utf8(bytes.to_vec()).map_err(|err| err.to_string()) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +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 campanion(args: &[&str]) -> Result { + Command::new(env!("CARGO_BIN_EXE_campanion")).args(args).output().map_err(|err| err.to_string()) +} + +fn square_dxf() -> String { + let mut entities = String::new(); + let points = [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0), (0.0, 0.0)]; + for pair in points.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 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}") +} + +#[test] +fn golden_nest_matches_the_example() -> Result<(), String> { + let root = workspace_root(); + let job = root.join("examples/nest.job.json"); + let dir = temp_dir("nest-golden")?; + let ngc = dir.join("out.ngc"); + let placed = dir.join("placements.json"); + let output = campanion(&[ + "run", + job.to_str().ok_or("job path")?, + "-o", + ngc.to_str().ok_or("ngc path")?, + "--nest-out", + placed.to_str().ok_or("placements path")?, + ])?; + let stderr = text(&output.stderr)?; + let stdout = text(&output.stdout)?; + assert_eq!(output.status.code(), Some(0), "stdout {stdout} stderr {stderr}"); + assert!(stderr.contains("nest: `left` at (0, 0) mm, 20 x 20 mm, rotation 0 deg"), "{stderr}"); + assert!(stderr.contains("nest: `right` at (24, 0) mm, 20 x 20 mm, rotation 0 deg"), "{stderr}"); + assert!(!stderr.contains("warning"), "{stderr}"); + let ngc_text = std::fs::read_to_string(&ngc).map_err(|err| err.to_string())?; + assert!(ngc_text.contains("X24.0000"), "{ngc_text}"); + assert!(ngc_text.contains("X44.0000"), "{ngc_text}"); + let actual = std::fs::read(&placed).map_err(|err| err.to_string())?; + let expected = std::fs::read(root.join("examples/nest.placements.json")).map_err(|err| err.to_string())?; + assert_eq!(actual, expected); + Ok(()) +} + +#[test] +fn a_part_larger_than_the_sheet_writes_nothing() -> Result<(), String> { + let dir = temp_dir("nest-too-big")?; + let drawing = square_dxf(); + let hash = fnv1a64(drawing.as_bytes()); + std::fs::write(dir.join("plate.dxf"), &drawing).map_err(|err| err.to_string())?; + let job = format!( + r#"{{ + "format": "campanion-job/1", + "source": {{ "path": "plate.dxf", "hash": "{hash}" }}, + "units": "mm", + "tools": [ {{ "id": 1, "diameter": 6, "speed": 1000 }} ], + "operations": [ {{ + "name": "plate", + "type": "engrave", + "layers": ["PLATE"], + "tool_id": 1, + "depth": 1, + "step_down": 1, + "feeds": {{ "cut": 100 }} + }} ], + "sheet": {{ "width": 40, "height": 40 }}, + "nest": {{ "parts": [ {{ "name": "block", "width": 50, "height": 10 }} ] }} +}}"# + ); + let job_path = dir.join("job.json"); + std::fs::write(&job_path, job).map_err(|err| err.to_string())?; + let ngc = dir.join("out.ngc"); + let placed = dir.join("placements.json"); + let output = + campanion(&["run", job_path.to_str().ok_or("job")?, "-o", ngc.to_str().ok_or("ngc")?, "--nest-out", placed.to_str().ok_or("placements")?])?; + let stderr = text(&output.stderr)?; + assert_eq!(output.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("block"), "{stderr}"); + assert!(stderr.contains("does not fit"), "{stderr}"); + assert!(!ngc.exists(), "ngc was written"); + assert!(!placed.exists(), "placements were written"); + Ok(()) +} + +#[test] +fn nest_out_without_a_sheet_is_an_error() -> Result<(), String> { + let dir = temp_dir("nest-flag")?; + let job = r#"{ + "format": "campanion-job/1", + "source": { "path": "missing.dxf", "hash": "cbf29ce484222325" }, + "units": "mm", + "tools": [], + "operations": [] +}"#; + let job_path = dir.join("job.json"); + std::fs::write(&job_path, job).map_err(|err| err.to_string())?; + let ngc = dir.join("out.ngc"); + let placed = dir.join("placements.json"); + let output = + campanion(&["run", job_path.to_str().ok_or("job")?, "-o", ngc.to_str().ok_or("ngc")?, "--nest-out", placed.to_str().ok_or("placements")?])?; + let stderr = text(&output.stderr)?; + assert_eq!(output.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("--nest-out needs a job with sheet and nest"), "{stderr}"); + assert!(!ngc.exists(), "ngc was written"); + assert!(!placed.exists(), "placements were written"); + Ok(()) +} diff --git a/docs/job-file.md b/docs/job-file.md index 4c031ba..fee7d6d 100644 --- a/docs/job-file.md +++ b/docs/job-file.md @@ -35,6 +35,8 @@ lengths are millimetres and feeds are millimetres per minute. | `rotary` | no | Fourth-axis table for indexed 3+1, wrapping and rotary finishing. See below. | | `fixtures` | no | STL meshes or axis-aligned boxes in the part frame. See [Tool assembly and collisions](#tool-assembly-and-collisions). | | `collision` | no | Clearance and sampling for the assembly check. See [Tool assembly and collisions](#tool-assembly-and-collisions). | +| `sheet` | no | Router sheet in the XY plane. See [Sheet nesting](#sheet-nesting). | +| `nest` | no | Part gap, margin, clamp keep-outs and the parts to pack. Needs `sheet`. See [Sheet nesting](#sheet-nesting). | `tolerance.linear` and `tolerance.chord` are lengths in `units`. `tolerance.angular` is radians either way. @@ -577,6 +579,68 @@ A delay of zero adds no dwell when a path is later timed. The toggles are the tw The loader stores delays, toggles and heights on the same process-parameter object the profile job builds, so both files share one mapping. +## Sheet nesting + +`sheet` and `nest` pack rectangles onto a router sheet before `campanion run` builds toolpaths. There is no `campanion nest` command. A job that omits both is unchanged. `sheet` alone is stored and does not move any path. `nest` without `sheet` is an error (`nest needs a sheet`). + +This packs axis-aligned bounds. It makes no claim of optimal yield. A contour packer can follow. Until then, parts must not overlap those rectangles once each is expanded by `gap / 2`, and each rectangle must clear every keep-out by the tool radius plus the gap. + +```json +"sheet": { "width": 50, "height": 30 }, +"nest": { + "gap": 4, + "margin": 0, + "tool_radius": 3, + "keep_out": [ { "min": [0, 0], "max": [10, 30] } ], + "parts": [ + { "name": "left", "operations": ["left"] }, + { "name": "right", "operations": ["right"], "rotation_step_deg": 90 } + ] +} +``` + +The origin is `(0, 0)`. The sheet is `[0, width]` by `[0, height]` in the XY plane. `width` and `height` are lengths in `units` and must be greater than 0. After `load` they are millimetres. + +`gap` is the space between part rectangles. It defaults to 0. `margin` is the inset from the sheet edge. It defaults to 0. `tool_radius` is optional. When it is omitted the run uses half the largest tool diameter, or half the kerf, and 0 when the job has no tool. Lengths are in `units`. `gap`, `margin` and `tool_radius` must be at least 0 and at most 1e6 mm. Angles are not scaled. + +`keep_out` is a list of clamp rectangles in the sheet frame. Each object is `min` and `max`, two numbers `[x, y]`. `max` must be greater than `min` on both axes. Coordinates are finite and within ±1e6 mm. At most 64 keep-outs. An omitted list is empty. + +`parts` is the list to place, in input order, at most 256. An omitted list places nothing. Each part has a `name` and exactly one of these: + +- `operations`: names of existing operations that are not mesh operations (`surface`, `rough`, `waterline`, `rotary_finish`). The rectangle is the union of those operations' layer contours on the job drawing. Each operation name is used by at most one part. Those operations are moved onto the sheet. +- `source` and `hash`, with optional `layers`: bounds of a DXF or SVG. `hash` is the same FNV-1a 64 as a job source. `layers` limits the contours; omitted, every contour counts. A path equal to the job source uses the drawing already imported. Any other path must be DXF or SVG (`.step`, `.stp` and `.stl` are rejected; anything else is read as DXF). A hash mismatch is a warning. This part reserves space and does not move a toolpath. +- `width` and `height`: a rectangle `[0, width]` by `[0, height]` in the part frame. Both are required. This part reserves space and does not move a toolpath. + +`layers` is valid only with `source`. A part that mixes these modes, or that sets none of them, fails with `nest part NAME needs a source, operations, or width and height`. + +`rotation_step_deg` is `0`, `90` or `180`. The default is `90`. A step of 90 tries 0° and then 90°. A step of 0 or 180 tries 0° only, because a rectangle's axis-aligned bounds repeat every 180°. Any other value is `rotation_step_deg must be 0, 90 or 180`. + +Parts are placed in input order. For each part the candidate corners are the margin, the far edge of each part already placed plus `gap`, and the far edge of each keep-out plus `tool_radius + gap`. That set is the exact bottom-left candidates for these rectangles. A uniform grid of `max(1 mm, gap)` is not used: a part whose size is not a multiple of that step can fit and still miss every grid point (two 10.2 mm parts on a 20.4 mm sheet). The first feasible pose wins. Order is ascending Y, then ascending X, then ascending rotation index. The same input produces the same placements. + +A fit that is short by at most 1e-6 mm counts as touching. Clearance, measured between the rectangles: + +- Each placed rectangle lies inside the sheet inset by `margin`. The tool radius is not added to that inset. Set `margin` at least as large as the tool radius when the cutter must stay on the sheet. +- Two parts clear each other by at least `gap` along X or along Y. The tool radius is not added again. Put it in `gap` when two cuts face each other. +- Each part clears each keep-out by at least `tool_radius + gap` along X or along Y. + +A part that does not fit fails with `nest: part NAME does not fit on the sheet (W x H mm)`. A rectangle outside the inset, a pair closer than `gap`, or a part closer than `tool_radius + gap` to a keep-out fails the run. Nothing is written. + +The IR stays a list of ordinary toolpaths. A placement is a rigid transform applied while the paths are built: rotate the part about its drawing bounds centre, counter-clockwise, then translate so the rotated bounds' minimum corner is the placement. Right angles swap coordinates exactly. Operations named by a part share that transform, so a profile, pocket or face sees the placed geometry. An operation that no part names stays in drawing coordinates. `examples/nest.job.json` engraves two 20 mm squares from `examples/nest.dxf` onto a 50 by 30 mm sheet with a 4 mm gap. They land at `(0, 0)` and `(24, 0)`. + +`nest` cannot be combined with `rotary`, with `kinematics`, or with an STL source. A STEP face is a drawing and can be nested. + +An explicit `stock` box is left where the file puts it. Nested paths are in the sheet frame. When the stock model is on and `stock` is omitted, the implicit box is still the unplaced drawing bounds. Put `stock` in the sheet frame when that check should match the placed paths. + +Placements are printed on stderr, one line per part: + +``` +nest: `left` at (0, 0) mm, 20 x 20 mm, rotation 0 deg +``` + +`--nest-out FILE.json` writes the same placements after the program is accepted. The file must differ from `-o`, `--ir`, `--stock-report` and `--stock-out`. The flag without `sheet` and `nest` is an error. The JSON tag is `campanion-nest/1`. Fields are `format`, `sheet` (`width`, `height`), `gap`, `margin`, `tool_radius`, `keep_out` and `placements`. Each placement has `name`, `x`, `y`, `rotation_deg`, `width` and `height`, in millimetres and degrees. Unknown keys on `sheet`, `nest`, a part or a keep-out are rejected. + +This output has not been proven on a machine. + ## Indexed 3+1 `rotary` turns the part about one table axis while the tool stays on machine +Z. The IR stays in the part frame: every pose is mapped back with the inverse rotation and tagged with the index angle. diff --git a/examples/nest.dxf b/examples/nest.dxf new file mode 100644 index 0000000..3f7c6ca --- /dev/null +++ b/examples/nest.dxf @@ -0,0 +1,114 @@ +0 +SECTION +2 +HEADER +9 +$INSUNITS +70 +4 +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LINE +8 +LEFT +10 +0 +20 +0 +11 +20 +21 +0 +0 +LINE +8 +LEFT +10 +20 +20 +0 +11 +20 +21 +20 +0 +LINE +8 +LEFT +10 +20 +20 +20 +11 +0 +21 +20 +0 +LINE +8 +LEFT +10 +0 +20 +20 +11 +0 +21 +0 +0 +LINE +8 +RIGHT +10 +0 +20 +0 +11 +20 +21 +0 +0 +LINE +8 +RIGHT +10 +20 +20 +0 +11 +20 +21 +20 +0 +LINE +8 +RIGHT +10 +20 +20 +20 +11 +0 +21 +20 +0 +LINE +8 +RIGHT +10 +0 +20 +20 +11 +0 +21 +0 +0 +ENDSEC +0 +EOF diff --git a/examples/nest.job.json b/examples/nest.job.json new file mode 100644 index 0000000..7414ead --- /dev/null +++ b/examples/nest.job.json @@ -0,0 +1,37 @@ +{ + "format": "campanion-job/1", + "source": { "path": "nest.dxf", "hash": "c8aed49f9577be3c" }, + "units": "mm", + "tools": [ + { "id": 1, "diameter": 6, "speed": 12000 } + ], + "operations": [ + { + "name": "left", + "type": "engrave", + "layers": ["LEFT"], + "tool_id": 1, + "depth": 1, + "step_down": 1, + "feeds": { "cut": 1000, "plunge": 300 } + }, + { + "name": "right", + "type": "engrave", + "layers": ["RIGHT"], + "tool_id": 1, + "depth": 1, + "step_down": 1, + "feeds": { "cut": 1000, "plunge": 300 } + } + ], + "sheet": { "width": 50, "height": 30 }, + "nest": { + "gap": 4, + "margin": 0, + "parts": [ + { "name": "left", "operations": ["left"] }, + { "name": "right", "operations": ["right"] } + ] + } +} diff --git a/examples/nest.placements.json b/examples/nest.placements.json new file mode 100644 index 0000000..2ebb43b --- /dev/null +++ b/examples/nest.placements.json @@ -0,0 +1,29 @@ +{ + "format": "campanion-nest/1", + "gap": 4.0, + "keep_out": [], + "margin": 0.0, + "placements": [ + { + "height": 20.0, + "name": "left", + "rotation_deg": 0.0, + "width": 20.0, + "x": 0.0, + "y": 0.0 + }, + { + "height": 20.0, + "name": "right", + "rotation_deg": 0.0, + "width": 20.0, + "x": 24.0, + "y": 0.0 + } + ], + "sheet": { + "height": 30.0, + "width": 50.0 + }, + "tool_radius": 3.0 +}