Design: a body knows what made it, so "Delete Body" can exist

Reported by Tommaso: select a body, and there is no Delete in the offer. Two independent
faults stacked behind that.

FIRST, clicking a body never selects the body. Whole-body picking is deliberately unbound
(DesignSketchTool.cpp) pending the rubber band, so a viewport click only ever yields
Face/Edge/Vertex. The offer therefore saw face_planar, and "delete" accepted body_solid but
no face kind, so the row was filtered out entirely — while the status line read "Body 1
face 0 selected", which actively teaches the wrong model.

SECOND, even selecting the body from the Bodies list, Delete refused in red: "Select the
FEATURE that created this body". CadBody had no link back to its maker, so the offer was
advertising a verb it could not perform — worse than the action:null rows fixed earlier this
session, because this one is ENABLED and its refusal reads like user error.

CadBody::source_feature fixes the second. It is stamped in ONE place, the recompute loop,
and the rule is just "still unset?". That is sufficient because of an invariant worth
stating: no feature ever replaces a whole CadBody. Every in-place op writes only .shape
(boolean, cut, mirror-fuse, transform, dress-up — all 8 sites checked), so a body keeps the
stamp it was born with; a consumed body is erased outright, taking its stamp with it; and
the only bodies still at -1 are the ones the current feature just pushed. A feature type
added later needs no change here as long as it keeps to that invariant.

"Delete Body" fixes the first, sitting beside "Delete Face" in Modify and reachable by
pointing at any face/edge/vertex. The two names cannot be confused, and "delete" gave up the
body kinds so both can never appear for one selection. Deleting a body removes the feature
that made it, which is a real edit to the recipe, so it asks first and NAMES the feature — a
body vanishing from the viewport is not evidence of which feature went, and this is the one
action here that cannot be eyeballed.

Multi-body delete is NOT offered. bodies_2 was in the first draft of the verb; the handler
deletes exactly one body, so a two-body selection would have silently deleted whichever was
m_sel_solid_body. Caught before it reached a binary, at the cost of one rebuild.

