mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
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:
+53
-12
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user