Design tab: pick several solid edges and dress them in one feature

A solid edge could only be picked one at a time, and Fillet/Chamfer took
either that one edge or a whole face group. Rounding three chosen edges
meant three features, whose edge ids each resolve against a body the
previous one had already changed.

- Shift+click (or Ctrl+click) on an edge of the body already picked adds
  it to the selection, or removes it; the same modifiers that extend a
  sketch selection. The whole set is highlighted. A plain click replaces
  it, as before.
- Fillet/Chamfer dresses every picked edge in ONE feature at one size,
  all ids resolved against the same body. The card says "3 edges", the
  status line and the offer header name the count.
- CadFeature gains dressup_edges, appended at the end of the framed
  recipe, so existing projects load and rebuild unchanged. dressup_edge
  keeps the first edge, so an older build opening a newer project still
  dresses that edge instead of falling back to the face group.
- The MCP fillet/chamfer verbs take `edge` as one id or an array.

Tests: a fillet on the four picked top edges equals the Top face group
exactly; the list survives save/load; two opposite chamfers remove
exactly twice one; a missing id fails with a reason. The truncated-
recipe test accounts for the new tail field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK4VgguuCAk2hZLWgcjJb9
This commit is contained in:
Claude
2026-09-30 08:34:33 +00:00
parent f4cdeb1706
commit 3c218591bc
13 changed files with 297 additions and 42 deletions
+5
View File
@@ -39,6 +39,11 @@ tool"*. It is also where a refusal explains itself.
face → edge → body.
- A click near a corner takes the corner, not the face behind it.
- Left-drag sweeps a rubber band, and a rubber band takes the whole body.
- Shift+click (or Ctrl+click) on an edge of the body already picked adds that edge, or removes
it if it is already picked. The set belongs to one body. **Fillet / Chamfer** then dresses
every picked edge in one feature, at one size. The edge ids are resolved together against
the same body. A chain of single-edge features would resolve each against a body the
previous one had already changed.
- An open sketch line can be clicked, even where it bounds a region.
- Double-click a sketch stroke to edit it — the gesture belongs on the geometry.
- Editing a dimension's value **updates** that dimension instead of adding a second one next
+30 -4
View File
@@ -928,6 +928,19 @@ int CadDocument::add_fillet(double radius, int edge_id, const std::string& name)
return int(features.size()) - 1;
}
int CadDocument::add_fillet(double radius, const std::vector<int>& edge_ids, const std::string& name)
{
if (edge_ids.size() == 1) return add_fillet(radius, edge_ids.front(), name);
CadFeature f;
f.type = CadFeatureType::Fillet;
f.name = name;
f.dressup_size = radius;
f.dressup_edges = edge_ids;
f.dressup_edge = edge_ids.empty() ? -1 : edge_ids.front();
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_chamfer(double distance, FaceGroup faces, const std::string& name)
{
CadFeature f;
@@ -950,6 +963,19 @@ int CadDocument::add_chamfer(double distance, int edge_id, const std::string& na
return int(features.size()) - 1;
}
int CadDocument::add_chamfer(double distance, const std::vector<int>& edge_ids, const std::string& name)
{
if (edge_ids.size() == 1) return add_chamfer(distance, edge_ids.front(), name);
CadFeature f;
f.type = CadFeatureType::Chamfer;
f.name = name;
f.dressup_size = distance;
f.dressup_edges = edge_ids;
f.dressup_edge = edge_ids.empty() ? -1 : edge_ids.front();
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_hole(double diameter, double depth, bool through,
double x, double y, const SketchPlane& plane,
const std::string& name)
@@ -2886,15 +2912,15 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
}
case CadFeatureType::Fillet:
if (!have_body) throw std::runtime_error("fillet needs a body");
if (f.dressup_edge >= 0)
result = GeometryEngine::apply_fillet(result, f.dressup_size, f.dressup_edge);
if (f.dressup_edge >= 0 || !f.dressup_edges.empty())
result = GeometryEngine::apply_fillet(result, f.dressup_size, f.dressup_edge_ids());
else
result = GeometryEngine::apply_fillet(result, f.dressup_size, f.face_group);
break;
case CadFeatureType::Chamfer:
if (!have_body) throw std::runtime_error("chamfer needs a body");
if (f.dressup_edge >= 0)
result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.dressup_edge);
if (f.dressup_edge >= 0 || !f.dressup_edges.empty())
result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.dressup_edge_ids());
else
result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.face_group);
break;
+17 -2
View File
@@ -112,6 +112,17 @@ struct CadFeature {
double dressup_size{1.0}; // fillet radius or chamfer distance
FaceGroup face_group{FaceGroup::All};
int dressup_edge{-1}; // global edge id for edge-targeted fillet/chamfer; -1 = use face_group
// Several picked edges dressed in ONE operation, all resolved against the same body, so
// the ids cannot drift the way they do across a chain of single-edge features. When set,
// dressup_edge holds the first of them: a build that predates the list still dresses that
// one edge instead of falling back to the whole face group.
std::vector<int> dressup_edges;
// The edges this dress-up targets: the list, else the single edge, else none (face group).
std::vector<int> dressup_edge_ids() const {
if (!dressup_edges.empty()) return dressup_edges;
if (dressup_edge >= 0) return { dressup_edge };
return {};
}
// Hole params (positioned circular cut into the current body)
double hole_diameter{5};
@@ -383,7 +394,8 @@ struct CadFeature {
expr,
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip,
coordsys_face_kind, coordsys_face_edges,
thread_major_nominal, pattern_inclusive);
thread_major_nominal, pattern_inclusive,
dressup_edges);
}
template<class Archive>
void load(Archive& ar) {
@@ -423,7 +435,8 @@ struct CadFeature {
expr,
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip,
coordsys_face_kind, coordsys_face_edges,
thread_major_nominal, pattern_inclusive);
thread_major_nominal, pattern_inclusive,
dressup_edges);
imported_solid = brep_from_string(brep);
}
// The pre-framing (v4) layout, FROZEN. A v4 recipe is one flat stream with no per-feature
@@ -596,8 +609,10 @@ public:
BooleanMode mode, const std::string& name);
int add_fillet(double radius, FaceGroup faces, const std::string& name);
int add_fillet(double radius, int edge_id, const std::string& name);
int add_fillet(double radius, const std::vector<int>& edge_ids, const std::string& name);
int add_chamfer(double distance, FaceGroup faces, const std::string& name);
int add_chamfer(double distance, int edge_id, const std::string& name);
int add_chamfer(double distance, const std::vector<int>& edge_ids, const std::string& name);
int add_hole(double diameter, double depth, bool through,
double x, double y, const SketchPlane& plane,
const std::string& name);
+23 -9
View File
@@ -364,28 +364,42 @@ TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double dis
TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, int edge_id)
{
if (radius <= 0.001) throw std::runtime_error("the fillet radius must be greater than 0");
return apply_fillet(solid, radius, std::vector<int>{ edge_id });
}
TopoDS_Edge edge = edge_by_index(solid, edge_id);
if (edge.IsNull()) throw std::runtime_error("apply_fillet: invalid edge id");
TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, int edge_id)
{
return apply_chamfer(solid, distance, std::vector<int>{ edge_id });
}
TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, const std::vector<int>& edge_ids)
{
if (radius <= 0.001) throw std::runtime_error("the fillet radius must be greater than 0");
if (edge_ids.empty()) throw std::runtime_error("apply_fillet: no edge picked");
BRepFilletAPI_MakeFillet mk(solid);
mk.Add(radius, edge);
for (int id : edge_ids) {
TopoDS_Edge edge = edge_by_index(solid, id);
if (edge.IsNull()) throw std::runtime_error("apply_fillet: invalid edge id");
mk.Add(radius, edge);
}
mk.Build();
if (!mk.IsDone()) throw std::runtime_error("apply_fillet: OCCT fillet failed");
return mk.Shape();
}
TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, int edge_id)
TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, const std::vector<int>& edge_ids)
{
if (distance <= 0.001) throw std::runtime_error("the chamfer distance must be greater than 0");
TopoDS_Edge edge = edge_by_index(solid, edge_id);
if (edge.IsNull()) throw std::runtime_error("apply_chamfer: invalid edge id");
if (edge_ids.empty()) throw std::runtime_error("apply_chamfer: no edge picked");
BRepFilletAPI_MakeChamfer mk(solid);
mk.Add(distance, edge);
for (int id : edge_ids) {
TopoDS_Edge edge = edge_by_index(solid, id);
if (edge.IsNull()) throw std::runtime_error("apply_chamfer: invalid edge id");
mk.Add(distance, edge);
}
mk.Build();
if (!mk.IsDone()) throw std::runtime_error("apply_chamfer: OCCT chamfer failed");
+5
View File
@@ -120,6 +120,11 @@ public:
FaceGroup faces = FaceGroup::All);
static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance,
int edge_id);
// Several edges in one operation, all ids resolved against `solid`.
static TopoDS_Shape apply_fillet(const TopoDS_Shape& solid, double radius,
const std::vector<int>& edge_ids);
static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance,
const std::vector<int>& edge_ids);
static TriangleMesh tessellate(const TopoDS_Shape& shape,
double linear_deflection = 0.003,
+5
View File
@@ -938,6 +938,11 @@ void DesignCanvas::set_on_pattern_changed(std::function<void(double)> cb)
m_sketch_tool.on_pattern_changed = std::move(cb);
}
std::vector<int> DesignCanvas::selected_solid_edges() const
{
return m_sketch_tool.selected_edges();
}
void DesignCanvas::set_on_solid_selection_changed(std::function<void(int, int, int, int)> cb)
{
m_sketch_tool.on_solid_selection_changed = std::move(cb);
+1
View File
@@ -122,6 +122,7 @@ public:
const std::vector<bool>* visible = nullptr,
const std::vector<Transform3d>* xform = nullptr);
void set_on_solid_selection_changed(std::function<void(int, int, int, int)> cb);
std::vector<int> selected_solid_edges() const; // the Shift/Ctrl+click edge set, last-clicked at the end
void select_body(int body); // Parts-list -> highlight a whole body by index
// Effective display colour of a body: the per-body override (Color tool) when set,
// otherwise the auto body-index palette. Single source of truth shared with reload().
+36 -13
View File
@@ -3663,6 +3663,7 @@ DesignPanel::DesignPanel(wxWindow* parent)
m_sel_solid_body = (level >= 1) ? body : -1;
m_sel_solid_face = (level == 2) ? face : -1; // 4 = Vertex: a corner is not its face
m_sel_solid_edge = (level == 3) ? edge : -1;
m_sel_solid_edges = (level == 3) ? m_viewport->selected_solid_edges() : std::vector<int>();
m_sel_solid_vertex = (level == 4);
// Keep the hit face even at whole-body level: the cycle's first click means "this body",
// but the user pointed AT a face and a sketch should be able to use it. 3a2.
@@ -3851,7 +3852,9 @@ DesignPanel::DesignPanel(wxWindow* parent)
set_status(level == 4 ? bodytag + _L("vertex selected — click again for the whole body")
: level == 1 ? bodytag + _L("selected (whole body) — right-click for what applies to it")
: level == 2 ? bodytag + wxString::Format(_L("face %d selected — right-click to push/pull it, or click again for the whole body"), face)
: level == 3 ? bodytag + wxString::Format(_L("edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body"), edge)
: level == 3 && m_sel_solid_edges.size() > 1
? bodytag + wxString::Format(_L_PLURAL("%zu edge selected — Shift+click adds or removes one, Fillet/Chamfer dresses them all", "%zu edges selected — Shift+click adds or removes one, Fillet/Chamfer dresses them all", m_sel_solid_edges.size()), m_sel_solid_edges.size())
: level == 3 ? bodytag + wxString::Format(_L("edge %d selected — Shift+click to add edges, or click again for the whole body"), edge)
: _L("Nothing selected"));
m_status->Refresh();
});
@@ -5064,13 +5067,14 @@ void DesignPanel::on_add_dressup()
bool fillet = (m_dressup_type->GetSelection() == 0);
m_feature_counter++;
// A click-selected solid edge targets THAT edge; otherwise dress the whole face-group.
// Click-selected solid edges are the target; otherwise dress the whole face-group.
int didx = -1;
if (m_sel_solid_edge >= 0) {
const std::vector<int> edges = dressup_edges();
if (!edges.empty()) {
if (fillet)
didx = m_doc.add_fillet(sz, m_sel_solid_edge, feature_name(_L("Fillet")));
didx = m_doc.add_fillet(sz, edges, feature_name(_L("Fillet")));
else
didx = m_doc.add_chamfer(sz, m_sel_solid_edge, feature_name(_L("Chamfer")));
didx = m_doc.add_chamfer(sz, edges, feature_name(_L("Chamfer")));
} else if (fillet)
didx = m_doc.add_fillet(sz, fg, feature_name(_L("Fillet")));
else
@@ -6383,8 +6387,13 @@ wxString DesignPanel::offer_header(int kind) const
case OfferSel::FacePlanar: return wxString::Format(_L("Flat face %d of %s"), m_sel_solid_face, body());
case OfferSel::FaceCyl: return wxString::Format(_L("Cylindrical face %d of %s"), m_sel_solid_face, body());
case OfferSel::FaceOther: return wxString::Format(_L("Face %d of %s"), m_sel_solid_face, body());
case OfferSel::EdgeStr: return wxString::Format(_L("Straight edge %d of %s"), m_sel_solid_edge, body());
case OfferSel::EdgeCirc: return wxString::Format(_L("Circular edge %d of %s"), m_sel_solid_edge, body());
case OfferSel::EdgeStr:
case OfferSel::EdgeCirc:
if (dressup_edges().size() > 1)
return wxString::Format(_L_PLURAL("%zu edge of %s", "%zu edges of %s", dressup_edges().size()), dressup_edges().size(), body());
return OfferSel(kind) == OfferSel::EdgeStr
? wxString::Format(_L("Straight edge %d of %s"), m_sel_solid_edge, body())
: wxString::Format(_L("Circular edge %d of %s"), m_sel_solid_edge, body());
case OfferSel::Vertex: return wxString::Format(_L("Vertex of %s"), body());
case OfferSel::SkLoop: return _L("Sketch profile");
case OfferSel::SkNone: return _L("Sketch — nothing selected");
@@ -8999,7 +9008,8 @@ void DesignPanel::load_feature_into_dialog(const CadFeature& f)
m_dressup_type->SetSelection(f.type == CadFeatureType::Fillet ? 0 : 1);
m_dressup_size->SetValue(f.dressup_size);
m_face_group->SetSelection(static_cast<int>(f.face_group));
m_sel_solid_edge = f.dressup_edge; // preserve edge-targeting on re-edit
m_sel_solid_edges = f.dressup_edge_ids(); // preserve edge-targeting on re-edit
m_sel_solid_edge = m_sel_solid_edges.empty() ? -1 : m_sel_solid_edges.back();
m_sel_solid_body = f.target_body; // preserve which body on re-edit
sync_dressup_target(); // and say which of the two the re-edit is targeting
break;
@@ -9658,8 +9668,12 @@ CadFeature DesignPanel::build_candidate(Tool t) const
: CadFeatureType::Chamfer;
f.dressup_size = m_dressup_size->GetValue();
f.face_group = static_cast<FaceGroup>(m_face_group->GetSelection());
// A click-selected solid edge overrides the face-group: dress THAT edge.
f.dressup_edge = m_sel_solid_edge; // -1 when no edge picked
// Click-selected solid edges override the face-group: dress THOSE edges.
{
const std::vector<int> edges = dressup_edges();
f.dressup_edge = edges.empty() ? -1 : edges.front(); // -1 when no edge picked
f.dressup_edges = edges.size() > 1 ? edges : std::vector<int>();
}
break;
case Tool::Hole:
f.type = CadFeatureType::Hole;
@@ -9928,12 +9942,21 @@ CadFeature DesignPanel::build_candidate(Tool t) const
// user picked in the viewport, or the face-group. build_dressup reads m_sel_solid_edge first and
// only falls back to the group, so when an edge is picked the group combo is inert — grey it out
// rather than leave it showing a value it will not use.
std::vector<int> DesignPanel::dressup_edges() const
{
if (m_sel_solid_edge < 0) return {};
if (!m_sel_solid_edges.empty() && m_sel_solid_edges.back() == m_sel_solid_edge)
return m_sel_solid_edges;
return { m_sel_solid_edge };
}
void DesignPanel::sync_dressup_target()
{
if (m_dressup_edge_label == nullptr) return;
const bool have_edge = (m_sel_solid_edge >= 0);
m_dressup_edge_label->SetLabel(have_edge
? wxString::Format(_L("Edge %d"), m_sel_solid_edge)
const size_t n = dressup_edges().size();
const bool have_edge = n > 0;
m_dressup_edge_label->SetLabel(n > 1 ? wxString::Format(_L_PLURAL("%zu edge", "%zu edges", n), n)
: have_edge ? wxString::Format(_L("Edge %d"), m_sel_solid_edge)
: _L("(no edge picked — group below)"));
if (m_face_group != nullptr) m_face_group->Enable(!have_edge);
m_dressup_edge_label->Refresh();
+5
View File
@@ -329,6 +329,7 @@ private:
void update_extrude_gizmo();
void update_fillet_gizmo(); // edge-anchored radius arrow (Dressup card)
void sync_dressup_target(); // Dressup card: show picked edge vs group, gate the combo
std::vector<int> dressup_edges() const; // the picked edge(s) a dress-up targets; empty = face group
void update_hole_gizmo(); // footprint circle + diameter/depth arrows (Hole card)
// A FEATURE button whose tool needs bodies it may not have yet. Greyed with an explanatory
// tooltip below min_bodies, rather than accepting the click and refusing afterwards.
@@ -716,6 +717,10 @@ private:
int m_sel_solid_body{-1}; // which body the face/edge selection is on
int m_sel_solid_face{-1};
int m_sel_solid_edge{-1};
// The picked edge set (Shift/Ctrl+click), m_sel_solid_edge last. Read through dressup_edges(),
// which drops it once m_sel_solid_edge moves on — the many places that reset the single edge
// then need not know the set exists.
std::vector<int> m_sel_solid_edges;
bool m_sel_solid_vertex{false}; // a corner is picked (body+point, no face/edge)
// The face actually under the last solid click, INDEPENDENT of the whole/face/edge cycle level.
// The first click on a solid selects the WHOLE body, but the ray has already resolved which face
+51
View File
@@ -3445,12 +3445,22 @@ void DesignSketchTool::clear_solid_selection()
m_solid_sel = SolidSel::None;
m_sel_body = m_sel_face = m_sel_edge = -1;
m_sel_edge_pts.clear();
m_sel_edges_more.clear();
m_sel_edges_more_pts.clear();
// The pre-highlight names a face/edge/vertex by index into a shape that a recompute has just
// rebuilt, so it expires with the selection it was a promise about. Left behind it would keep
// glowing on whatever now sits at those indices — a real entity, but not the one meant.
m_pre = SolidPick{};
}
std::vector<int> DesignSketchTool::selected_edges() const
{
if (m_solid_sel != SolidSel::Edge || m_sel_edge < 0) return {};
std::vector<int> out = m_sel_edges_more;
out.push_back(m_sel_edge);
return out;
}
void DesignSketchTool::select_body(int body)
{
// Hidden bodies aren't highlighted (the tint overlay would otherwise draw over a
@@ -3463,6 +3473,8 @@ void DesignSketchTool::select_body(int body)
m_sel_body = body;
m_sel_face = m_sel_edge = -1;
m_sel_edge_pts.clear();
m_sel_edges_more.clear();
m_sel_edges_more_pts.clear();
m_solid_sel = SolidSel::Whole; // render_solid_highlight tints just this body
}
@@ -3683,6 +3695,42 @@ bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent
const int prev_body = m_sel_body, prev_face = m_sel_face, prev_edge = m_sel_edge;
const Vec3d prev_vtx = m_sel_vertex_pt;
// SHIFT/CTRL+CLICK ON AN EDGE BUILDS AN EDGE SET, the same modifiers that extend a sketch
// selection. Only edges of the one body already picked: a dress-up acts on one body, and a
// set spanning two could not be applied. An edge already in the set leaves it; the last one
// leaving clears the selection. No escalation to the whole body here — a modified click is
// always about the set.
const bool extend = evt.ShiftDown() || evt.ControlDown() || evt.CmdDown();
if (extend && p.kind == SolidSel::Edge && prev_kind == SolidSel::Edge && p.body == prev_body) {
auto more = std::find(m_sel_edges_more.begin(), m_sel_edges_more.end(), p.edge);
if (p.edge == prev_edge) {
if (m_sel_edges_more.empty()) {
clear_solid_selection();
} else { // the previous pick becomes the current one
m_sel_edge = m_sel_edges_more.back();
m_sel_edge_pts = std::move(m_sel_edges_more_pts.back());
m_sel_edges_more.pop_back();
m_sel_edges_more_pts.pop_back();
}
} else if (more != m_sel_edges_more.end()) {
const size_t k = size_t(more - m_sel_edges_more.begin());
m_sel_edges_more.erase(more);
m_sel_edges_more_pts.erase(m_sel_edges_more_pts.begin() + k);
} else {
m_sel_edges_more.push_back(prev_edge);
m_sel_edges_more_pts.push_back(std::move(m_sel_edge_pts));
m_sel_edge = p.edge;
m_sel_edge_pts = std::move(p.edge_pts);
m_sel_face = p.face;
}
dp_pick_trace("edge set -> %zu edge(s), current %d", selected_edges().size(), m_sel_edge);
if (on_solid_selection_changed)
on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge);
return true;
}
m_sel_edges_more.clear();
m_sel_edges_more_pts.clear();
m_sel_body = p.body;
m_sel_face = p.face;
m_sel_edge = p.edge;
@@ -3871,6 +3919,9 @@ void DesignSketchTool::render_solid_highlight()
render_solid_sel(m_solid_sel, m_sel_body, m_sel_face, m_sel_edge_pts, m_sel_vertex_pt,
sel_cyan, 1.0f);
if (m_solid_sel == SolidSel::Edge)
for (const std::vector<Vec3d>& pts : m_sel_edges_more_pts)
render_solid_sel(SolidSel::Edge, m_sel_body, -1, pts, Vec3d::Zero(), sel_cyan, 1.0f);
}
// Datum/reference planes (Plane feature) have no solid; draw each as a translucent indigo
+9
View File
@@ -200,6 +200,10 @@ public:
void set_pick_only_body(int b) { m_pick_only_body = b; }
void clear_solid_selection();
bool has_solid_selection() const { return m_solid_sel != SolidSel::None; }
// Every picked edge when the selection is an edge set (Shift/Ctrl+click adds and removes
// edges of the same body): the earlier picks first, the last-clicked edge at the end.
// Empty unless the selection is at edge level.
std::vector<int> selected_edges() const;
// Select a whole body by index (from the Parts list) — Whole-level highlight, no face/edge.
// body < 0 or out of range clears the selection.
void select_body(int body);
@@ -1194,6 +1198,11 @@ private:
int m_sel_face{-1};
int m_sel_edge{-1};
std::vector<Vec3d> m_sel_edge_pts;
// Edges picked BEFORE m_sel_edge in a Shift/Ctrl+click set, same body, with their world
// polylines for the highlight. m_sel_edge stays the last-clicked one, so everything that
// reads a single edge (the radius gizmo, the offer header) keeps working unchanged.
std::vector<int> m_sel_edges_more;
std::vector<std::vector<Vec3d>> m_sel_edges_more_pts;
Vec3d m_sel_vertex_pt{Vec3d::Zero()}; // world point of a picked vertex
bool handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt); // pick + notify
// What a click at (mx,my) WOULD take, resolved without touching the selection. One
+22 -8
View File
@@ -156,15 +156,15 @@ json describe_tools()
json{{"name", "profile"}, {"type", "array"}, {"default", json::array()}, {"description", "optional closed contour [[x,y],...] in plane mm; overrides width/height"}},
json{{"name", "boolean"}, {"type", "string"}, {"enum", json::array({"new", "union", "subtract", "intersect"})}, {"default", "new"}},
})}},
json{{"name", "fillet"}, {"summary", "Round a measured edge of a body (edge id from query_topology on that body)."},
json{{"name", "fillet"}, {"summary", "Round measured edges of a body (edge ids from query_topology on that body)."},
{"params", json::array({
json{{"name", "edge"}, {"type", "integer"}},
json{{"name", "edge"}, {"type", "integer|array"}, {"description", "one edge id, or an array of ids rounded together in one feature"}},
json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}},
})}},
json{{"name", "chamfer"}, {"summary", "Chamfer a measured edge of a body (edge id from query_topology on that body)."},
json{{"name", "chamfer"}, {"summary", "Chamfer measured edges of a body (edge ids from query_topology on that body)."},
{"params", json::array({
json{{"name", "edge"}, {"type", "integer"}},
json{{"name", "edge"}, {"type", "integer|array"}, {"description", "one edge id, or an array of ids chamfered together in one feature"}},
json{{"name", "distance"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}},
})}},
@@ -867,16 +867,30 @@ int target_body_arg(const json& params, const CadDocument& doc)
return bi; // <0 -> kernel uses the last body
}
// `edge` is one id or an array of ids; an array becomes ONE feature, every id resolved
// against the same body (a chain of single-edge features would see the ids drift).
std::vector<int> edge_ids_arg(const json& params, const char* verb)
{
if (!params.contains("edge"))
throw std::runtime_error(std::string(verb) + " needs 'edge' (id or array of ids from query_topology)");
const json& e = params["edge"];
std::vector<int> ids;
if (e.is_array()) for (const json& v : e) ids.push_back(v.get<int>());
else ids.push_back(e.get<int>());
if (ids.empty()) throw std::runtime_error(std::string(verb) + ": 'edge' is an empty array");
return ids;
}
json action_fillet(DesignPanel* panel, const json& params)
{
if (!params.contains("edge")) throw std::runtime_error("fillet needs 'edge' (id from query_topology)");
const std::vector<int> edges = edge_ids_arg(params, "fillet");
const double radius = params.value("radius", 1.0);
if (radius <= 0) throw std::runtime_error("radius must be > 0");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to fillet");
int bi = target_body_arg(params, doc);
doc.checkpoint();
int f = doc.add_fillet(radius, params["edge"].get<int>(), "Fillet");
int f = doc.add_fillet(radius, edges, "Fillet");
if (bi >= 0) doc.features[f].target_body = bi; // edge id resolved against THIS body's shape
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
@@ -887,14 +901,14 @@ json action_fillet(DesignPanel* panel, const json& params)
json action_chamfer(DesignPanel* panel, const json& params)
{
if (!params.contains("edge")) throw std::runtime_error("chamfer needs 'edge' (id from query_topology)");
const std::vector<int> edges = edge_ids_arg(params, "chamfer");
const double dist = params.value("distance", 1.0);
if (dist <= 0) throw std::runtime_error("distance must be > 0");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to chamfer");
int bi = target_body_arg(params, doc);
doc.checkpoint();
int c = doc.add_chamfer(dist, params["edge"].get<int>(), "Chamfer");
int c = doc.add_chamfer(dist, edges, "Chamfer");
if (bi >= 0) doc.features[c].target_body = bi; // edge id resolved against THIS body's shape
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
+88 -6
View File
@@ -2347,12 +2347,12 @@ TEST_CASE("a truncated feature keeps what it could read", "[CadDocument][recipe]
}
// Shorten feature 1 so it ends right after coordsys_face_kind: drop coordsys_face_edges
// (4 bytes) and the two flags appended after it (thread_major_nominal, pattern_inclusive:
// 1 byte each). Rewrite its length prefix and erase the tail bytes. The reader then runs
// out inside fa(f), throws, and keeps everything it had already assigned — that is the
// whole point of the try/catch. (Cut on a field boundary: a field cut in half is read as
// whatever half arrived.)
const size_t drop = sizeof(uint32_t) + 2 * sizeof(bool);
// (4 bytes), the two flags appended after it (thread_major_nominal, pattern_inclusive:
// 1 byte each) and the empty dressup_edges list (its 8-byte size tag). Rewrite its length
// prefix and erase the tail bytes. The reader then runs out inside fa(f), throws, and keeps
// everything it had already assigned — that is the whole point of the try/catch. (Cut on a
// field boundary: a field cut in half is read as whatever half arrived.)
const size_t drop = sizeof(uint32_t) + 2 * sizeof(bool) + sizeof(cereal::size_type);
REQUIRE(f_len[1] > drop);
std::string shortened = blob;
shortened.erase(f_off[1] + 4 + f_len[1] - drop, drop);
@@ -2370,6 +2370,7 @@ TEST_CASE("a truncated feature keeps what it could read", "[CadDocument][recipe]
REQUIRE(loaded.features[1].coordsys_face_edges == -1); // defaulted by the cut
REQUIRE_FALSE(loaded.features[1].thread_major_nominal); // ...and so were the later flags
REQUIRE_FALSE(loaded.features[1].pattern_inclusive);
REQUIRE(loaded.features[1].dressup_edges.empty());
REQUIRE(loaded.features[0].name == doc.features[0].name);
REQUIRE(loaded.features[2].name == doc.features[2].name);
}
@@ -7986,6 +7987,87 @@ TEST_CASE("dressup: four chamfer ids captured up-front drift as earlier chamfers
REQUIRE(ok);
}
// Several picked edges dressed by ONE feature: every id is resolved against the same body, so
// capturing them up-front is correct here (unlike the chain of single-edge features above).
TEST_CASE("dressup: one fillet on four picked edges equals the Top face group", "[CadDocument][dressup]")
{
const double half = 10.0, h = 10.0, r = 1.0;
CadDocument doc = make_centred_box(half, h);
REQUIRE(doc.recompute());
const double v0 = solid_volume(doc.bodies[0].shape);
std::vector<int> ids;
for (const Vec3d& t : { Vec3d(half, 0.0, h), Vec3d(0.0, half, h), Vec3d(-half, 0.0, h), Vec3d(0.0, -half, h) }) {
const int id = edge_near(doc.bodies[0].shape, t, 1.5);
REQUIRE(id >= 0);
ids.push_back(id);
}
const int fi = doc.add_fillet(r, ids, "Fillet");
REQUIRE(doc.features[fi].dressup_edges == ids);
REQUIRE(doc.features[fi].dressup_edge == ids.front()); // what an older build falls back to
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
const double v_list = solid_volume(doc.bodies[0].shape);
CadDocument ref = make_centred_box(half, h);
ref.add_fillet(r, FaceGroup::Top, "Fillet");
REQUIRE(ref.recompute());
const double v_group = solid_volume(ref.bodies[0].shape);
INFO("v0=" << v0 << " list=" << v_list << " group=" << v_group);
REQUIRE(v_list < v0);
REQUIRE(std::abs(v_list - v_group) < 1e-6 * v0);
SECTION("the list survives a save and load") {
const std::string blob = doc.serialize_recipe();
CadDocument back;
REQUIRE(back.deserialize_recipe(blob));
REQUIRE(back.features.size() == doc.features.size());
REQUIRE(back.features[fi].dressup_edges == ids);
REQUIRE(std::abs(solid_volume(back.bodies[0].shape) - v_list) < 1e-6 * v0);
}
}
TEST_CASE("dressup: one chamfer on two picked edges, a single id still takes the one-edge path", "[CadDocument][dressup]")
{
const double half = 10.0, h = 10.0, d = 0.5;
CadDocument doc = make_centred_box(half, h);
REQUIRE(doc.recompute());
const double v0 = solid_volume(doc.bodies[0].shape);
const int a = edge_near(doc.bodies[0].shape, Vec3d(half, 0.0, h), 1.5);
const int b = edge_near(doc.bodies[0].shape, Vec3d(-half, 0.0, h), 1.5);
REQUIRE(a >= 0);
REQUIRE(b >= 0);
CadDocument one = make_centred_box(half, h);
REQUIRE(one.recompute());
const int f1 = one.add_chamfer(d, std::vector<int>{ a }, "Chamfer");
REQUIRE(one.features[f1].dressup_edges.empty()); // a single pick stays a plain edge feature
REQUIRE(one.features[f1].dressup_edge == a);
REQUIRE(one.recompute());
const double single = v0 - solid_volume(one.bodies[0].shape);
doc.add_chamfer(d, std::vector<int>{ a, b }, "Chamfer");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
const double both = v0 - solid_volume(doc.bodies[0].shape);
INFO("single=" << single << " both=" << both);
REQUIRE(single > 0.0);
// Two opposite rim edges share no corner: removing both takes exactly twice one.
REQUIRE(std::abs(both - 2.0 * single) < 1e-6 * v0);
}
TEST_CASE("dressup: an edge list naming a missing edge fails with a reason", "[CadDocument][dressup]")
{
CadDocument doc = make_centred_box(10.0, 10.0);
REQUIRE(doc.recompute());
const int good = edge_near(doc.bodies[0].shape, Vec3d(10.0, 0.0, 10.0), 1.5);
REQUIRE(good >= 0);
doc.add_fillet(1.0, std::vector<int>{ good, 9999 }, "Fillet");
REQUIRE_FALSE(doc.recompute());
REQUIRE_FALSE(doc.error.empty());
}
// --- Face-drift fingerprint: a FaceAndDirection connector warns when its face index slides ---
TEST_CASE("a connector records its face fingerprint on first recompute", "[CadDocument][mate]")