From e7545f0bc0bde04c128b9051271827fc8609ff28 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 5 Oct 2026 14:41:07 +0800 Subject: [PATCH] Commit Design bodies to the plate as one assembly, keeping their positions Commit to Plate now sends all visible bodies to Prepare as one object with a part per body, so their relative placement survives. A dropdown beside the button switches to Commit to Plate (as bodies), the previous one-object-per-body behaviour, and the choice is remembered. --- docs/HLSD/design-tab.md | 5 +- resources/images/toolbar_add_plate_bodies.svg | 1 + .../images/toolbar_add_plate_bodies_dark.svg | 1 + src/slic3r/GUI/CAD/DesignCanvas.cpp | 14 ++- src/slic3r/GUI/CAD/DesignPanel.cpp | 118 +++++++++++++++--- src/slic3r/GUI/CAD/DesignPanel.hpp | 11 +- src/slic3r/GUI/GUI_ObjectList.cpp | 16 ++- src/slic3r/GUI/GUI_ObjectList.hpp | 2 + 8 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 resources/images/toolbar_add_plate_bodies.svg create mode 100644 resources/images/toolbar_add_plate_bodies_dark.svg diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 466d3043c3..c4bb9fd297 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -13,8 +13,9 @@ known. Its coupling to the rest of the application is deliberately narrow. It adds no stage to the slicing pipeline and touches neither the preset system nor `Tab`. It reaches the rest of Orca in two places: **Commit to Plate**, which hands finished solids to Prepare as ordinary model -objects, and one optional 3MF archive entry that carries the recipe. Everything else is -contained in `src/libslic3r/CAD/` and `src/slic3r/GUI/CAD/`. +objects (by default one assembly object with a part per body, which keeps the bodies' relative +placement, or one object per body), and one optional 3MF archive entry that carries the recipe. +Everything else is contained in `src/libslic3r/CAD/` and `src/slic3r/GUI/CAD/`. The user-facing manual lives in the wiki ([Design Tab](https://www.orcaslicer.com/wiki/design_tab)), not here. This document covers the diff --git a/resources/images/toolbar_add_plate_bodies.svg b/resources/images/toolbar_add_plate_bodies.svg new file mode 100644 index 0000000000..af5a19d666 --- /dev/null +++ b/resources/images/toolbar_add_plate_bodies.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/images/toolbar_add_plate_bodies_dark.svg b/resources/images/toolbar_add_plate_bodies_dark.svg new file mode 100644 index 0000000000..50af0b90e2 --- /dev/null +++ b/resources/images/toolbar_add_plate_bodies_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 2df2f3edfe..fa58034052 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -45,6 +45,11 @@ #include #include +#if defined(__WXMSW__) && wxUSE_POPUPWIN +#include +extern wxPopupWindow* wxCurrentPopupWindow; +#endif + namespace Slic3r { namespace GUI { @@ -198,7 +203,14 @@ DesignCanvas::DesignCanvas(wxWindow* parent) // …but NOT while an inline value field is open: the field floats over the canvas, so // the smallest pointer jiggle re-enters the viewport and would yank focus off the // field (the "no cursor focus on the number, click to focus" bug). - if (m_canvas_widget && !m_sketch_tool.inline_busy()) m_canvas_widget->SetFocus(); + bool take_focus = m_canvas_widget && !m_sketch_tool.inline_busy(); +#if defined(__WXMSW__) && wxUSE_POPUPWIN + // …nor while a popup is open. A ribbon dropdown opens over the viewport, and the pointer + // crosses the viewport on its way to it: taking focus then reactivates the frame, which + // on MSW dismisses the popup under the pointer. GLCanvas3D::on_mouse has the same guard. + take_focus = take_focus && !wxCurrentPopupWindow; +#endif + if (take_focus) m_canvas_widget->SetFocus(); e.Skip(); }); diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index d19ace8b14..f1177e016d 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -267,6 +267,15 @@ static ::Button* sidebar_icon_btn(wxWindow* parent, const char* icon, const wxSt return b; } +// Commit to Plate's faces: Prepare's add-plate glyph, drawn for its light GL toolbar, and the same +// glyph with a body on the plate for "as bodies". Each ships a "_dark" twin for the dark ribbon, +// picked the way GLToolbar picks it. +static std::string commit_icon(bool bodies) +{ + const std::string name = bodies ? "toolbar_add_plate_bodies" : "toolbar_add_plate"; + return dp_dark() ? name + "_dark" : name; +} + // The icons on each Feature tree and Bodies row (DesignRowList::Action::id). enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; @@ -1606,11 +1615,12 @@ DesignPanel::DesignPanel(wxWindow* parent) // Some Orca glyphs (toolbar_flatten) are drawn for Prepare's light GL toolbar and come out // the same tone as a dark ribbon. Those ship a "_dark" twin, picked per theme here (and // again on a theme switch) the way GLToolbar picks it. - auto doc_btn = [this](const char* icon, const wxString& tip, bool has_dark_twin = false) { + auto doc_btn = [this](const char* icon, const wxString& tip, bool has_dark_twin = false, + int cell_w = 40, int icon_px = 34) { const std::string name(icon); auto themed = [name, has_dark_twin] { return has_dark_twin && dp_dark() ? name + "_dark" : name; }; - auto* b = new ScalableButton(m_toolbar, wxID_ANY, themed(), "", FromDIP(wxSize(40, 40)), - wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); + auto* b = new ScalableButton(m_toolbar, wxID_ANY, themed(), "", FromDIP(wxSize(cell_w, 40)), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, icon_px); if (has_dark_twin) m_icon_refresh.push_back([b, themed] { b->SetBitmap_(themed()); }); b->SetToolTip(tip); @@ -1668,12 +1678,73 @@ DesignPanel::DesignPanel(wxWindow* parent) m_section_flip_btn->Enable(false); // only usable while a section view is active tb_slot["flip"].push_back(m_section_flip_btn); - // Commit is the tab's primary action and sits far right, next to Confirm/Cancel. + // Commit is the tab's primary action and sits far right, next to Confirm/Cancel. A split + // button like Prepare's Slice: the face commits in the current mode, the chevron's + // dropdown only switches the mode. m_tb_commit = new wxBoxSizer(wxHORIZONTAL); - auto* b_commit = doc_btn("toolbar_add_plate", _L("Commit to Plate — send the solid to Prepare"), - true); - b_commit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); }); - m_tb_commit->Add(b_commit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + m_commit_btn = doc_btn("toolbar_add_plate", wxEmptyString); // face and tip: set_commit_mode + m_commit_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); }); + auto* b_mode = doc_btn("drop_down", _L("Choose how Commit to Plate sends the bodies"), false, 16, 16); + m_tb_commit->Add(m_commit_btn, 0, wxALIGN_CENTER_VERTICAL); + m_tb_commit->Add(b_mode, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + + // Same themed-DropDown shape as the FEATURE flyouts; row index == int(CommitMode). + struct CommitFlyout { + std::vector items; + wxLongLong closed_ms; // when the list last closed (wxGetLocalTimeMillis) + DropDown drop; // declared LAST: destroyed before the vector it references + CommitFlyout() : drop(items) {} + }; + auto cf = std::make_shared(); + cf->items.resize(2); + cf->items[int(CommitMode::Assembly)].text = _L("Commit to Plate"); + cf->items[int(CommitMode::Assembly)].tip = _L("All bodies become one object with a part per body, " + "keeping their relative positions"); + cf->items[int(CommitMode::Bodies)].text = _L("Commit to Plate (as bodies)"); + cf->items[int(CommitMode::Bodies)].tip = _L("Each body becomes its own object, placed on its own"); + CommitFlyout* cp = cf.get(); + auto refresh_rows = [this, cp] { + for (size_t i = 0; i < cp->items.size(); ++i) + cp->items[i].icon = create_scaled_bitmap(commit_icon(i == size_t(CommitMode::Bodies)), m_toolbar, 18); + }; + refresh_rows(); + cp->drop.Create(m_commit_btn); + cp->drop.SetUseContentWidth(true, false); + cp->drop.Invalidate(true); + m_commit_drop = &cp->drop; + cp->drop.Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { + const bool bodies = e.GetInt() == int(CommitMode::Bodies); + set_commit_mode(bodies ? CommitMode::Bodies : CommitMode::Assembly); + wxGetApp().app_config->set("design_commit_mode", bodies ? "bodies" : "assembly"); + }); + cp->drop.Bind(EVT_DISMISS, [cp](wxCommandEvent&) { cp->closed_ms = wxGetLocalTimeMillis(); }); + b_mode->Bind(wxEVT_BUTTON, [this, b_mode, cp](wxCommandEvent&) { + // A click on ▾ while the list is open closes it, and that same click reaches this + // button too (on MSW the list's deferred dismissal runs before the button's mouse-up): + // it must not reopen the list. + if (cp->drop.IsShown() || wxGetLocalTimeMillis() - cp->closed_ms < 300) + return; + // A fresh content measure before Popup(), as the FEATURE flyouts do. Invalidate(true) + // also clears the selection, which is the check on the current mode: put it back. + m_commit_drop->Invalidate(true); + m_commit_drop->SetUseContentWidth(false, false); + m_commit_drop->SetUseContentWidth(true, false); + m_commit_drop->SetSelection(int(m_commit_mode)); + // Right-aligned under the split button, which sits at the ribbon's far right. + wxPoint pos = b_mode->ClientToScreen(wxPoint(b_mode->GetSize().x, -6)); + pos.x -= m_commit_drop->GetSize().x; + m_commit_drop->Position(pos, wxSize(0, b_mode->GetSize().y + b_mode->FromDIP(12))); + m_commit_drop->Popup(); + }); + m_icon_refresh.push_back([this, refresh_rows] { + refresh_rows(); + m_commit_drop->Invalidate(true); + set_commit_mode(m_commit_mode); // the face's theme twin, and the check Invalidate cleared + }); + m_flyout_keepalive.push_back(cf); + // Assembly unless the user picked "as bodies": it is the mode that keeps the design as drawn. + set_commit_mode(wxGetApp().app_config->get("design_commit_mode") == "bodies" ? CommitMode::Bodies + : CommitMode::Assembly); } // Feature-group layout, in the requested left-to-right order. @@ -9898,6 +9969,19 @@ void DesignPanel::on_export_step() m_status->Refresh(); } +void DesignPanel::set_commit_mode(CommitMode mode) +{ + m_commit_mode = mode; + const bool bodies = mode == CommitMode::Bodies; + if (m_commit_btn) { + m_commit_btn->SetBitmap_(commit_icon(bodies)); + m_commit_btn->SetToolTip(bodies ? _L("Commit to Plate (as bodies) — send each body to Prepare as its own object") + : _L("Commit to Plate — send the solid to Prepare")); + } + if (m_commit_drop) + m_commit_drop->SetSelection(int(mode)); // the check on the current row +} + void DesignPanel::on_commit() { // A feature tool open with a live preview ghost (e.g. a fillet being previewed) is @@ -9914,24 +9998,28 @@ void DesignPanel::on_commit() if (obj_list == nullptr) return; - // Multi-body: ship each (visible) body as its own plate object so they arrive on the - // slicer plate as independent, separately-arrangeable parts (Onshape "Commit all parts"). + // Multi-body: ship the (visible) bodies as the parts of one assembly object that keeps + // their placement (the default), or each as its own plate object so they arrive as + // independent, separately-arrangeable parts (Onshape "Commit all parts"). // Hidden bodies are skipped — what you see on the Design plate is what gets committed. sync_body_visible(); rebuild_disp_meshes(); // ship moved bodies at their Move-gizmo positions if (m_disp_body_meshes.size() > 1) { - int committed = 0; + std::vector> parts; for (size_t b = 0; b < m_disp_body_meshes.size(); ++b) { if (b < m_body_visible.size() && !m_body_visible[b]) continue; // skip hidden if (m_disp_body_meshes[b].its.indices.empty()) continue; - obj_list->load_mesh_object(m_disp_body_meshes[b], - "Design Body " + std::to_string(b + 1)); - ++committed; + parts.emplace_back(&m_disp_body_meshes[b], wxString::FromUTF8("Design Body " + std::to_string(b + 1))); } - if (committed == 0) { // every body hidden — nothing to ship + if (parts.empty()) { // every body hidden — nothing to ship set_status(_L("All bodies hidden — show one before committing")); return; } + if (m_commit_mode == CommitMode::Assembly && parts.size() > 1) + obj_list->load_mesh_object(parts, "Design Assembly"); + else + for (const auto& [mesh, name] : parts) + obj_list->load_mesh_object(*mesh, name); } else { obj_list->load_mesh_object(m_disp_pick_mesh, "Design Body"); } diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 01c8cbe5b9..cc1c6c83a1 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -33,6 +33,7 @@ class wxStaticText; class wxStaticLine; class Button; // Orca-styled button (Widgets/Button.hpp) class CheckBox; // Orca teal checkbox (Widgets/CheckBox.hpp) +class DropDown; // Orca themed popup list (Widgets/DropDown.hpp) class wxSizer; // wxBoxSizer, wxTextCtrl and wxListCtrl are used here as pointers only, so a forward // declaration is enough — but they must be declared. Every ordinary build happened to pull @@ -237,6 +238,11 @@ private: // a modal dialog editing the feature's placement transform in place. void on_transform_imported(int feat_idx); void on_commit(); + // What Commit to Plate sends: one object with a part per body (an assembly, which keeps the + // bodies' relative placement), or, "as bodies", one Prepare object per body. The split + // button's dropdown switches it, like Prepare's Slice button, without committing. + enum class CommitMode { Assembly, Bodies }; + void set_commit_mode(CommitMode mode); void on_export_step(); // write all bodies to a .step file (native B-rep) // Rehydrate the parametric model from a project's saved recipe (3MF // Metadata/orca_cad.bin): deserialize -> recompute -> refresh viewport + tree. @@ -400,7 +406,10 @@ private: void show_move_card(bool show); void apply_move_card(); // numeric move/rotate -> same xform the gizmo builds void push_polygon_params(); - wxSizer* m_tb_commit{nullptr}; // far-right Commit to Plate, beside Confirm/Cancel + wxSizer* m_tb_commit{nullptr}; // far-right Commit to Plate split button, beside Confirm/Cancel + CommitMode m_commit_mode{CommitMode::Assembly}; + ScalableButton* m_commit_btn{nullptr}; // main face: runs the current commit mode + DropDown* m_commit_drop{nullptr}; // commit-mode choices, owned via m_flyout_keepalive wxSizer* m_tb_doc{nullptr}; // toolbar document/view actions (new, commit, export, section, place) CheckBox* m_show_bed{nullptr}; // view option: draw the printer bed + plate grid, or not wxSizer* m_box_move{nullptr}; // Move/Rotate numeric options (distance, axis, angle) diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 59e26663a2..1348ba6f78 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -2662,6 +2662,11 @@ void ObjectList::load_shape_object(const std::string &type_name) } void ObjectList::load_mesh_object(const TriangleMesh &mesh, const wxString &name, bool center) +{ + load_mesh_object(std::vector>{{&mesh, name}}, name, center); +} + +void ObjectList::load_mesh_object(const std::vector> &parts, const wxString &name, bool center) { // Add mesh to model as a new object Model& model = wxGetApp().plater()->model(); @@ -2671,14 +2676,19 @@ void ObjectList::load_mesh_object(const TriangleMesh &mesh, const wxString &name #endif /* _DEBUG */ std::vector object_idxs; - auto bb = mesh.bounding_box(); + BoundingBoxf3 bb; ModelObject* new_object = model.add_object(); new_object->name = into_u8(name); new_object->add_instance(); // each object should have at least one instance - ModelVolume* new_volume = new_object->add_volume(mesh); + // add_volume() centres each part on its own geometry, so the part offsets carry the + // meshes' placement relative to each other. + for (const auto& [mesh, part_name] : parts) { + bb.merge(mesh->bounding_box()); + ModelVolume* new_volume = new_object->add_volume(*mesh); + new_volume->name = into_u8(part_name); + } new_object->sort_volumes(true); - new_volume->name = into_u8(name); // set a default extruder value, since user can't add it manually // BBS new_object->config.set_key_value("extruder", new ConfigOptionInt(1)); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index 1f2d970cef..92fb6ba470 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -314,6 +314,8 @@ public: void load_generic_subobject(const std::string& type_name, const ModelVolumeType type); void load_shape_object(const std::string &type_name); void load_mesh_object(const TriangleMesh &mesh, const wxString &name, bool center = true); + // One object holding one part per mesh; the parts keep their placement relative to each other. + void load_mesh_object(const std::vector> &parts, const wxString &name, bool center = true); // BBS void switch_to_object_process(); bool del_object(const int obj_idx, bool refresh_immediately = true);