diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 185376c..30ca01a 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec { pub const PARTITION: CommandSpec = CommandSpec { id: "geometry.partition", label: "Partition", - params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a plane that divides no body, or a listed body it does not divide, is an error"#, + params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, form: Some(super::forms::PARTITION), kind: CommandKind::Model, requires: &[Requirement::Design, Requirement::DatumPlane], diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 142f1e6..7b4a51c 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -708,6 +708,32 @@ mod tests { assert!(r["energy_balance"].as_f64().unwrap() < 0.05, "{r}"); } + /// A partition the kernel fails to make is reported as the kernel's failure, never as a plane + /// that does not divide the body (#283): x = 148 plainly crosses the crank's half y > 0, whose + /// cut fails in the kernel's boolean, and the same plane cuts the half y < 0. + #[test] + fn a_kernel_failure_to_partition_is_reported_as_such() { + use fieldcraft_geometry::Cut; + let design = fieldcraft_geometry::open_design(&fieldcraft_geometry::script_design(CRANK_ARM).unwrap()).unwrap(); + let cut = |name: &str, point: [f64; 3], normal: [f64; 3], bodies: &[&str]| Cut { + name: name.into(), + point, + normal, + bodies: bodies.iter().map(|b| b.to_string()).collect(), + }; + let half = cut("Half", [0.0; 3], [0.0, 1.0, 0.0], &[]); + let p = design.partitioned(&[half.clone(), cut("X148", [148.0, 0.0, 0.0], [1.0, 0.0, 0.0], &[CRANK_HALF])]).unwrap(); + assert!(p.errors.is_empty(), "{:?}", p.errors); + let p = design.partitioned(&[half, cut("X148", [148.0, 0.0, 0.0], [1.0, 0.0, 0.0], &["CrankArm/b"])]).unwrap(); + // Should the kernel learn to make this cut, the cut is all there is to check. + if p.cuts[1].split.is_empty() { + let errors: Vec<&str> = p.errors.iter().filter(|e| e.feature == "partition X148").map(|e| e.message.as_str()).collect(); + assert!(matches!(errors.as_slice(), [m] if m.starts_with("the kernel could not cut `CrankArm/b`")), "{errors:?}"); + } else { + assert_eq!(p.cuts[1].split, ["CrankArm/b"]); + } + } + /// `file.sample NAME`, checked clean: ready, no errors or warnings. fn open(name: &str) -> Session { let mut s = Session::default(); diff --git a/crates/geometry/src/partition.rs b/crates/geometry/src/partition.rs index 76810c5..487a347 100644 --- a/crates/geometry/src/partition.rs +++ b/crates/geometry/src/partition.rs @@ -89,8 +89,8 @@ fn plane_of(cut: &Cut) -> Result<(Plane, [f64; 3])> { impl Design { /// The design as opened, cut by `cuts` in order (the partitions replace any applied before). - /// A cut that names a body the design does not have, or that does not divide a body it - /// names, is listed in [`Design::errors`] (feature `partition `), as a feature of the + /// A cut that names a body the design does not have, that does not divide a body it names, + /// or that the kernel fails to make on any body it applies to, is listed in [`Design::errors`] (feature `partition `), as a feature of the /// design that failed to rebuild; the rest still apply. pub fn partitioned(&self, cuts: &[Cut]) -> Result { if cuts.len() > MAX_CUTS { @@ -116,12 +116,12 @@ impl Design { continue; } match split(&body, plane, *n, cut)? { - Some((cells, faces)) => { + Split::Cells(cells, faces) => { report.split.push(body.name.clone()); report.faces += faces; next.extend(cells); } - None => { + Split::Misses => { if listed { errors.push(FeatureError { feature: feature.clone(), @@ -131,6 +131,16 @@ impl Design { } next.push(body); } + // Listed or not: the plane may well divide the body, and a cut that silently + // leaves it whole would be mistaken for one that does not reach it (#283). + Split::Failed(e) => { + errors.push(FeatureError { + feature: feature.clone(), + message: format!("the kernel could not cut `{}` ({e})", body.name), + bodies: vec![body.name.clone()], + }); + next.push(body); + } } if next.len() > MAX_CELLS { return Err(Error::Invalid(format!("the partitions make at most {MAX_CELLS} cells"))); @@ -143,12 +153,22 @@ impl Design { } } -/// Cut one body: its cells, named, and how many cut faces they have; `None` when the plane does -/// not divide it. -fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result, usize)>> { +/// What cutting one body gave. +enum Split { + /// Its cells, named, and how many cut faces they have. + Cells(Vec, usize), + /// The plane does not divide it. + Misses, + /// The kernel failed to cut it: its message. + Failed(String), +} + +/// Cut one body. +fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result { let parts = match guard("partition", || solidcraft_kernel::split_by_plane(&body.body, plane)) { Ok(p) if p.len() >= 2 => p, - _ => return Ok(None), + Ok(_) => return Ok(Split::Misses), + Err(e) => return Ok(Split::Failed(e.to_string())), }; let size = body.size()?; let tol = (MATCH_TOLERANCE * size).max(1e-4); @@ -180,7 +200,7 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result