mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-05 14:51:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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]")
|
||||
|
||||
Reference in New Issue
Block a user