Merge upstream/main into belt-printer

Brings belt-printer up to main 4b4a261787. Resolutions:

- G-code header (#15897, #15915): main moved the header, config and
  thumbnail block later in _do_export; write_belt_header() moves with it,
  still after the thumbnails and outside the BTT_TFT gate.
- _extrude: first-layer acceleration keeps the per-path first-layer plane
  test with main's cached nozzle index (#16028); main's set_speed out-param
  form (#16108) everywhere else.
- GCodeWriter (#16108): the arc-to-polyline fallback for machine mappings
  that cannot express G2/G3 now runs in the out-param extrude_arc_to_xy,
  which is the overload GCode calls, and appends to the caller's string.
- GCodeProcessorResult: the belt fields join main's forwarding assign.
- Clipper2 (#15969): belt arrange helpers take Slic3r::Point; the tree
  support join types lose their ClipperLib qualifier.
- CLI arrange (#15837): belt printers still reserve no wipe tower.
- Wipe tower options (#15841): the two new sparse-layer toggles are hidden
  for belt printers like the rest of the tower options.
- Keyboard shortcuts (#15706): main's registry replaces the old key switch;
  the belt view toggle is re-registered in the next commit.
- Print::process: the belt purge-plan undo runs before main's SliceStarted
  event.
- scripts/filament_id_snapshot.json: deleted on main (a77209af8f).
- Includes and appended tests: union of both sides.
This commit is contained in:
harrierpigeon
2026-10-04 19:20:33 -05:00
11471 changed files with 2992690 additions and 2720441 deletions
+273 -90
View File
@@ -1,14 +1,17 @@
#include "libslic3r/libslic3r.h"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "GUI_ObjectList.hpp"
#include "GUI_Factories.hpp"
//#include "GUI_ObjectLayers.hpp"
#include "GUI_App.hpp"
#include "Shortcuts.hpp"
#include "I18N.hpp"
#include "Plater.hpp"
#include "BitmapComboBox.hpp"
#include "MainFrame.hpp"
#include "slic3r/Utils/UndoRedo.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "OptionsGroup.hpp"
#include "Tab.hpp"
@@ -25,16 +28,68 @@
#include "StepMeshDialog.hpp"
#include "slic3r/GUI/Event.hpp"
#include "libslic3r/Config.hpp"
#include <string>
#include "slic3r/GUI/Widgets/StateColor.hpp"
#include "slic3r/GUI/Widgets/Label.hpp"
#include "slic3r/GUI/ObjectDataViewModel.hpp"
#include <cstddef>
#include "slic3r/GUI/ExtraRenderers.hpp"
#include <cassert>
#include <algorithm>
#include "libslic3r/TriangleMesh.hpp"
#include "slic3r/GUI/GUI.hpp"
#include <memory>
#include <iterator>
#include "slic3r/GUI/GLToolbar.hpp"
#include <optional>
#include <cstdlib>
#include <utility>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Point.hpp"
#include <boost/algorithm/string/predicate.hpp>
#include "libslic3r/LocalesUtils.hpp"
#include "libslic3r/Format/STEP.hpp"
#include <exception>
#include "slic3r/GUI/ParamsPanel.hpp"
#include "slic3r/GUI/GUI_ObjectLayers.hpp"
#include <map>
#include <set>
#include "libslic3r/BrimEarsPoint.hpp"
#include "libslic3r/ObjectID.hpp"
#include "slic3r/GUI/GUI_ObjectSettings.hpp"
#include <cmath>
#include "slic3r/GUI/GUI_Utils.hpp"
#include <vector>
#include <unordered_map>
#include <functional>
#include <boost/algorithm/string.hpp>
#include <boost/log/trivial.hpp>
#include <wx/arrstr.h>
#include <wx/dc.h>
#include <wx/gdicmn.h>
#include <wx/colour.h>
#include <wx/dataview.h>
#include <wx/event.h>
#include <wx/dvrenderers.h>
#include <wx/chartype.h>
#include <wx/dataobj.h>
#include <wx/dnd.h>
#include <wx/busycursor.h>
#include <wx/msgdlg.h>
#include <wx/accel.h>
#include <wx/app.h>
#include <wx/progdlg.h>
#include <wx/listbook.h>
#include <wx/numformatter.h>
#include <wx/string.h>
#include <wx/setup.h>
#include <wx/settings.h>
#include <wx/utils.h>
#include <wx/headerctrl.h>
#include <wx/window.h>
#include "slic3r/Utils/FixModelByCgal.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
@@ -245,56 +300,15 @@ ObjectList::ObjectList(wxWindow* parent) :
// Key events are not correctly processed by the wxDataViewCtrl on OSX.
// Our patched wxWidgets process the keyboard accelerators.
// On the other hand, using accelerators will break in-place editing on Windows & Linux/GTK (there is no in-place editing working on OSX for wxDataViewCtrl for now).
// Bind(wxEVT_KEY_DOWN, &ObjectList::OnChar, this);
{
// Accelerators
// wxAcceleratorEntry entries[25];
wxAcceleratorEntry entries[26];
int index = 0;
entries[index++].Set(wxACCEL_CTRL, (int)'C', wxID_COPY);
entries[index++].Set(wxACCEL_CTRL, (int)'X', wxID_CUT);
entries[index++].Set(wxACCEL_CTRL, (int)'V', wxID_PASTE);
entries[index++].Set(wxACCEL_CTRL, (int)'M', wxID_DUPLICATE);
entries[index++].Set(wxACCEL_CTRL, (int)'A', wxID_SELECTALL);
entries[index++].Set(wxACCEL_CTRL, (int)'Z', wxID_UNDO);
entries[index++].Set(wxACCEL_CTRL, (int)'Y', wxID_REDO);
entries[index++].Set(wxACCEL_NORMAL, WXK_BACK, wxID_DELETE);
//entries[index++].Set(wxACCEL_NORMAL, int('+'), wxID_ADD);
//entries[index++].Set(wxACCEL_NORMAL, WXK_NUMPAD_ADD, wxID_ADD);
//entries[index++].Set(wxACCEL_NORMAL, int('-'), wxID_REMOVE);
//entries[index++].Set(wxACCEL_NORMAL, WXK_NUMPAD_SUBTRACT, wxID_REMOVE);
//entries[index++].Set(wxACCEL_NORMAL, int('p'), wxID_PRINT);
int numbers_cnt = 0;
for (auto char_number : { '1', '2', '3', '4', '5', '6', '7', '8', '9' }) {
entries[index + numbers_cnt].Set(wxACCEL_NORMAL, int(char_number), wxID_LAST + numbers_cnt+1);
entries[index + 9 + numbers_cnt].Set(wxACCEL_NORMAL, WXK_NUMPAD0 + numbers_cnt - 1, wxID_LAST + numbers_cnt+1);
numbers_cnt++;
// index++;
}
wxAcceleratorTable accel(26, entries);
SetAcceleratorTable(accel);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->copy(); }, wxID_COPY);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->paste(); }, wxID_PASTE);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->select_item_all_children(); }, wxID_SELECTALL);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->remove(); }, wxID_DELETE);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->undo(); }, wxID_UNDO);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->redo(); }, wxID_REDO);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->cut(); }, wxID_CUT);
this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->clone(); }, wxID_DUPLICATE);
//this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->increase_instances(); }, wxID_ADD);
//this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->decrease_instances(); }, wxID_REMOVE);
//this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->toggle_printable_state(); }, wxID_PRINT);
for (int i = 1; i < 10; i++)
this->Bind(wxEVT_MENU, [this, i](wxCommandEvent &evt) {
if (filaments_count() > 1 && i <= filaments_count())
this->set_extruder_for_selected_items(i);
}, wxID_LAST+i);
m_accel = accel;
}
m_shortcut_id_base = wxWindow::NewControlId(int(Shortcut::Count));
for (size_t i = 0; i < size_t(Shortcut::Count); ++i)
this->Bind(wxEVT_MENU, [this, shortcut = Shortcut(i)](wxCommandEvent&) { dispatch_shortcut(shortcut); }, m_shortcut_id_base + int(i));
for (int i = 1; i < 10; i++)
this->Bind(wxEVT_MENU, [this, i](wxCommandEvent &evt) {
if (filaments_count() > 1 && i <= filaments_count())
this->set_extruder_for_selected_items(i);
}, wxID_LAST+i);
update_shortcut_accelerators();
#else //__WXOSX__
Bind(wxEVT_CHAR, [this](wxKeyEvent& event) { key_event(event); }); // doesn't work on OSX
#endif
@@ -1199,17 +1213,39 @@ void ObjectList::update_name_in_model(const wxDataViewItem& item) const
if (m_objects_model->GetItemType(item) & itObject) {
std::string name = m_objects_model->GetName(item).ToUTF8().data();
if (obj->name != name) {
const std::string previous_name = obj->name;
obj->name = name;
// if object has just one volume, rename this volume too
if (obj->volumes.size() == 1)
obj->volumes[0]->name = obj->name;
Slic3r::save_object_mesh(*obj);
LifecycleEventContext ctx;
ctx.name = name;
ctx.previous_name = previous_name;
ctx.id = std::to_string(obj->id().id);
ctx.index = obj_idx;
ctx.source = "object";
fire_lifecycle_event(LifecycleEvent::ObjectRenamed, ctx);
}
return;
}
if (volume_id < 0) return;
obj->volumes[volume_id]->name = m_objects_model->GetName(item).ToUTF8().data();
std::string name = m_objects_model->GetName(item).ToUTF8().data();
if (obj->volumes[volume_id]->name == name)
return;
const std::string previous_name = obj->volumes[volume_id]->name;
obj->volumes[volume_id]->name = name;
LifecycleEventContext ctx;
ctx.name = name;
ctx.previous_name = previous_name;
ctx.id = std::to_string(obj->volumes[volume_id]->id().id);
ctx.index = volume_id;
ctx.source = "volume";
fire_lifecycle_event(LifecycleEvent::ObjectRenamed, ctx);
}
void ObjectList::update_name_in_list(int obj_idx, int vol_idx) const
@@ -1796,36 +1832,10 @@ void ObjectList::decrease_instances()
#ifndef __WXOSX__
void ObjectList::key_event(wxKeyEvent& event)
{
//if (event.GetKeyCode() == WXK_TAB)
// Navigate(event.ShiftDown() ? wxNavigationKeyEvent::IsBackward : wxNavigationKeyEvent::IsForward);
//else
if (event.GetKeyCode() == WXK_DELETE /*|| event.GetKeyCode() == WXK_BACK*/ )
remove();
//else if (event.GetKeyCode() == WXK_F5)
// wxGetApp().plater()->reload_all_from_disk();
else if (wxGetKeyState(wxKeyCode('A')) && wxGetKeyState(WXK_CONTROL/*WXK_SHIFT*/))
select_item_all_children();
else if (wxGetKeyState(wxKeyCode('C')) && wxGetKeyState(WXK_CONTROL))
copy();
else if (wxGetKeyState(wxKeyCode('V')) && wxGetKeyState(WXK_CONTROL))
paste();
else if (wxGetKeyState(wxKeyCode('Y')) && wxGetKeyState(WXK_CONTROL))
redo();
else if (wxGetKeyState(wxKeyCode('Z')) && wxGetKeyState(WXK_CONTROL))
undo();
else if (wxGetKeyState(wxKeyCode('X')) && wxGetKeyState(WXK_CONTROL))
cut();
else if (wxGetKeyState(wxKeyCode('K')) && wxGetKeyState(WXK_CONTROL))
clone();
else if (event.GetUnicodeKey() == '+')
increase_instances();
else if (event.GetUnicodeKey() == '-')
decrease_instances();
else if (event.GetUnicodeKey() == 'p')
toggle_printable_state();
else if (event.GetUnicodeKey() == 'd')
toggle_auto_drop();
else if (filaments_count() > 1) {
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::ObjectList, KeyChord::from_event(event));
if (shortcut.has_value() && dispatch_shortcut(*shortcut))
return;
if (filaments_count() > 1) {
std::vector<wxChar> numbers = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
wxChar key_char = event.GetUnicodeKey();
if (std::find(numbers.begin(), numbers.end(), key_char) != numbers.end()) {
@@ -1842,6 +1852,43 @@ void ObjectList::key_event(wxKeyEvent& event)
}
#endif /* __WXOSX__ */
#ifdef __WXOSX__
void ObjectList::update_shortcut_accelerators()
{
std::vector<wxAcceleratorEntry> entries;
const ShortcutRegistry& shortcuts = wxGetApp().shortcuts();
for (Shortcut shortcut : shortcuts_in(ShortcutContext::ObjectList))
if (const KeyChord chord = shortcuts.binding(shortcut); chord.valid())
entries.push_back(chord.to_accelerator_entry(m_shortcut_id_base + int(shortcut)));
for (int i = 1; i < 10; ++i) {
entries.emplace_back(wxACCEL_NORMAL, '0' + i, wxID_LAST + i);
entries.emplace_back(wxACCEL_NORMAL, WXK_NUMPAD0 + i, wxID_LAST + i);
}
m_accel = wxAcceleratorTable(int(entries.size()), entries.data());
SetAcceleratorTable(m_accel);
}
#endif /* __WXOSX__ */
bool ObjectList::dispatch_shortcut(Shortcut shortcut)
{
switch (shortcut) {
case Shortcut::DeleteSelected: remove(); break;
case Shortcut::SelectAll: select_item_all_children(); break;
case Shortcut::Copy: copy(); break;
case Shortcut::Paste: paste(); break;
case Shortcut::Cut: cut(); break;
case Shortcut::Undo: undo(); break;
case Shortcut::Redo: redo(); break;
case Shortcut::CloneSelected: clone(); break;
case Shortcut::AddInstance: increase_instances(); break;
case Shortcut::RemoveInstance: decrease_instances(); break;
case Shortcut::TogglePrintable: toggle_printable_state(); break;
case Shortcut::ToggleAutoDrop: toggle_auto_drop(); break;
default: return false;
}
return true;
}
void ObjectList::OnBeginDrag(wxDataViewEvent &event)
{
const bool mult_sel = multiple_selection();
@@ -1949,6 +1996,12 @@ bool ObjectList::can_drop(const wxDataViewItem& item, int& src_obj_id, int& src_
if (dragged_item_v_type == item_v_type && dragged_item_v_type != ModelVolumeType::MODEL_PART)
return true;
// Use tree item types: hidden cut connectors make tree indices differ from volumes indices.
if (is_precise_seam(dragged_item_v_type) && is_precise_seam(item_v_type))
// Allow reordering only within the strong or weak modifier group.
return is_precise_seam_strong(dragged_item_v_type) == is_precise_seam_strong(item_v_type);
if ((dragged_item_v_type != item_v_type) || // we can't reorder volumes outside of types
item_v_type >= ModelVolumeType::SUPPORT_BLOCKER) // support blockers/enforcers can't change its place
return false;
@@ -2038,10 +2091,43 @@ void ObjectList::OnDrop(wxDataViewEvent &event)
int to_volume_id = m_objects_model->GetVolumeIdByItem(item);
int delta = to_volume_id < from_volume_id ? -1 : 1;
auto &volumes = (*m_objects)[m_dragged_data.obj_idx()]->volumes;
const int obj_idx = m_dragged_data.obj_idx();
// Object-indexed UI maps may be stale after another object is removed or reordered.
if (obj_idx < 0 || size_t(obj_idx) >= m_objects->size()) {
event.Veto();
m_dragged_data.clear();
return;
}
const ModelObject *object = (*m_objects)[obj_idx];
auto &volumes = (*m_objects)[obj_idx]->volumes;
std::vector<size_t> visible_volume_indices;
visible_volume_indices.reserve(volumes.size());
for (size_t idx = 0; idx < volumes.size(); ++idx)
// Match add_volumes_to_object_in_list: only connectors of cut objects are hidden.
if (!(object->is_cut() && volumes[idx]->is_cut_connector()))
visible_volume_indices.push_back(idx);
int cnt = 0;
for (int id = from_volume_id; cnt < abs(from_volume_id - to_volume_id); id += delta, cnt++) std::swap(volumes[id], volumes[id + delta]);
// Validate the entire move before any swap; these checks must also protect Release builds.
if (from_volume_id < 0 || to_volume_id < 0 ||
size_t(from_volume_id) >= visible_volume_indices.size() || size_t(to_volume_id) >= visible_volume_indices.size()) {
event.Veto();
m_dragged_data.clear();
return;
}
// Move through visible slots only, keeping hidden cut connectors at their original indices.
// The local mapping stays valid because no hidden volume changes slots during the move.
for (int id = from_volume_id; id != to_volume_id; id += delta) {
const size_t current_idx = visible_volume_indices[id];
const size_t next_idx = visible_volume_indices[id + delta];
std::swap(volumes[current_idx], volumes[next_idx]);
}
// Later selection/filament handlers use this cache; repair it for the moved object too.
auto &ui_to_model = m_objects_model->get_ui_and_3d_volume_map()[obj_idx];
ui_to_model.clear();
for (size_t idx = 0; idx < visible_volume_indices.size(); ++idx)
ui_to_model[int(idx)] = int(visible_volume_indices[idx]);
select_item(m_objects_model->ReorganizeChildren(from_volume_id, to_volume_id, m_objects_model->GetParent(item)));
@@ -3548,7 +3634,14 @@ void ObjectList::delete_all_connectors_for_object(int obj_idx)
obj->delete_connectors();
if (obj->volumes.empty() || !obj->has_solid_mesh()) {
const std::string deleted_obj_name = obj->name;
model.delete_object(idx);
{
Slic3r::LifecycleEventContext ctx;
ctx.name = deleted_obj_name;
ctx.code = Slic3r::LifecycleEvtCode::Ok;
Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectDeleted, ctx);
}
m_objects_model->Delete(m_objects_model->GetItemById(idx));
continue;
}
@@ -3853,7 +3946,8 @@ wxDataViewItem ObjectList::add_settings_item(wxDataViewItem parent_item, const D
const bool is_layer_settings = m_objects_model->GetItemType(parent_item) == itLayer;
if (!is_object_settings) {
ModelVolumeType volume_type = m_objects_model->GetVolumeType(parent_item);
if (volume_type == ModelVolumeType::NEGATIVE_VOLUME || volume_type == ModelVolumeType::SUPPORT_BLOCKER || volume_type == ModelVolumeType::SUPPORT_ENFORCER)
// Precise Seam is non-printing helper geometry — no per-volume settings
if (volume_type == ModelVolumeType::NEGATIVE_VOLUME || volume_type == ModelVolumeType::SUPPORT_BLOCKER || volume_type == ModelVolumeType::SUPPORT_ENFORCER || is_precise_seam(volume_type))
return ret;
}
@@ -4117,6 +4211,13 @@ void ObjectList::add_object_to_list(size_t obj_idx, bool call_selection_changed,
const auto item = m_objects_model->AddObject(model_object, warning_bitmap, model_object->is_cut());
Expand(m_objects_model->GetParent(item));
{
Slic3r::LifecycleEventContext ctx;
ctx.name = model_object->name;
ctx.code = Slic3r::LifecycleEvtCode::Ok;
Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectAdded, ctx);
}
if (!do_info_update)
return;
@@ -5712,13 +5813,49 @@ ModelVolumeType ObjectList::get_selected_volume_type()
return ModelVolumeType::INVALID;
}
void ObjectList::set_volume_type(ModelVolumeType new_type)
// ---------------- Helpers for Precise Seam group-aware type changes ----------------
// Used by set_volume_type() and the "Precise Seam Type" subtype submenu handler.
// Detects whether a type change crosses a Precise Seam "group boundary" that requires
// manual repositioning inside ModelObject::volumes[]:
// - between any non-PS type and any PS subtype, or
// - between strong PS (CENTER/LEFT/RIGHT) and weak PS (ENFORCED/BLOCKED/NEUTRAL).
// sort_volumes() is stable and groups strong PS before weak PS. When a volume crosses
// a group, moving it to the end of volumes[] lets the subsequent stable sort place it
// at the end of its new group. Without this, a strong→weak transition would leave the
// volume stuck in the strong segment and break the modifier-application order.
static bool precise_seam_group_changed(ModelVolumeType old_type, ModelVolumeType new_type)
{
const bool old_is_ps = is_precise_seam(old_type);
const bool new_is_ps = is_precise_seam(new_type);
if (old_is_ps != new_is_ps)
return true; // non-PS ↔ PS transition
if (!old_is_ps)
return false; // both non-PS — regular enum ordering is enough
return is_precise_seam_strong(old_type) != is_precise_seam_strong(new_type);
}
// Moves a volume to the end of ModelObject::volumes[]. Combined with stable sort_volumes(),
// this places it at the end of its new group while preserving relative order of others.
static void move_volume_to_end(ModelObject* obj, ModelVolume* volume)
{
if (obj == nullptr || volume == nullptr)
return;
auto it = std::find(obj->volumes.begin(), obj->volumes.end(), volume);
if (it != obj->volumes.end()) {
obj->volumes.erase(it);
obj->volumes.push_back(volume);
}
}
void ObjectList::set_volume_type(ModelVolumeType new_type, bool preserve_ps_subtype)
{
struct VolumeSelection {
int object_idx;
ModelVolume* volume;
};
// --- Collect selected volumes from the object tree, falling back to the 3D canvas ---
std::vector<VolumeSelection> volumes;
auto add_volume = [&volumes](int obj_idx, ModelVolume* volume) {
if (volume == nullptr)
@@ -5783,25 +5920,49 @@ void ObjectList::set_volume_type(ModelVolumeType new_type)
// and historically crashed (originally fixed in the now-disabled change_part_type() by hiding
// Support entries in the old choice dialog; the UI-side guard for the current submenu lives
// in MenuFactory::append_menu_item_change_type, see #13120).
// The same rationale applies to any Precise Seam subtype.
// This block must never be reachable under a healthy UI; if it ever logs, the UI guard has
// been bypassed (new entry point, refactor, plugin, etc.) and should be investigated.
if (new_type == ModelVolumeType::SUPPORT_BLOCKER || new_type == ModelVolumeType::SUPPORT_ENFORCER) {
if (new_type == ModelVolumeType::SUPPORT_BLOCKER || new_type == ModelVolumeType::SUPPORT_ENFORCER
|| is_precise_seam(new_type)) {
const bool has_text_or_svg = std::any_of(volumes.begin(), volumes.end(),
[](const VolumeSelection& sel) { return sel.volume->is_svg() || sel.volume->is_text(); });
if (has_text_or_svg) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__
<< ": blocked attempt to set SUPPORT_BLOCKER/ENFORCER on SVG/text volume; "
<< ": blocked attempt to set SUPPORT_BLOCKER/ENFORCER or Precise Seam on SVG/text volume; "
<< "UI guard should have prevented this -- possible regression in the Change Type menu";
return;
}
}
// --- Subtype preservation for the generic "Precise Seam" entry ---
// The Change Type submenu uses PRECISE_SEAM_CENTER as the single "Precise Seam" entry
// and calls with preserve_ps_subtype=true: volumes that are already Precise Seam keep
// their subtype (LEFT/RIGHT/etc.); only non-PS volumes get converted to the default
// CENTER subtype.
//
// The "Precise Seam Type" subtype submenu calls with preserve_ps_subtype=false: the
// user explicitly picked CENTER and all selected PS volumes must be set to CENTER
// verbatim (otherwise CENTER would become unreachable for mixed-subtype selections,
// since preservation would keep every volume at its current subtype and any_diff would
// be false — the reason for this two-parameter split).
auto effective_new_type = [new_type, preserve_ps_subtype](const ModelVolume* v) -> ModelVolumeType {
if (preserve_ps_subtype && new_type == ModelVolumeType::PRECISE_SEAM_CENTER && v->is_precise_seam())
return v->type();
return new_type;
};
// --- Any-change check using the effective target type ---
const bool any_diff = std::any_of(volumes.begin(), volumes.end(),
[new_type](const VolumeSelection& sel) { return sel.volume->type() != new_type; });
[&effective_new_type](const VolumeSelection& sel) {
return sel.volume->type() != effective_new_type(sel.volume);
});
if (!any_diff)
return;
// --- Last-solid-part guard (pre-existing behavior) ---
// When converting away from MODEL_PART, ensure at least one solid part remains per object.
if (new_type != ModelVolumeType::MODEL_PART) {
std::map<int, int> total_part_cnt;
std::map<int, int> selected_part_cnt;
@@ -5829,14 +5990,36 @@ void ObjectList::set_volume_type(ModelVolumeType new_type)
take_snapshot(_u8L("Change part type"));
// --- Apply type changes with Precise Seam group-aware repositioning ---
// Note: `changed_volumes` / `touched_objects` track every processed volume, including
// subtype-preservation no-ops. Tracking no-ops is intentional — it ensures such
// volumes remain selected after the post-apply rebuild. Without that, a cross-object
// mixed selection like [Part, PS_LEFT] clicking "Change type → Precise Seam" would
// silently deselect the preserved PS_LEFT: only the Part's object would enter
// touched_objects, and the final SetSelections(new_selection) would drop PS_LEFT.
std::set<const ModelVolume*> changed_volumes;
std::set<int> touched_objects;
for (const auto& sel : volumes) {
sel.volume->set_type(new_type);
const ModelVolumeType target = effective_new_type(sel.volume);
const ModelVolumeType old_type = sel.volume->type();
// Record the volume for reselection before the no-op short-circuit (see comment above).
changed_volumes.insert(sel.volume);
touched_objects.insert(sel.object_idx);
if (old_type == target)
continue; // subtype-preservation no-op — nothing to write/move
sel.volume->set_type(target);
// If the change crosses a PS group boundary, move the volume to the end of volumes[]
// so the stable sort in reorder_volumes_and_get_selection() places it at the end of
// its new group (see precise_seam_group_changed() for details).
if (precise_seam_group_changed(old_type, target))
move_volume_to_end((*m_objects)[sel.object_idx], sel.volume);
}
// --- Rebuild selection to follow the changed volumes ---
wxDataViewItemArray new_selection;
for (int obj_idx : touched_objects) {
wxDataViewItemArray sel_items = reorder_volumes_and_get_selection(obj_idx, [&changed_volumes](const ModelVolume* volume) {