Cut: warn when it leaves a closed cavity instead of opening on a face (#7317) (#7319)

A Cut whose tool stops short of every face of a body leaves a closed void
inside it and said nothing. It now adds a feature warning naming the body
and the volume the cut took. The check lives in the document evaluation
and uses the kernel's existing shell_count, lumps and exact measure_mass:
a body's voids are its shells beyond one per piece. The operation lookup
it shares with the Join-apart note is now `feature_operation`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rbousigues88
2026-11-10 11:28:13 +00:00
committed by GitHub
parent 3cb35fe8c4
commit 10c8fb66c1
2 changed files with 104 additions and 12 deletions
+53 -12
View File
@@ -2745,6 +2745,7 @@ impl Model {
}
track_altered_sheets(&state, &mut next, f, &mut warning);
note_join_apart(&state, &next, f, &mut warning);
note_cut_cavity(&state, &next, f, &mut warning);
}
let ms = refused.map_or(t0.elapsed().as_secs_f64() * 1000.0, |x| x.ms);
let internal = matches!(r, Err(kernel::KernelError::Internal(_)));
@@ -3333,7 +3334,56 @@ fn track_altered_sheets(before: &ModelState, after: &mut ModelState, f: &Feature
/// when Join was left on by mistake. Says so when a feature's Join leaves the bodies it changed
/// in more pieces than they had (#4434). Combine joins the bodies it is given, apart or not.
fn note_join_apart(before: &ModelState, after: &ModelState, f: &Feature, warning: &mut Option<String>) {
let operation = match &f.kind {
if feature_operation(f) != Some(Operation::Join) {
return;
}
for (name, n) in join_pieces_added(before, after) {
append_warning(
warning,
format!("the joined piece doesn't touch `{name}`, so `{name}` is now in {n} separate pieces (New Body makes it a body of its own)"),
);
}
}
/// A Cut whose tool never reaches the outside of a body leaves a closed cavity in it: valid, but
/// rarely meant (a pocket whose tool was shortened until it stopped short of the face, #7317).
/// Says so, with the volume the cut took, when a feature's Cut leaves a body with more closed
/// voids than it had.
fn note_cut_cavity(before: &ModelState, after: &ModelState, f: &Feature, warning: &mut Option<String>) {
if feature_operation(f) != Some(Operation::Cut) {
return;
}
for was in &before.bodies {
let Some(now) = after.body(&was.name) else { continue };
// Only a body with more shells can have gained a void, and it is the cheap test.
if now.body.shell_count() <= was.body.shell_count() || now.body.same_shape(&was.body) || voids(&now.body) <= voids(&was.body) {
continue;
}
// The exact volumes before and after: what the cut took, the cavity when it took nothing else.
let removed = match (kernel::measure_mass(&was.body), kernel::measure_mass(&now.body)) {
(Ok(a), Ok(b)) if (a.volume - b.volume).is_finite() && a.volume > b.volume => {
format!(", taking {} mm³", show_mm(((a.volume - b.volume) * 100.0).round() / 100.0))
}
_ => String::new(),
};
append_warning(
warning,
format!(
"the cut leaves a closed cavity inside `{}` that opens on no face{removed} (extend the tool through the face to open it)",
now.name
),
);
}
}
/// The closed voids of a body: its shells beyond the one outside each of its pieces.
fn voids(b: &Body) -> usize {
b.shell_count().saturating_sub(b.lumps().map_or(1, |l| l.len()))
}
/// The operation of a feature that makes or changes bodies with one.
fn feature_operation(f: &Feature) -> Option<Operation> {
Some(match &f.kind {
FeatureKind::Extrude { operation, .. }
| FeatureKind::Revolve { operation, .. }
| FeatureKind::Box { operation, .. }
@@ -3348,17 +3398,8 @@ fn note_join_apart(before: &ModelState, after: &ModelState, f: &Feature, warning
| FeatureKind::BoundaryFill { operation, .. }
| FeatureKind::Thicken { operation, .. }
| FeatureKind::SurfaceNetwork { operation, .. } => *operation,
_ => return,
};
if operation != Operation::Join {
return;
}
for (name, n) in join_pieces_added(before, after) {
append_warning(
warning,
format!("the joined piece doesn't touch `{name}`, so `{name}` is now in {n} separate pieces (New Body makes it a body of its own)"),
);
}
_ => return None,
})
}
/// The bodies a Join from `before` to `after` left in more separate pieces than it found, with
@@ -1616,3 +1616,54 @@ fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
}
/// #7317: a cut that opens on no face leaves a closed cavity and says so, with the void's volume.
/// A cut that opens on the outside, one that touches no material and a cup shelled on purpose
/// say nothing.
#[test]
fn cut_that_leaves_a_closed_cavity_says_so() {
let plot = || {
let mut s = Session::default();
s.execute("solid.box", &json!({"corner": [-10, -20, 0], "length": 20, "width": 20, "height": 10, "operation": "new", "body_name": "Plot"}))
.unwrap();
s
};
let magnet = |height: f64| json!({"base": [0, -2.2, 5], "axis": [0, 1, 0], "radius": 3.1, "height": height, "operation": "cut", "targets": ["Plot"], "name": "Logement"});
// Through the y = 0 face: one shell, nothing to say.
let mut s = plot();
let open = s.execute("solid.cylinder", &magnet(2.3)).unwrap();
assert!(open["warning"].is_null(), "{open}");
assert_eq!(s.model.state().body("Plot").map(|b| b.body.shell_count()), Some(1));
// Shorter, it stops inside the body: a cavity of pi * 3.1² * 2.0 mm³, told in the reply and on the feature.
let mut s = plot();
let closed = s.execute("solid.cylinder", &magnet(2.0)).unwrap();
assert_eq!(s.model.state().body("Plot").map(|b| b.body.shell_count()), Some(2));
let note = closed["warning"].as_str().unwrap_or_default();
assert!(note.contains("closed cavity") && note.contains("60.38"), "{closed}");
assert_eq!(s.model.results.last().and_then(|r| r.warning.as_deref()), closed["warning"].as_str());
// The same feature edited from the opening height to the closed one.
let mut s = plot();
s.execute("solid.cylinder", &magnet(2.3)).unwrap();
s.execute("timeline.edit", &json!({"feature": "Logement", "set": {"height": "2.0"}})).unwrap();
assert!(s.model.results.last().and_then(|r| r.warning.as_deref()).is_some_and(|w| w.contains("closed cavity")));
// A cut that misses the body changes nothing and says nothing here.
let mut s = plot();
let away = s
.execute(
"solid.cylinder",
&json!({"base": [0, 50, 5], "axis": [0, 1, 0], "radius": 3.1, "height": 2.0, "operation": "cut", "targets": ["Plot"]}),
)
.unwrap();
assert!(!away["warning"].as_str().unwrap_or_default().contains("closed cavity"), "{away}");
// A cup made by cutting from its top: open, no warning.
let mut s = plot();
let cup = s
.execute("solid.box", &json!({"length": 16, "width": 16, "height": 10, "center": [0, -10, 6], "operation": "cut", "targets": ["Plot"]}))
.unwrap();
assert!(!cup["warning"].as_str().unwrap_or_default().contains("closed cavity"), "{cup}");
}