Verified on BOTH rigs, full round trip: click a face -> Modify > Delete Body -> "Delete
Extrude2?" -> body gone, Sketch1 correctly left behind, panel falls back to the idle hint ->
Undo -> Extrude2 and Body 1 restored.
This commit is contained in:
Tommaso Bianchi
2026-08-02 09:41:58 +02:00
parent 34eb4224a1
commit b2654ebd8a
6 changed files with 97 additions and 8 deletions
+25 -3
View File
@@ -1059,14 +1059,11 @@
"feature": null,
"mcp": null,
"accepts": [
"body_solid",
"body_sheet",
"sk_loop",
"art",
"datum_plane",
"datum_axis",
"coordsys",
"bodies_2",
"sk_line",
"sk_arc",
"sk_point",
@@ -1079,6 +1076,31 @@
"icon": "design_delete",
"hint": "Delete what is selected"
},
{
"id": "delete_body",
"name": "Delete Body",
"slot": "modify",
"key": null,
"action": "btn:delete_body",
"refusal": null,
"accepts": [
"body_solid",
"body_sheet",
"face_planar",
"face_cyl",
"face_other",
"edge_str",
"edge_circ",
"vertex"
],
"needs": {
"bodies": 1
},
"mode": "model",
"family": null,
"icon": "design_delete",
"hint": "Delete this whole body — removes the feature it was made from"
},
{
"id": "sk_line_t",
"name": "Line",
+15 -3
View File
@@ -3208,15 +3208,27 @@ bool CadDocument::recompute()
for (CadFeature& f : features)
for (const auto& [field, e] : f.expr)
assign_field(f, field, eval_expr(e, varvals));
for (CadFeature& f : features) {
for (size_t fi = 0; fi < features.size(); ++fi) {
CadFeature& f = features[fi];
if (!f.enabled) continue;
if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude
if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path
if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand
if (f.type == CadFeatureType::Axis) continue; // datum axis
if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system
if (f.type == CadFeatureType::Project) { apply_project(built, f); continue; }
route_feature(built, f);
if (f.type == CadFeatureType::Project) { apply_project(built, f); }
else { route_feature(built, f); }
// Record which feature made each body. "Still unset?" is the whole rule, and it is
// sufficient because of an invariant worth stating: NO feature ever replaces a whole
// CadBody. Every in-place op writes only `.shape` (boolean, cut, mirror-fuse,
// transform, dress-up — checked, all 8 sites), so an existing body keeps the stamp it
// was born with; a consumed body is erased outright, taking its stamp with it; and
// the only bodies still at -1 here are the ones THIS feature just pushed. That also
// means a feature type added later needs no change here, as long as it keeps to the
// same invariant.
for (CadBody& b : built)
if (b.source_feature < 0)
b.source_feature = int(fi);
}
} catch (const Standard_Failure& e) {
// OCCT raises Standard_Failure (NOT a std::exception) — must be caught
+6
View File
@@ -400,6 +400,12 @@ struct CadBody {
// falls back to the auto body-index palette. Carried across recompute() by body index.
bool has_color{false};
ColorRGBA color;
// Index into `features` of the feature that CREATED this body, or -1. A body is a
// recomputed result, so without this there is no way back to its maker and "delete this
// body" cannot be expressed at all — the GUI could only answer "select the FEATURE that
// created this body". Stamped in one place, the recompute loop; see the note there for
// why a single "still unset?" test is sufficient and stays correct for new feature types.
int source_feature{-1};
};
// OCCT-only feature tree backing the Design tab. No GUI dependencies (lives in libslic3r).
+3 -2
View File
@@ -123,7 +123,8 @@ static const OfferVerb kOfferVerbs[] = {
{"edit_feature", "Edit", 7, nullptr, "btn:edit", nullptr, 0x00007d8eu, 0, 0, false, false, nullptr, "design_edit", "Reopen the selected feature to change what it was made from"},
{"delete_face", "Delete Face", 7, nullptr, "fly:dressup#3", "Delete Face needs a body — add or import one first", 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", "Remove faces from a body and heal the solid"},
{"colour", "Colour", 7, nullptr, "btn:colour", nullptr, 0x00000180u, 1, 0, false, false, nullptr, "color_palette", "Set the selected body's display colour"},
{"delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f7f80u, 0, 0, false, false, nullptr, "design_delete", "Delete what is selected"},
{"delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", "Delete what is selected"},
{"delete_body", "Delete Body", 7, nullptr, "btn:delete_body", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "design_delete", "Delete this whole body — removes the feature it was made from"},
{"sk_line_t", "Line", 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, "Line", "design_line", "Line — click start, then end"},
{"sk_polyline", "Polyline", 0, nullptr, "fly:design_line#1", nullptr, 0x000f8000u, 0, 0, false, true, "Line", "design_polyline", "Click points; click first / right-click to close the loop"},
{"sk_rect", "Corner rectangle", 0, "R", "key:R", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect", "Rectangle — click two opposite corners"},
@@ -168,7 +169,7 @@ static const OfferVerb kOfferVerbs[] = {
{"sk_extend", "Extend", 7, "X", "key:X", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_extend", "Extend — click a line/arc to extend it"},
{"sk_delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_delete", "Delete the selected sketch entities"},
};
static const int kOfferVerbCount = 86;
static const int kOfferVerbCount = 87;
}} // namespace Slic3r::GUI
+44
View File
@@ -938,6 +938,7 @@ DesignPanel::DesignPanel(wxWindow* parent)
fadd("color", b_color);
m_verb_actions["btn:colour"] = [this] { on_set_body_color(); };
m_verb_actions["btn:delete"] = [this] { on_delete_feature(); };
m_verb_actions["btn:delete_body"] = [this] { on_delete_body(); };
m_verb_actions["btn:edit"] = [this] { on_edit_feature(); };
m_verb_actions["btn:mass"] = [this] { on_mass_properties(); };
@@ -6059,6 +6060,49 @@ void DesignPanel::on_new_design()
set_status_ok();
}
// The verb the offer names when you point at a body, or at any face/edge/vertex of one. A body
// is a recomputed RESULT, so what actually gets deleted is the feature that created it
// (CadBody::source_feature). That is an edit to the recipe and can take other features with it,
// so it asks first and NAMES the feature: a body disappearing from the viewport is not by itself
// evidence of which feature went, and this is the one action here that cannot be eyeballed.
void DesignPanel::on_delete_body()
{
const int nb = int(m_doc.bodies.size());
if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) {
m_status->SetForegroundColour(wxColour(235, 110, 110));
set_status(_L("Select a body first"));
m_status->Refresh();
return;
}
const int src = m_doc.bodies[m_sel_solid_body].source_feature;
if (src < 0 || src >= int(m_doc.features.size())) {
// Only reachable for a body no feature claims — a stale recipe, or a feature type that
// broke the "never replace a whole CadBody" invariant recompute() relies on.
m_status->SetForegroundColour(wxColour(235, 110, 110));
set_status(_L("This body has no feature to delete — use New Design to start over"));
m_status->Refresh();
return;
}
const std::string& raw = m_doc.features[src].name;
const wxString fname = raw.empty() ? wxString::Format(_L("feature %d"), src + 1)
: wxString::FromUTF8(raw);
if (wxMessageBox(wxString::Format(
_L("Delete %s?\n\nThat is the feature this body was made from. "
"Features built on it may be removed or stop working."), fname),
_L("Delete body"), wxYES_NO | wxNO_DEFAULT | wxICON_QUESTION, this) != wxYES)
return;
// A card left open over a feature that is about to vanish goes stale — same reason
// on_delete_feature() closes it.
if (m_active != Tool::None || m_edit_index >= 0) {
reset_edit_state();
close_tool();
}
m_doc.checkpoint(); // undo boundary: deleting a body's feature
m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; // the selection is about to
m_sel_solid_vertex = false; // name a body that is gone
after_tree_edit(m_doc.remove_feature(src));
}
void DesignPanel::on_delete_feature()
{
// A Body row has no directly-removable feature (bodies are recomputed results); guide the
+4
View File
@@ -175,6 +175,10 @@ private:
// Feature-tree editing (Onshape-style): act on the selected tree row.
void on_delete_feature();
// "Delete Body" — the geometry-first counterpart, reached by pointing at a body or any of
// its faces. Resolves the body to the feature that created it and removes THAT, because a
// body is a recomputed result and has nothing else to delete.
void on_delete_body();
void on_new_design();
void on_move_feature(int delta); // -1 = up, +1 = down
void on_toggle_visibility(); // show/hide the selected feature (CadFeature::enabled)