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]")