From 3c218591bc9f4c4fd3769cb4d0b128bb7f39e474 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:56:47 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01QK4VgguuCAk2hZLWgcjJb9 --- docs/CAD/design_tab.md | 5 ++ src/libslic3r/CAD/CadDocument.cpp | 34 +++++++-- src/libslic3r/CAD/CadDocument.hpp | 19 ++++- src/libslic3r/CAD/GeometryEngine.cpp | 32 ++++++--- src/libslic3r/CAD/GeometryEngine.hpp | 5 ++ src/slic3r/GUI/CAD/DesignCanvas.cpp | 5 ++ src/slic3r/GUI/CAD/DesignCanvas.hpp | 1 + src/slic3r/GUI/CAD/DesignPanel.cpp | 49 +++++++++---- src/slic3r/GUI/CAD/DesignPanel.hpp | 5 ++ src/slic3r/GUI/CAD/DesignSketchTool.cpp | 51 ++++++++++++++ src/slic3r/GUI/CAD/DesignSketchTool.hpp | 9 +++ src/slic3r/GUI/CAD/McpControl.cpp | 30 +++++--- tests/libslic3r/test_caddocument.cpp | 94 +++++++++++++++++++++++-- 13 files changed, 297 insertions(+), 42 deletions(-) diff --git a/docs/CAD/design_tab.md b/docs/CAD/design_tab.md index 2270909bcc..65f455bc3c 100644 --- a/docs/CAD/design_tab.md +++ b/docs/CAD/design_tab.md @@ -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 diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp index 7c261b958d..f77c4ceeaf 100644 --- a/src/libslic3r/CAD/CadDocument.cpp +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -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& 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& 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; diff --git a/src/libslic3r/CAD/CadDocument.hpp b/src/libslic3r/CAD/CadDocument.hpp index b95c493496..c85dcac220 100644 --- a/src/libslic3r/CAD/CadDocument.hpp +++ b/src/libslic3r/CAD/CadDocument.hpp @@ -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 dressup_edges; + // The edges this dress-up targets: the list, else the single edge, else none (face group). + std::vector 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 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& 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& 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); diff --git a/src/libslic3r/CAD/GeometryEngine.cpp b/src/libslic3r/CAD/GeometryEngine.cpp index 03ae1de979..5795058150 100644 --- a/src/libslic3r/CAD/GeometryEngine.cpp +++ b/src/libslic3r/CAD/GeometryEngine.cpp @@ -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{ 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{ edge_id }); +} + +TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, const std::vector& 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& 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"); diff --git a/src/libslic3r/CAD/GeometryEngine.hpp b/src/libslic3r/CAD/GeometryEngine.hpp index bad29ed953..16040eee5e 100644 --- a/src/libslic3r/CAD/GeometryEngine.hpp +++ b/src/libslic3r/CAD/GeometryEngine.hpp @@ -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& edge_ids); + static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance, + const std::vector& edge_ids); static TriangleMesh tessellate(const TopoDS_Shape& shape, double linear_deflection = 0.003, diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index a3842b2223..2f00b04e61 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -938,6 +938,11 @@ void DesignCanvas::set_on_pattern_changed(std::function cb) m_sketch_tool.on_pattern_changed = std::move(cb); } +std::vector DesignCanvas::selected_solid_edges() const +{ + return m_sketch_tool.selected_edges(); +} + void DesignCanvas::set_on_solid_selection_changed(std::function cb) { m_sketch_tool.on_solid_selection_changed = std::move(cb); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 026d4a524d..191b2bbdc0 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -122,6 +122,7 @@ public: const std::vector* visible = nullptr, const std::vector* xform = nullptr); void set_on_solid_selection_changed(std::function cb); + std::vector 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(). diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 92609d00a3..3678d8861c 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -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(); 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 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(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(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 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(); + } 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 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(); diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 9d4f274b17..c9b6e65f47 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -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 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 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 diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 996a77c1a1..31beb1e407 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -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 DesignSketchTool::selected_edges() const +{ + if (m_solid_sel != SolidSel::Edge || m_sel_edge < 0) return {}; + std::vector 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& 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 diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 62e97981f7..60a7fa25dd 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -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 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 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 m_sel_edges_more; + std::vector> 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 diff --git a/src/slic3r/GUI/CAD/McpControl.cpp b/src/slic3r/GUI/CAD/McpControl.cpp index bc05e480c1..90148abee1 100644 --- a/src/slic3r/GUI/CAD/McpControl.cpp +++ b/src/slic3r/GUI/CAD/McpControl.cpp @@ -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 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 ids; + if (e.is_array()) for (const json& v : e) ids.push_back(v.get()); + else ids.push_back(e.get()); + 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 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(), "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 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(), "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; } diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 6b3327ee1d..135905b116 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -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 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{ 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{ 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{ 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]")