diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index d1cc9f5c4a9..0c0d20db050 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -27,6 +27,7 @@ #include "wx/dcclient.h" #include "slic3r/Utils/MacDarkMode.hpp" #endif +#include // ---------------------------------------------------------------------------- // MenuWithSeparators @@ -618,6 +619,44 @@ void MenuFactory::append_menu_item_add_text(wxMenu* menu, ModelVolumeType type, } void MenuFactory::append_menu_item_add_svg(wxMenu *menu, ModelVolumeType type, bool is_submenu_item /* = true*/){ + if (type == ModelVolumeType::INVALID) { + auto add_svg_shape = [](const wxCommandEvent &) { + wxFileDialog dialog(wxGetApp().GetTopWindow(), + _L("Choose one or more SVG files:"), + from_u8(wxGetApp().app_config->get_last_dir()), "", + file_wildcards(FT_SVG), wxFD_OPEN | wxFD_MULTIPLE | wxFD_FILE_MUST_EXIST); + if (dialog.ShowModal() != wxID_OK) + return; + + wxArrayString input_files; + dialog.GetPaths(input_files); + if (input_files.IsEmpty()) + return; + + std::vector paths; + paths.reserve(input_files.size()); + for (const auto &input_file : input_files) + paths.emplace_back(input_file.ToUTF8().data()); + + wxString snapshot_label = (paths.size() == 1) ? _L("Import Object") : _L("Import Objects"); + snapshot_label += ": "; + snapshot_label += wxString::FromUTF8(boost::filesystem::path(paths.front()).filename().string().c_str()); + for (size_t i = 1; i < paths.size(); ++i) { + snapshot_label += ", "; + snapshot_label += wxString::FromUTF8(boost::filesystem::path(paths[i]).filename().string().c_str()); + } + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), snapshot_label); + wxGetApp().plater()->load_files(paths, true, false); + }; + + wxString item_name = _L("SVG"); + menu->AppendSeparator(); + const std::string icon_name = ""; + append_menu_item(menu, wxID_ANY, item_name, "", add_svg_shape, icon_name, menu); + return; + } + append_menu_itemm_add_(_L("SVG"), GLGizmosManager::Svg, menu, type, is_submenu_item); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp index dbacc213e3f..7ab14cc401a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp @@ -26,6 +26,15 @@ #include "nanosvg/nanosvg.h" // load SVG file #include // detection of change DPI +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include @@ -118,6 +127,22 @@ std::string get_file_name(const std::string &file_path); /// Name for volume std::string volume_name(const EmbossShape& shape); +struct SvgImportOptions +{ + std::vector selected_layers; +}; + +struct SvgLayerInfo +{ + std::vector names; + std::vector shape_to_layer; + std::vector default_selected; +}; + +SvgLayerInfo build_svg_layer_info(const EmbossShape::SvgFile &svg_file, const NSVGimage &image); +bool select_svg_import_options(const SvgLayerInfo &layer_info, SvgImportOptions &options, bool show_relink_notice = false); +ExPolygonsWithIds create_shapes_with_selected_layers(EmbossShape::SvgFile &svg_file, const SvgImportOptions &options, const SvgLayerInfo &layer_info); + enum class IconType : unsigned { reset_value, reset_value_hover, @@ -559,8 +584,20 @@ void GLGizmoSVG::on_set_state() } void GLGizmoSVG::data_changed(bool is_serializing) { + if (m_relink_prompt_active) + return; + + if (m_empty_shape_prompt_canceled_volume_id.valid()) { + const Selection &selection = m_parent.get_selection(); + const Model *model = selection.get_model(); + const GLVolume *gl_volume = model != nullptr ? get_selected_gl_volume(selection) : nullptr; + const ModelVolume *selected_volume = (gl_volume != nullptr && model != nullptr) ? get_model_volume(*gl_volume, model->objects) : nullptr; + if (selected_volume == nullptr || selected_volume->id() != m_empty_shape_prompt_canceled_volume_id) + m_empty_shape_prompt_canceled_volume_id = ObjectID{}; + } + set_volume_by_selection(); - if (!is_serializing && m_volume == nullptr) + if (!is_serializing && m_volume == nullptr && !m_empty_shape_prompt_canceled_volume_id.valid()) close(); } @@ -1179,16 +1216,26 @@ std::vector create_shape_warnings(const EmbossShape &shape, float s void GLGizmoSVG::set_volume_by_selection() { + if (m_relink_prompt_active) + return; + const Selection &selection = m_parent.get_selection(); + const Model *model = selection.get_model(); + if (model == nullptr) + return reset_volume(); + const GLVolume *gl_volume = get_selected_gl_volume(selection); if (gl_volume == nullptr) return reset_volume(); - const ModelObjectPtrs &objects = selection.get_model()->objects; + const ModelObjectPtrs &objects = model->objects; ModelVolume *volume =get_model_volume(*gl_volume, objects); if (volume == nullptr) return reset_volume(); + if (volume->id() == m_empty_shape_prompt_canceled_volume_id) + return; + // is same volume as actual selected? if (volume->id() == m_volume_id) return; @@ -1222,9 +1269,25 @@ void GLGizmoSVG::set_volume_by_selection() assert(svg_file.image.get() != nullptr); const NSVGimage &image = *svg_file.image; ExPolygonsWithIds &shape_ids = es.shapes_with_ids; - if (shape_ids.empty()) { - NSVGLineParams params{get_tesselation_tolerance(get_scale_for_tolerance())}; - shape_ids = create_shape_with_ids(image, params); + bool needs_relink_prompt = false; + if (shape_ids.empty()) { + const SvgLayerInfo layer_info = build_svg_layer_info(svg_file, image); + SvgImportOptions import_options; + import_options.selected_layers = layer_info.default_selected; + if (import_options.selected_layers.empty()) + import_options.selected_layers.assign(layer_info.names.size(), true); + shape_ids = create_shapes_with_selected_layers(svg_file, import_options, layer_info); + if (shape_ids.empty()) { + import_options.selected_layers.assign(layer_info.names.size(), true); + shape_ids = create_shapes_with_selected_layers(svg_file, import_options, layer_info); + if (shape_ids.empty()) { + show_error(nullptr, _L("Selected SVG layers are empty.")); + m_empty_shape_prompt_canceled_volume_id = volume->id(); + return reset_volume(); + } + } + m_empty_shape_prompt_canceled_volume_id = ObjectID{}; + needs_relink_prompt = true; } reset_volume(); // clear cached data @@ -1239,6 +1302,64 @@ void GLGizmoSVG::set_volume_by_selection() m_distance = calc_distance(*gl_volume, m_raycast_manager, m_parent); m_shape_bb = get_extents(m_volume_shape.shapes_with_ids); + + if (needs_relink_prompt && !m_relink_prompt_scheduled) { + m_relink_prompt_scheduled = true; + m_relink_prompt_volume_id = m_volume_id; + wxGetApp().plater()->CallAfter([this]() { + m_relink_prompt_scheduled = false; + if (m_relink_prompt_active || this->get_state() != GLGizmoBase::On || m_relink_prompt_volume_id.invalid()) + return; + + const Selection &selection = m_parent.get_selection(); + const Model *model = selection.get_model(); + if (model == nullptr) + return; + + ModelVolume *volume = get_model_volume(m_relink_prompt_volume_id, model->objects); + if (volume == nullptr || !volume->emboss_shape.has_value() || !volume->emboss_shape->svg_file.has_value()) + return; + + EmbossShape &es = *volume->emboss_shape; + EmbossShape::SvgFile &svg_file = *es.svg_file; + if (svg_file.image == nullptr && init_image(svg_file) == nullptr) + return; + + const SvgLayerInfo layer_info = build_svg_layer_info(svg_file, *svg_file.image); + SvgImportOptions import_options; + import_options.selected_layers = layer_info.default_selected; + + m_relink_prompt_active = true; + const bool accepted = select_svg_import_options(layer_info, import_options, true); + m_relink_prompt_active = false; + + if (!accepted) { + m_empty_shape_prompt_canceled_volume_id = volume->id(); + if (m_volume != nullptr && m_volume_id == volume->id()) + reset_volume(); + return; + } + + ExPolygonsWithIds selected_shapes = create_shapes_with_selected_layers(svg_file, import_options, layer_info); + if (selected_shapes.empty()) { + show_error(nullptr, _L("Selected SVG layers are empty.")); + m_empty_shape_prompt_canceled_volume_id = volume->id(); + if (m_volume != nullptr && m_volume_id == volume->id()) + reset_volume(); + return; + } + + es.shapes_with_ids = selected_shapes; + m_empty_shape_prompt_canceled_volume_id = ObjectID{}; + + if (m_volume != nullptr && m_volume_id == volume->id()) { + m_volume_shape.shapes_with_ids = selected_shapes; + m_shape_warnings = create_shape_warnings(m_volume_shape, get_scale_for_tolerance()); + m_shape_bb = get_extents(m_volume_shape.shapes_with_ids); + process(false); + } + }); + } } namespace { void delete_texture(Texture& texture){ @@ -1250,6 +1371,9 @@ void delete_texture(Texture& texture){ } void GLGizmoSVG::reset_volume() { + m_relink_prompt_scheduled = false; + m_relink_prompt_volume_id = ObjectID{}; + if (m_volume == nullptr) return; // already reseted @@ -1325,6 +1449,8 @@ bool GLGizmoSVG::process(bool make_snapshot) { auto base = std::make_unique(m_volume->name, m_job_cancel, std::move(shape)); base->is_outside = m_volume->type() == ModelVolumeType::MODEL_PART; DataUpdate data{std::move(base), m_volume_id, make_snapshot}; + if (!m_volume_shape.projection.use_surface) + data.trmat = m_volume->get_matrix(); return start_update_volume(std::move(data), *m_volume, m_parent.get_selection(), m_raycast_manager); } @@ -1800,6 +1926,7 @@ void GLGizmoSVG::draw_size() if (new_relative_scale.has_value()){ Selection &selection = m_parent.get_selection(); selection.setup_cache(); + const double preserved_z = (m_volume != nullptr) ? m_volume->get_offset().z() : 0.0; auto selection_scale_fnc = [&selection, rel_scale = *new_relative_scale]() { selection.scale(rel_scale, get_drag_transformation_type(selection)); @@ -1809,18 +1936,21 @@ void GLGizmoSVG::draw_size() std::string snap_name; // Empty mean do not store on undo/redo stack m_parent.do_scale(snap_name); wxGetApp().obj_manipul()->set_dirty(); + + // Keep the current Z placement while scaling XY from the SVG panel. + if (m_volume != nullptr && !is_approx(m_volume->get_offset().z(), preserved_z)) { + m_volume->set_offset(Axis::Z, preserved_z); + if (!selection.get_volume_idxs().empty()) { + if (GLVolume *gl_volume = selection.get_volume(*selection.get_volume_idxs().begin()); gl_volume != nullptr) + gl_volume->set_volume_offset(Axis::Z, preserved_z); + } + selection.setup_cache(); + } + // should be the almost same calculate_scale(); - - const NSVGimage *img = m_volume_shape.svg_file->image.get(); - assert(img != NULL); - if (img != NULL){ - NSVGLineParams params{get_tesselation_tolerance(get_scale_for_tolerance())}; - m_volume_shape.shapes_with_ids = create_shape_with_ids(*img, params); - m_volume_shape.final_shape = {}; // reset cache for final shape - if (!make_snap) // Be carefull: Last change may be without change of scale - process(false); - } + if (!make_snap) // Be carefull: Last change may be without change of scale + process(false); } if (make_snap) @@ -2172,6 +2302,337 @@ GuiCfg create_gui_configuration() { return cfg; } +static bool is_svg_shape_tag(const std::string &tag_name) +{ + return tag_name == "path" || tag_name == "rect" || tag_name == "circle" || + tag_name == "ellipse" || tag_name == "polygon" || tag_name == "polyline" || + tag_name == "line"; +} + +static std::string get_xml_attr(const std::string &tag, const char *attr_name) +{ + const std::string key = std::string(attr_name) + "="; + size_t pos = tag.find(key); + if (pos == std::string::npos) + return {}; + pos += key.size(); + if (pos >= tag.size()) + return {}; + const char quote = tag[pos]; + if (quote != '"' && quote != '\'') + return {}; + size_t end = tag.find(quote, pos + 1); + if (end == std::string::npos || end <= pos + 1) + return {}; + return tag.substr(pos + 1, end - pos - 1); +} + +static bool has_hidden_style_flag(const std::string &style_raw) +{ + if (style_raw.empty()) + return false; + const std::string style = boost::algorithm::to_lower_copy(style_raw); + return style.find("display:none") != std::string::npos || + style.find("visibility:hidden") != std::string::npos || + style.find("visibility:collapse") != std::string::npos; +} + +static bool is_hidden_tag(const std::string &tag) +{ + const std::string display = boost::algorithm::to_lower_copy(get_xml_attr(tag, "display")); + const std::string visibility = boost::algorithm::to_lower_copy(get_xml_attr(tag, "visibility")); + if (display == "none") + return true; + if (visibility == "hidden" || visibility == "collapse") + return true; + return has_hidden_style_flag(get_xml_attr(tag, "style")); +} + +static bool parse_explicit_svg_layers(const std::string &svg_text, size_t shape_count, SvgLayerInfo &out) +{ + constexpr size_t npos = std::numeric_limits::max(); + out.names.clear(); + out.shape_to_layer.assign(shape_count, size_t(0)); + out.default_selected.clear(); + + std::vector group_stack; + std::vector hidden_stack; + size_t current_layer = npos; + bool current_hidden = false; + size_t shape_index = 0; + bool has_layer_groups = false; + + size_t pos = 0; + while (true) { + const size_t lt = svg_text.find('<', pos); + if (lt == std::string::npos) + break; + const size_t gt = svg_text.find('>', lt + 1); + if (gt == std::string::npos) + break; + + std::string tag = svg_text.substr(lt + 1, gt - lt - 1); + boost::algorithm::trim(tag); + if (tag.empty() || tag[0] == '?' || tag[0] == '!') { + pos = gt + 1; + continue; + } + + const bool is_end_tag = tag[0] == '/'; + const bool self_closing = !tag.empty() && tag.back() == '/'; + + if (is_end_tag) { + std::string end_name = tag.substr(1); + boost::algorithm::trim(end_name); + if (end_name == "g" && !group_stack.empty() && !hidden_stack.empty()) { + current_layer = group_stack.back(); + group_stack.pop_back(); + current_hidden = hidden_stack.back(); + hidden_stack.pop_back(); + } + pos = gt + 1; + continue; + } + + std::string tag_name = tag.substr(0, tag.find_first_of(" \t\r\n/")); + boost::algorithm::to_lower(tag_name); + + if (tag_name == "g") { + group_stack.push_back(current_layer); + hidden_stack.push_back(current_hidden); + current_hidden = current_hidden || is_hidden_tag(tag); + const std::string group_mode = get_xml_attr(tag, "inkscape:groupmode"); + if (group_mode == "layer") { + has_layer_groups = true; + std::string layer_name = get_xml_attr(tag, "inkscape:label"); + if (layer_name.empty()) + layer_name = get_xml_attr(tag, "id"); + if (layer_name.empty()) + layer_name = GUI::format("%1% %2%", _u8L("Layer"), out.names.size() + 1); + + auto it = std::find(out.names.begin(), out.names.end(), layer_name); + if (it == out.names.end()) { + current_layer = out.names.size(); + out.names.push_back(layer_name); + out.default_selected.push_back(!current_hidden); + } else { + current_layer = static_cast(std::distance(out.names.begin(), it)); + out.default_selected[current_layer] = out.default_selected[current_layer] && !current_hidden; + } + } + if (self_closing && !group_stack.empty() && !hidden_stack.empty()) { + current_layer = group_stack.back(); + group_stack.pop_back(); + current_hidden = hidden_stack.back(); + hidden_stack.pop_back(); + } + pos = gt + 1; + continue; + } + + if (is_svg_shape_tag(tag_name)) { + if (shape_index < out.shape_to_layer.size()) + out.shape_to_layer[shape_index] = current_layer == npos ? size_t(0) : current_layer; + ++shape_index; + } + + pos = gt + 1; + } + + if (!has_layer_groups) + return false; + + if (out.names.empty()) + out.names.push_back(GUI::format("%1% %2%", _u8L("Layer"), 1)); + if (out.default_selected.size() != out.names.size()) + out.default_selected.assign(out.names.size(), true); + + for (size_t &layer_idx : out.shape_to_layer) + if (layer_idx == npos || layer_idx >= out.names.size()) + layer_idx = 0; + + // Inkscape renders the layer stack top-to-bottom opposite to SVG declaration order. + // Reverse the UI/order mapping so the dialog matches Inkscape's visual layer list. + if (out.names.size() > 1) { + const size_t last_idx = out.names.size() - 1; + std::reverse(out.names.begin(), out.names.end()); + std::reverse(out.default_selected.begin(), out.default_selected.end()); + for (size_t &layer_idx : out.shape_to_layer) + layer_idx = last_idx - layer_idx; + } + + return true; +} + +SvgLayerInfo build_svg_layer_info(const EmbossShape::SvgFile &svg_file, const NSVGimage &image) +{ + SvgLayerInfo info; + const size_t shape_count = get_shapes_count(image); + + if (shape_count == 0) { + info.names.push_back(GUI::format("%1% %2%", _u8L("Layer"), 1)); + info.shape_to_layer.assign(1, size_t(0)); + info.default_selected.assign(1, true); + return info; + } + + if (svg_file.file_data != nullptr && parse_explicit_svg_layers(*svg_file.file_data, shape_count, info)) + return info; + + // Fallback: no explicit layers in source SVG, treat as one logical layer. + info.names.push_back(GUI::format("%1% %2%", _u8L("Layer"), 1)); + info.shape_to_layer.assign(shape_count, size_t(0)); + info.default_selected.assign(1, true); + return info; +} + +class SvgImportOptionsDialog final : public wxDialog +{ +public: + SvgImportOptionsDialog(wxWindow *parent, const std::vector &layer_names, const std::vector &layer_default_selected, SvgImportOptions &options, bool show_relink_notice) + : wxDialog(parent, wxID_ANY, _L("SVG import options"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) + , m_layer_names(layer_names) + , m_layer_default_selected(layer_default_selected) + , m_options(options) + { + if (m_options.selected_layers.size() != m_layer_names.size()) + m_options.selected_layers = (m_layer_default_selected.size() == m_layer_names.size()) ? m_layer_default_selected : std::vector(m_layer_names.size(), true); + + auto *main_sizer = new wxBoxSizer(wxVERTICAL); + + if (show_relink_notice) { + auto *notice = new wxStaticText(this, wxID_ANY, _L("Please relink the SVG layer(s) you want to edit.")); + notice->Wrap(520); + main_sizer->Add(notice, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 8); + } + + auto *layers_box = new wxStaticBoxSizer(wxVERTICAL, this, _L("Layers")); + layers_box->GetStaticBox()->SetToolTip(_L("Select which SVG layers will be imported.")); + m_layers_panel = new wxScrolledWindow(layers_box->GetStaticBox(), wxID_ANY, wxDefaultPosition, wxSize(480, 220), wxVSCROLL | wxTAB_TRAVERSAL); + m_layers_panel->SetScrollRate(0, 10); + m_layers_panel->SetToolTip(_L("List of SVG layers detected in the file.")); + + auto *layers_grid = new wxFlexGridSizer(2, 5, 8); + wxFont header_font = this->GetFont(); + header_font.MakeBold(); + auto *hdr_import = new wxStaticText(m_layers_panel, wxID_ANY, _L("Import")); + auto *hdr_layer = new wxStaticText(m_layers_panel, wxID_ANY, _L("Layer name")); + hdr_import->SetFont(header_font); + hdr_layer->SetFont(header_font); + hdr_import->SetToolTip(_L("Enable or disable importing this layer.")); + hdr_layer->SetToolTip(_L("Name of the source SVG layer.")); + layers_grid->Add(hdr_import, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + layers_grid->Add(hdr_layer, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + + for (size_t i = 0; i < m_layer_names.size(); ++i) { + wxCheckBox *checkbox = new wxCheckBox(m_layers_panel, wxID_ANY, ""); + checkbox->SetValue(m_options.selected_layers[i]); + checkbox->SetToolTip(_L("Enable to import this layer.")); + checkbox->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent &) { this->update_row_visual_state(); }); + m_layer_checks.push_back(checkbox); + layers_grid->Add(checkbox, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + auto *layer_name_label = new wxStaticText(m_layers_panel, wxID_ANY, from_u8(m_layer_names[i])); + layer_name_label->SetToolTip(_L("Layer name read from the SVG file.")); + m_layer_name_labels.push_back(layer_name_label); + layers_grid->Add(layer_name_label, 0, wxALIGN_CENTER_VERTICAL); + } + + m_layers_panel->SetSizer(layers_grid); + layers_box->Add(m_layers_panel, 1, wxEXPAND | wxALL, 5); + main_sizer->Add(layers_box, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 5); + + wxStdDialogButtonSizer *buttons = new wxStdDialogButtonSizer(); + auto *ok_btn = new wxButton(this, wxID_OK); + auto *cancel_btn = new wxButton(this, wxID_CANCEL); + ok_btn->SetToolTip(_L("Apply the selected SVG import options.")); + cancel_btn->SetToolTip(_L("Cancel SVG import.")); + buttons->AddButton(ok_btn); + buttons->AddButton(cancel_btn); + buttons->Realize(); + main_sizer->Add(buttons, 0, wxEXPAND | wxALL, 8); + + this->SetSizerAndFit(main_sizer); + this->SetMinSize(this->GetSize()); + wxGetApp().UpdateDlgDarkUI(this); + update_row_visual_state(); + } + + bool transfer_from_controls() + { + for (size_t i = 0; i < m_layer_checks.size(); ++i) + m_options.selected_layers[i] = m_layer_checks[i]->GetValue(); + + const bool has_any_selected = std::any_of(m_options.selected_layers.begin(), m_options.selected_layers.end(), [](bool selected) { return selected; }); + if (!has_any_selected) { + show_error(this, _L("At least one SVG layer must be selected.")); + return false; + } + return true; + } + +private: + void update_row_visual_state() + { + for (size_t i = 0; i < m_layer_name_labels.size(); ++i) { + const bool enabled = i < m_layer_checks.size() && m_layer_checks[i] != nullptr && m_layer_checks[i]->GetValue(); + if (m_layer_name_labels[i] != nullptr) + m_layer_name_labels[i]->Enable(enabled); + } + if (m_layers_panel != nullptr) + m_layers_panel->Refresh(); + } + + std::vector m_layer_names; + std::vector m_layer_default_selected; + SvgImportOptions &m_options; + + wxScrolledWindow *m_layers_panel{nullptr}; + std::vector m_layer_checks; + std::vector m_layer_name_labels; +}; + +bool select_svg_import_options(const SvgLayerInfo &layer_info, SvgImportOptions &options, bool show_relink_notice) +{ + if (layer_info.names.empty()) + return true; + + SvgImportOptionsDialog dialog(nullptr, layer_info.names, layer_info.default_selected, options, show_relink_notice); + if (dialog.ShowModal() != wxID_OK) + return false; + return dialog.transfer_from_controls(); +} + +ExPolygonsWithIds create_shapes_with_selected_layers(EmbossShape::SvgFile &svg_file, const SvgImportOptions &options, const SvgLayerInfo &layer_info) +{ + NSVGimage *image = init_image(svg_file); + if (image == nullptr) + return {}; + + std::vector original_flags; + const size_t shape_count = get_shapes_count(*image); + original_flags.reserve(shape_count); + + size_t shape_index = 0; + for (NSVGshape *shape_ptr = image->shapes; shape_ptr != nullptr; shape_ptr = shape_ptr->next, ++shape_index) { + original_flags.push_back(shape_ptr->flags); + const size_t layer_index = shape_index < layer_info.shape_to_layer.size() ? layer_info.shape_to_layer[shape_index] : size_t(0); + const bool should_select = layer_index < options.selected_layers.size() ? options.selected_layers[layer_index] : true; + if (should_select) + shape_ptr->flags = static_cast(shape_ptr->flags | NSVG_FLAGS_VISIBLE); + else + shape_ptr->flags = static_cast(shape_ptr->flags & ~NSVG_FLAGS_VISIBLE); + } + + NSVGLineParams params{get_tesselation_tolerance(1.)}; + ExPolygonsWithIds shape_ids = create_shape_with_ids(*image, params); + + shape_index = 0; + for (NSVGshape *shape_ptr = image->shapes; shape_ptr != nullptr && shape_index < original_flags.size(); shape_ptr = shape_ptr->next, ++shape_index) + shape_ptr->flags = original_flags[shape_index]; + + return shape_ids; +} + std::string choose_svg_file() { wxWindow *parent = nullptr; @@ -2243,9 +2704,12 @@ EmbossShape select_shape(std::string_view filepath, double tesselation_tolerance return {}; } - // Set default and unchanging scale - NSVGLineParams params{tesselation_tolerance}; - shape.shapes_with_ids = create_shape_with_ids(*svg.image, params); + SvgImportOptions import_options; + const SvgLayerInfo layer_info = build_svg_layer_info(svg, *svg.image); + if (!select_svg_import_options(layer_info, import_options)) + return {}; + + shape.shapes_with_ids = create_shapes_with_selected_layers(svg, import_options, layer_info); // Must contain some shapes !!! if (shape.shapes_with_ids.empty()) { diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSVG.hpp b/src/slic3r/GUI/Gizmos/GLGizmoSVG.hpp index 24d8c7350b7..e62c554a02c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSVG.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSVG.hpp @@ -154,6 +154,11 @@ class GLGizmoSVG : public GLGizmoBase // When work with undo redo stack there could be situation that // m_volume point to unexisting volume so One need also objectID ObjectID m_volume_id; + // Prevent reopening the layer selection dialog every frame after user cancels it. + ObjectID m_empty_shape_prompt_canceled_volume_id; + bool m_relink_prompt_active{false}; + bool m_relink_prompt_scheduled{false}; + ObjectID m_relink_prompt_volume_id; // cancel for previous update of volume to cancel finalize part std::shared_ptr> m_job_cancel = nullptr; diff --git a/src/slic3r/GUI/Jobs/EmbossJob.cpp b/src/slic3r/GUI/Jobs/EmbossJob.cpp index b3caa01c82a..0b1915df23f 100644 --- a/src/slic3r/GUI/Jobs/EmbossJob.cpp +++ b/src/slic3r/GUI/Jobs/EmbossJob.cpp @@ -428,6 +428,10 @@ void UpdateJob::update_volume(ModelVolume *volume, TriangleMesh &&mesh, const Da if (!is_valid_input) return; + const bool is_svg_update = base.shape.svg_file.has_value(); + const bool normalize_svg_origin = is_svg_update && !base.shape.projection.use_surface; + const Vec3d prev_offset = normalize_svg_origin ? volume->get_offset() : Vec3d::Zero(); + // update volume volume->set_mesh(std::move(mesh)); volume->set_new_unique_id(); @@ -435,6 +439,11 @@ void UpdateJob::update_volume(ModelVolume *volume, TriangleMesh &&mesh, const Da // write data from base into volume base.write(*volume); + if (normalize_svg_origin) { + // Normalize updated SVG mesh origin to its geometric center, but keep part placement unchanged. + volume->center_geometry_after_creation(false); + volume->set_offset(prev_offset); + } GUI_App &app = wxGetApp(); // may be move to input if (volume->name != base.volume_name) { diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 9c6b45e1890..38ec5597802 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -31,6 +31,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -48,6 +51,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -80,6 +89,8 @@ #include "libslic3r/ModelProcessing.hpp" #include "libslic3r/FileReader.hpp" #include "libslic3r/MultipleBeds.hpp" +#include "libslic3r/NSVGUtils.hpp" +#include "libslic3r/Emboss.hpp" // For stl export #include "libslic3r/CSGMesh/ModelToCSGMesh.hpp" @@ -217,6 +228,697 @@ bool emboss_svg(Plater& plater, const wxString &svg_file, const Vec2d& mouse_dro return svg->create_volume(svg_file_str, mouse_drop_position, ModelVolumeType::MODEL_PART); } + +enum class SvgImportMode : int { + Merged = 0, + LayersAsParts = 1 +}; + +struct SvgImportOptions +{ + SvgImportMode import_mode = SvgImportMode::Merged; + std::vector selected_layers; + std::vector layer_types; + std::vector layer_from_mm; + std::vector layer_to_mm; +}; + +struct SvgLayerInfo +{ + std::vector names; + std::vector shape_to_layer; + std::vector default_selected; +}; + +static bool is_svg_shape_tag(const std::string &tag_name) +{ + return tag_name == "path" || tag_name == "rect" || tag_name == "circle" || + tag_name == "ellipse" || tag_name == "polygon" || tag_name == "polyline" || + tag_name == "line"; +} + +static std::string get_xml_attr(const std::string &tag, const char *attr_name) +{ + const std::string key = std::string(attr_name) + "="; + size_t pos = tag.find(key); + if (pos == std::string::npos) + return {}; + pos += key.size(); + if (pos >= tag.size()) + return {}; + const char quote = tag[pos]; + if (quote != '"' && quote != '\'') + return {}; + size_t end = tag.find(quote, pos + 1); + if (end == std::string::npos || end <= pos + 1) + return {}; + return tag.substr(pos + 1, end - pos - 1); +} + +static bool has_hidden_style_flag(const std::string &style_raw) +{ + if (style_raw.empty()) + return false; + const std::string style = boost::algorithm::to_lower_copy(style_raw); + return style.find("display:none") != std::string::npos || + style.find("visibility:hidden") != std::string::npos || + style.find("visibility:collapse") != std::string::npos; +} + +static bool is_hidden_tag(const std::string &tag) +{ + const std::string display = boost::algorithm::to_lower_copy(get_xml_attr(tag, "display")); + const std::string visibility = boost::algorithm::to_lower_copy(get_xml_attr(tag, "visibility")); + if (display == "none") + return true; + if (visibility == "hidden" || visibility == "collapse") + return true; + return has_hidden_style_flag(get_xml_attr(tag, "style")); +} + +static bool parse_explicit_svg_layers(const std::string &svg_text, size_t shape_count, SvgLayerInfo &out) +{ + constexpr size_t npos = std::numeric_limits::max(); + out.names.clear(); + out.shape_to_layer.assign(shape_count, size_t(0)); + out.default_selected.clear(); + + std::vector group_stack; + std::vector hidden_stack; + size_t current_layer = npos; + bool current_hidden = false; + size_t shape_index = 0; + bool has_layer_groups = false; + + size_t pos = 0; + while (true) { + const size_t lt = svg_text.find('<', pos); + if (lt == std::string::npos) + break; + const size_t gt = svg_text.find('>', lt + 1); + if (gt == std::string::npos) + break; + + std::string tag = svg_text.substr(lt + 1, gt - lt - 1); + boost::algorithm::trim(tag); + if (tag.empty() || tag[0] == '?' || tag[0] == '!') { + pos = gt + 1; + continue; + } + + const bool is_end_tag = tag[0] == '/'; + const bool self_closing = !tag.empty() && tag.back() == '/'; + + if (is_end_tag) { + std::string end_name = tag.substr(1); + boost::algorithm::trim(end_name); + if (end_name == "g" && !group_stack.empty() && !hidden_stack.empty()) { + current_layer = group_stack.back(); + group_stack.pop_back(); + current_hidden = hidden_stack.back(); + hidden_stack.pop_back(); + } + pos = gt + 1; + continue; + } + + std::string tag_name = tag.substr(0, tag.find_first_of(" \t\r\n/")); + boost::algorithm::to_lower(tag_name); + + if (tag_name == "g") { + group_stack.push_back(current_layer); + hidden_stack.push_back(current_hidden); + current_hidden = current_hidden || is_hidden_tag(tag); + const std::string group_mode = get_xml_attr(tag, "inkscape:groupmode"); + if (group_mode == "layer") { + has_layer_groups = true; + std::string layer_name = get_xml_attr(tag, "inkscape:label"); + if (layer_name.empty()) + layer_name = get_xml_attr(tag, "id"); + if (layer_name.empty()) + layer_name = format("%1% %2%", _u8L("Layer"), out.names.size() + 1); + + auto it = std::find(out.names.begin(), out.names.end(), layer_name); + if (it == out.names.end()) { + current_layer = out.names.size(); + out.names.push_back(layer_name); + out.default_selected.push_back(!current_hidden); + } else { + current_layer = static_cast(std::distance(out.names.begin(), it)); + out.default_selected[current_layer] = out.default_selected[current_layer] && !current_hidden; + } + } + if (self_closing && !group_stack.empty() && !hidden_stack.empty()) { + current_layer = group_stack.back(); + group_stack.pop_back(); + current_hidden = hidden_stack.back(); + hidden_stack.pop_back(); + } + pos = gt + 1; + continue; + } + + if (is_svg_shape_tag(tag_name)) { + if (shape_index < out.shape_to_layer.size()) + out.shape_to_layer[shape_index] = current_layer == npos ? size_t(0) : current_layer; + ++shape_index; + } + + pos = gt + 1; + } + + if (!has_layer_groups) + return false; + + if (out.names.empty()) + out.names.push_back(format("%1% %2%", _u8L("Layer"), 1)); + if (out.default_selected.size() != out.names.size()) + out.default_selected.assign(out.names.size(), true); + + // Shapes outside explicit layers fall back to first layer. + for (size_t &layer_idx : out.shape_to_layer) + if (layer_idx == npos || layer_idx >= out.names.size()) + layer_idx = 0; + + // Inkscape renders the layer stack top-to-bottom opposite to SVG declaration order. + // Reverse the UI/order mapping so the dialog matches Inkscape's visual layer list. + if (out.names.size() > 1) { + const size_t last_idx = out.names.size() - 1; + std::reverse(out.names.begin(), out.names.end()); + std::reverse(out.default_selected.begin(), out.default_selected.end()); + for (size_t &layer_idx : out.shape_to_layer) + layer_idx = last_idx - layer_idx; + } + + return true; +} + +static constexpr double svg_default_extrusion_from_mm = 0.0; +static constexpr double svg_default_extrusion_base_to_mm = 10.0; +static constexpr double svg_default_extrusion_step_mm = 2.0; + +static void ensure_default_layer_ranges(SvgImportOptions &options, size_t layer_count) +{ + if (options.layer_from_mm.size() != layer_count) + options.layer_from_mm.assign(layer_count, svg_default_extrusion_from_mm); + if (options.layer_to_mm.size() != layer_count) { + options.layer_to_mm.resize(layer_count); + for (size_t i = 0; i < layer_count; ++i) { + const size_t reversed_index = layer_count - 1 - i; + options.layer_to_mm[i] = svg_default_extrusion_base_to_mm + svg_default_extrusion_step_mm * static_cast(reversed_index); + } + } +} + +static std::pair get_layer_from_to_mm(size_t layer_index, const SvgImportOptions &options) +{ + const double from = layer_index < options.layer_from_mm.size() ? options.layer_from_mm[layer_index] : svg_default_extrusion_from_mm; + double to = layer_index < options.layer_to_mm.size() ? options.layer_to_mm[layer_index] : (svg_default_extrusion_base_to_mm + svg_default_extrusion_step_mm * static_cast(layer_index)); + if (to <= from) + to = from + 0.01; + return {from, to}; +} + +class SvgImportOptionsDialog final : public wxDialog +{ +public: + SvgImportOptionsDialog(wxWindow *parent, const std::vector &layer_names, const std::vector &layer_default_selected, SvgImportOptions &options) + : wxDialog(parent, wxID_ANY, _L("SVG import options"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) + , m_layer_names(layer_names) + , m_layer_default_selected(layer_default_selected) + , m_options(options) + { + if (m_options.selected_layers.size() != m_layer_names.size()) + m_options.selected_layers = (m_layer_default_selected.size() == m_layer_names.size()) ? m_layer_default_selected : std::vector(m_layer_names.size(), true); + if (m_options.layer_types.size() != m_layer_names.size()) + m_options.layer_types.assign(m_layer_names.size(), ModelVolumeType::MODEL_PART); + ensure_default_layer_ranges(m_options, m_layer_names.size()); + + auto *main_sizer = new wxBoxSizer(wxVERTICAL); + + auto *mode_box = new wxStaticBoxSizer(wxVERTICAL, this, _L("Import mode")); + mode_box->GetStaticBox()->SetToolTip(_L("Choose how selected layers are imported.")); + m_rb_mode_merged = new wxRadioButton(mode_box->GetStaticBox(), wxID_ANY, _L("Merged layers"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP); + m_rb_mode_layers = new wxRadioButton(mode_box->GetStaticBox(), wxID_ANY, _L("Layers as parts")); + m_rb_mode_merged->SetToolTip(_L("Import selected layers as a single merged volume.")); + m_rb_mode_layers->SetToolTip(_L("Import selected layers as separate parts.")); + mode_box->Add(m_rb_mode_merged, 0, wxALL, 5); + mode_box->Add(m_rb_mode_layers, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5); + main_sizer->Add(mode_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 5); + + auto *layers_box = new wxStaticBoxSizer(wxVERTICAL, this, _L("SVG layers")); + layers_box->GetStaticBox()->SetToolTip(_L("Configure per-layer import options.")); + m_layers_panel = new wxScrolledWindow(layers_box->GetStaticBox(), wxID_ANY, wxDefaultPosition, wxSize(480, 220), wxVSCROLL | wxTAB_TRAVERSAL); + m_layers_panel->SetScrollRate(0, 10); + m_layers_panel->SetToolTip(_L("List of SVG layers detected in the file.")); + + auto *layers_grid = new wxFlexGridSizer(5, 5, 8); + layers_grid->AddGrowableCol(1, 1); + constexpr int type_col_width_px = 170; + constexpr int from_to_col_width_px = 90; + wxFont header_font = this->GetFont(); + header_font.MakeBold(); + auto *hdr_import = new wxStaticText(m_layers_panel, wxID_ANY, _L("Import")); + auto *hdr_layer = new wxStaticText(m_layers_panel, wxID_ANY, _L("Layer name")); + auto *hdr_type = new wxStaticText(m_layers_panel, wxID_ANY, _L("Type"), wxDefaultPosition, wxDefaultSize, wxALIGN_CENTRE_HORIZONTAL); + auto *hdr_from_panel = new wxPanel(m_layers_panel, wxID_ANY); + auto *hdr_from_sizer = new wxBoxSizer(wxHORIZONTAL); + auto *hdr_from_main = new wxStaticText(hdr_from_panel, wxID_ANY, _L("From")); + auto *hdr_from_unit = new wxStaticText(hdr_from_panel, wxID_ANY, _L(" (mm)")); + hdr_from_sizer->AddStretchSpacer(1); + hdr_from_sizer->Add(hdr_from_main, 0, wxALIGN_CENTER_VERTICAL); + hdr_from_sizer->Add(hdr_from_unit, 0, wxALIGN_CENTER_VERTICAL); + hdr_from_sizer->AddStretchSpacer(1); + hdr_from_panel->SetSizerAndFit(hdr_from_sizer); + auto *hdr_to_panel = new wxPanel(m_layers_panel, wxID_ANY); + auto *hdr_to_sizer = new wxBoxSizer(wxHORIZONTAL); + auto *hdr_to_main = new wxStaticText(hdr_to_panel, wxID_ANY, _L("To")); + auto *hdr_to_unit = new wxStaticText(hdr_to_panel, wxID_ANY, _L(" (mm)")); + hdr_to_sizer->AddStretchSpacer(1); + hdr_to_sizer->Add(hdr_to_main, 0, wxALIGN_CENTER_VERTICAL); + hdr_to_sizer->Add(hdr_to_unit, 0, wxALIGN_CENTER_VERTICAL); + hdr_to_sizer->AddStretchSpacer(1); + hdr_to_panel->SetSizerAndFit(hdr_to_sizer); + hdr_import->SetFont(header_font); + hdr_layer->SetFont(header_font); + hdr_type->SetFont(header_font); + hdr_from_main->SetFont(header_font); + hdr_to_main->SetFont(header_font); + hdr_import->SetToolTip(_L("Enable or disable importing this layer.")); + hdr_layer->SetToolTip(_L("Name of the source SVG layer.")); + hdr_type->SetToolTip(_L("Select how each layer is added to the model.")); + hdr_from_main->SetToolTip(_L("Start height of the extrusion range in millimeters.")); + hdr_from_unit->SetToolTip(_L("Units are millimeters.")); + hdr_to_main->SetToolTip(_L("End height of the extrusion range in millimeters.")); + hdr_to_unit->SetToolTip(_L("Units are millimeters.")); + m_header_type = hdr_type; + m_header_from_main = hdr_from_main; + m_header_from_unit = hdr_from_unit; + m_header_to_main = hdr_to_main; + m_header_to_unit = hdr_to_unit; + m_header_enabled_color = hdr_type->GetForegroundColour(); + m_header_disabled_color = wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT); + hdr_type->SetMinSize(wxSize(type_col_width_px, -1)); + hdr_type->SetMaxSize(wxSize(type_col_width_px, -1)); + hdr_from_panel->SetMinSize(wxSize(from_to_col_width_px, -1)); + hdr_from_panel->SetMaxSize(wxSize(from_to_col_width_px, -1)); + hdr_to_panel->SetMinSize(wxSize(from_to_col_width_px, -1)); + hdr_to_panel->SetMaxSize(wxSize(from_to_col_width_px, -1)); + layers_grid->Add(hdr_import, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + layers_grid->Add(hdr_layer, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT, 25); + layers_grid->Add(hdr_type, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + layers_grid->Add(hdr_from_panel, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + layers_grid->Add(hdr_to_panel, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + for (size_t i = 0; i < m_layer_names.size(); ++i) { + wxCheckBox *checkbox = new wxCheckBox(m_layers_panel, wxID_ANY, ""); + checkbox->SetValue(m_options.selected_layers[i]); + checkbox->SetToolTip(_L("Enable to import this layer.")); + checkbox->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent &) { this->update_layer_editable_controls_state(); }); + m_layer_checks.push_back(checkbox); + layers_grid->Add(checkbox, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_CENTER_HORIZONTAL); + + auto *layer_name_label = new wxStaticText(m_layers_panel, wxID_ANY, from_u8(m_layer_names[i]), wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); + layer_name_label->SetMinSize(wxSize(1, -1)); + layer_name_label->SetToolTip(_L("Layer name read from the SVG file.")); + layers_grid->Add(layer_name_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT | wxEXPAND, 25); + + wxArrayString type_choices; + type_choices.Add(_L("Part")); + type_choices.Add(_L("Negative volume")); + type_choices.Add(_L("Modifier")); + wxChoice *type_choice = new wxChoice(m_layers_panel, wxID_ANY, wxDefaultPosition, wxDefaultSize, type_choices); + type_choice->SetMinSize(wxSize(type_col_width_px, -1)); + type_choice->SetMaxSize(wxSize(type_col_width_px, -1)); + int sel = 0; + if (m_options.layer_types[i] == ModelVolumeType::NEGATIVE_VOLUME) + sel = 1; + else if (m_options.layer_types[i] == ModelVolumeType::PARAMETER_MODIFIER) + sel = 2; + type_choice->SetSelection(sel); + type_choice->SetToolTip(_L("Choose the volume type for this layer.")); + m_layer_type_choices.push_back(type_choice); + layers_grid->Add(type_choice, 0, wxALIGN_CENTER_VERTICAL); + + wxTextCtrl *from_ctrl = new wxTextCtrl(m_layers_panel, wxID_ANY, double_to_string(m_options.layer_from_mm[i])); + wxTextCtrl *to_ctrl = new wxTextCtrl(m_layers_panel, wxID_ANY, double_to_string(m_options.layer_to_mm[i])); + from_ctrl->SetMinSize(wxSize(from_to_col_width_px, -1)); + from_ctrl->SetMaxSize(wxSize(from_to_col_width_px, -1)); + to_ctrl->SetMinSize(wxSize(from_to_col_width_px, -1)); + to_ctrl->SetMaxSize(wxSize(from_to_col_width_px, -1)); + from_ctrl->SetToolTip(_L("Extrusion start height in millimeters.")); + to_ctrl->SetToolTip(_L("Extrusion end height in millimeters (must be greater than From).")); + m_layer_from_inputs.push_back(from_ctrl); + m_layer_to_inputs.push_back(to_ctrl); + layers_grid->Add(from_ctrl, 0, wxALIGN_CENTER_VERTICAL); + layers_grid->Add(to_ctrl, 0, wxALIGN_CENTER_VERTICAL); + } + m_layers_panel->SetSizer(layers_grid); + layers_box->Add(m_layers_panel, 1, wxEXPAND | wxALL, 5); + main_sizer->Add(layers_box, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 5); + + wxStdDialogButtonSizer *buttons = new wxStdDialogButtonSizer(); + auto *ok_btn = new wxButton(this, wxID_OK); + auto *cancel_btn = new wxButton(this, wxID_CANCEL); + ok_btn->SetToolTip(_L("Apply the selected SVG import options.")); + cancel_btn->SetToolTip(_L("Cancel SVG import.")); + buttons->AddButton(ok_btn); + buttons->AddButton(cancel_btn); + buttons->Realize(); + main_sizer->Add(buttons, 0, wxEXPAND | wxALL, 8); + + this->SetSizerAndFit(main_sizer); + wxSize initial_size = this->GetSize(); + initial_size.x += 120; + this->SetSize(initial_size); + this->SetMinSize(initial_size); + + m_rb_mode_merged->SetValue(m_options.import_mode == SvgImportMode::Merged); + m_rb_mode_layers->SetValue(m_options.import_mode == SvgImportMode::LayersAsParts); + auto update_columns_state = [this](wxCommandEvent &) { this->update_layer_editable_controls_state(); }; + m_rb_mode_merged->Bind(wxEVT_RADIOBUTTON, update_columns_state); + m_rb_mode_layers->Bind(wxEVT_RADIOBUTTON, update_columns_state); + wxGetApp().UpdateDlgDarkUI(this); + update_layer_editable_controls_state(); + } + + bool transfer_from_controls() + { + m_options.import_mode = m_rb_mode_layers->GetValue() ? SvgImportMode::LayersAsParts : SvgImportMode::Merged; + + for (size_t i = 0; i < m_layer_checks.size(); ++i) + m_options.selected_layers[i] = m_layer_checks[i]->GetValue(); + for (size_t i = 0; i < m_layer_type_choices.size(); ++i) { + const int sel = m_layer_type_choices[i]->GetSelection(); + m_options.layer_types[i] = + sel == 1 ? ModelVolumeType::NEGATIVE_VOLUME : + sel == 2 ? ModelVolumeType::PARAMETER_MODIFIER : + ModelVolumeType::MODEL_PART; + } + for (size_t i = 0; i < m_layer_from_inputs.size(); ++i) { + double from = 0.0; + double to = 0.0; + if (!m_layer_from_inputs[i]->GetValue().ToDouble(&from) || !m_layer_to_inputs[i]->GetValue().ToDouble(&to)) { + show_error(this, _L("From/To must be valid numeric values.")); + return false; + } + if (to <= from) { + show_error(this, _L("Each layer must satisfy: To > From.")); + return false; + } + m_options.layer_from_mm[i] = from; + m_options.layer_to_mm[i] = to; + } + + const bool has_any_selected = std::any_of(m_options.selected_layers.begin(), m_options.selected_layers.end(), [](bool v) { return v; }); + if (!has_any_selected) { + show_error(this, _L("At least one SVG layer must be selected.")); + return false; + } + return true; + } + +private: + void update_layer_editable_controls_state() + { + const bool enable_layer_specific = m_rb_mode_layers != nullptr && m_rb_mode_layers->GetValue(); + for (size_t i = 0; i < m_layer_type_choices.size(); ++i) { + const bool layer_enabled = enable_layer_specific && i < m_layer_checks.size() && m_layer_checks[i] != nullptr && m_layer_checks[i]->GetValue(); + if (m_layer_type_choices[i] != nullptr) + m_layer_type_choices[i]->Enable(layer_enabled); + if (i < m_layer_from_inputs.size() && m_layer_from_inputs[i] != nullptr) + m_layer_from_inputs[i]->Enable(layer_enabled); + if (i < m_layer_to_inputs.size() && m_layer_to_inputs[i] != nullptr) + m_layer_to_inputs[i]->Enable(layer_enabled); + } + + const wxColour &header_color = enable_layer_specific ? m_header_enabled_color : m_header_disabled_color; + if (m_header_type != nullptr) + m_header_type->SetForegroundColour(header_color); + if (m_header_from_main != nullptr) + m_header_from_main->SetForegroundColour(header_color); + if (m_header_from_unit != nullptr) + m_header_from_unit->SetForegroundColour(header_color); + if (m_header_to_main != nullptr) + m_header_to_main->SetForegroundColour(header_color); + if (m_header_to_unit != nullptr) + m_header_to_unit->SetForegroundColour(header_color); + if (m_layers_panel != nullptr) + m_layers_panel->Refresh(); + } + + std::vector m_layer_names; + std::vector m_layer_default_selected; + SvgImportOptions &m_options; + + wxScrolledWindow *m_layers_panel{nullptr}; + std::vector m_layer_checks; + std::vector m_layer_type_choices; + std::vector m_layer_from_inputs; + std::vector m_layer_to_inputs; + wxStaticText *m_header_type{nullptr}; + wxStaticText *m_header_from_main{nullptr}; + wxStaticText *m_header_from_unit{nullptr}; + wxStaticText *m_header_to_main{nullptr}; + wxStaticText *m_header_to_unit{nullptr}; + wxColour m_header_enabled_color; + wxColour m_header_disabled_color; + wxRadioButton *m_rb_mode_merged{nullptr}; + wxRadioButton *m_rb_mode_layers{nullptr}; +}; + +static SvgLayerInfo build_svg_layer_info(const EmbossShape::SvgFile &svg_file, const ExPolygonsWithIds &shapes_with_ids) +{ + SvgLayerInfo info; + size_t shape_count = 0; + EmbossShape::SvgFile svg_copy = svg_file; + const NSVGimage *image = init_image(svg_copy); + if (image != nullptr) + shape_count = get_shapes_count(*image); + else { + size_t max_shape_index = 0; + for (const ExPolygonsWithId &s : shapes_with_ids) + max_shape_index = std::max(max_shape_index, static_cast(s.id / 2)); + if (!shapes_with_ids.empty()) + shape_count = max_shape_index + 1; + } + + if (shape_count == 0) { + info.names.push_back(format("%1% %2%", _u8L("Layer"), 1)); + info.shape_to_layer.assign(1, size_t(0)); + info.default_selected.assign(1, true); + return info; + } + + if (svg_file.file_data != nullptr && parse_explicit_svg_layers(*svg_file.file_data, shape_count, info)) + return info; + + // Fallback: no explicit layers in source SVG, treat as one logical layer. + if (info.names.empty()) + info.names.push_back(format("%1% %2%", _u8L("Layer"), 1)); + info.shape_to_layer.assign(shape_count, size_t(0)); + info.default_selected.assign(1, true); + return info; +} + +static ExPolygonsWithIds filter_shapes_by_selected_layers(const ExPolygonsWithIds &shape_ids, const SvgImportOptions &options, const SvgLayerInfo &layer_info) +{ + ExPolygonsWithIds filtered; + filtered.reserve(shape_ids.size()); + for (const ExPolygonsWithId &shape : shape_ids) { + const size_t shape_index = static_cast(shape.id / 2); + const size_t layer_index = shape_index < layer_info.shape_to_layer.size() ? layer_info.shape_to_layer[shape_index] : size_t(0); + if (layer_index < options.selected_layers.size() && options.selected_layers[layer_index]) + filtered.push_back(shape); + } + return filtered; +} + +static ExPolygonsWithIds get_shapes_for_layer(const ExPolygonsWithIds &shape_ids, const SvgLayerInfo &layer_info, size_t layer_index) +{ + ExPolygonsWithIds out; + for (const ExPolygonsWithId &shape : shape_ids) { + const size_t shape_index = static_cast(shape.id / 2); + const size_t shape_layer = shape_index < layer_info.shape_to_layer.size() ? layer_info.shape_to_layer[shape_index] : size_t(0); + if (shape_layer == layer_index) + out.push_back(shape); + } + return out; +} + +static ExPolygonsWithIds create_shapes_with_selected_layers(EmbossShape &shape, const SvgImportOptions &options, const SvgLayerInfo &layer_info) +{ + if (!shape.svg_file.has_value()) + return {}; + + NSVGimage *image = init_image(*shape.svg_file); + if (image == nullptr) + return {}; + + std::vector original_flags; + original_flags.reserve(get_shapes_count(*image)); + + size_t shape_index = 0; + for (NSVGshape *shape_ptr = image->shapes; shape_ptr != nullptr; shape_ptr = shape_ptr->next, ++shape_index) { + original_flags.push_back(shape_ptr->flags); + const size_t layer_index = shape_index < layer_info.shape_to_layer.size() ? layer_info.shape_to_layer[shape_index] : size_t(0); + const bool selected = layer_index < options.selected_layers.size() ? options.selected_layers[layer_index] : false; + if (selected) + shape_ptr->flags = static_cast(shape_ptr->flags | NSVG_FLAGS_VISIBLE); + else + shape_ptr->flags = static_cast(shape_ptr->flags & ~NSVG_FLAGS_VISIBLE); + } + + NSVGLineParams params(1e10); + ExPolygonsWithIds out = create_shape_with_ids(*image, params); + + shape_index = 0; + for (NSVGshape *shape_ptr = image->shapes; shape_ptr != nullptr && shape_index < original_flags.size(); shape_ptr = shape_ptr->next, ++shape_index) + shape_ptr->flags = original_flags[shape_index]; + + return out; +} + +static ModelVolumeType get_layer_type(size_t layer_index, const SvgImportOptions &options) +{ + if (layer_index < options.layer_types.size()) + return options.layer_types[layer_index]; + return ModelVolumeType::MODEL_PART; +} + +static std::string get_layer_name(const SvgLayerInfo &layer_info, size_t layer_index) +{ + if (layer_index < layer_info.names.size() && !layer_info.names[layer_index].empty()) + return layer_info.names[layer_index]; + return format("%1% %2%", _u8L("Layer"), layer_index + 1); +} + +static TriangleMesh create_mesh_from_emboss_shape(EmbossShape shape) +{ + ExPolygons union_shape = union_with_delta(shape, Slic3r::Emboss::UNION_DELTA, Slic3r::Emboss::UNION_MAX_ITERATIN); + double scale = shape.scale; + double depth = shape.projection.depth / scale; + auto project_z = std::make_unique(depth); + Transform3d tr{Eigen::Scaling(scale)}; + Slic3r::Emboss::ProjectTransform project(std::move(project_z), tr); + indexed_triangle_set its = Slic3r::Emboss::polygons2model(union_shape, project); + return TriangleMesh(std::move(its)); +} + +static bool process_svg_import_options(wxWindow *parent, const std::string &path, Model &model) +{ + if (model.objects.size() != 1) + return true; + ModelObject *object = model.objects.front(); + if (object == nullptr || object->volumes.size() != 1) + return true; + ModelVolume *volume = object->volumes.front(); + if (volume == nullptr || !volume->emboss_shape.has_value() || !volume->emboss_shape->svg_file.has_value()) + return true; + + EmbossShape &shape = *volume->emboss_shape; + if (shape.shapes_with_ids.empty()) + return true; + + SvgImportOptions options; + SvgLayerInfo layer_info = build_svg_layer_info(*shape.svg_file, shape.shapes_with_ids); + std::vector layer_names = layer_info.names; + if (layer_names.empty()) + layer_names.push_back(format("%1% %2%", _u8L("Layer"), 1)); + if (options.selected_layers.size() != layer_names.size()) + options.selected_layers = (layer_info.default_selected.size() == layer_names.size()) ? layer_info.default_selected : std::vector(layer_names.size(), true); + ensure_default_layer_ranges(options, layer_names.size()); + + for (;;) { + SvgImportOptionsDialog dialog(parent, layer_names, layer_info.default_selected, options); + if (dialog.ShowModal() != wxID_OK) + return false; + if (dialog.transfer_from_controls()) + break; + } + + ExPolygonsWithIds selected_shapes = create_shapes_with_selected_layers(shape, options, layer_info); + if (selected_shapes.empty()) + selected_shapes = filter_shapes_by_selected_layers(shape.shapes_with_ids, options, layer_info); + if (selected_shapes.empty()) { + show_error(parent, format_wxstr(_L("SVG file does NOT contain selected layers to import (%1%)."), from_u8(path))); + return false; + } + + if (options.import_mode == SvgImportMode::Merged) { + const size_t shape_index = static_cast(selected_shapes.front().id / 2); + const size_t layer_index = shape_index < layer_info.shape_to_layer.size() ? layer_info.shape_to_layer[shape_index] : size_t(0); + const auto [from_mm, to_mm] = get_layer_from_to_mm(layer_index, options); + const double depth_mm = to_mm - from_mm; + const ModelVolumeType merged_type = get_layer_type(layer_index, options); + shape.shapes_with_ids = std::move(selected_shapes); + shape.projection.depth = depth_mm; + shape.final_shape = {}; + volume->set_mesh(create_mesh_from_emboss_shape(shape)); + volume->set_type(merged_type); + volume->calculate_convex_hull(); + volume->center_geometry_after_creation(); + Vec3d merged_offset = volume->get_offset(); + merged_offset.z() = from_mm + depth_mm * 0.5; + volume->set_offset(merged_offset); + object->invalidate_bounding_box(); + return true; + } + + std::set layer_order; + for (const ExPolygonsWithId &s : selected_shapes) { + const size_t shape_index = static_cast(s.id / 2); + const size_t layer_index = shape_index < layer_info.shape_to_layer.size() ? layer_info.shape_to_layer[shape_index] : size_t(0); + layer_order.insert(layer_index); + } + if (layer_order.empty()) { + show_error(parent, _L("Selected SVG layers are empty.")); + return false; + } + const size_t initial_layer = *layer_order.begin(); + ExPolygonsWithIds initial_shapes = get_shapes_for_layer(selected_shapes, layer_info, initial_layer); + const ModelVolumeType initial_type = get_layer_type(initial_layer, options); + + EmbossShape base_shape = shape; + base_shape.shapes_with_ids = initial_shapes; + const auto [initial_from_mm, initial_to_mm] = get_layer_from_to_mm(initial_layer, options); + base_shape.projection.depth = initial_to_mm - initial_from_mm; + base_shape.final_shape = {}; + shape = std::move(base_shape); + volume->set_mesh(create_mesh_from_emboss_shape(shape)); + volume->set_type(initial_type); + volume->calculate_convex_hull(); + volume->center_geometry_after_creation(); + Vec3d initial_offset = volume->get_offset(); + initial_offset.z() = initial_from_mm + shape.projection.depth * 0.5; + volume->set_offset(initial_offset); + object->name = fs::path(path).stem().string(); + volume->name = get_layer_name(layer_info, initial_layer); + + for (size_t layer_idx : layer_order) { + if (layer_idx == initial_layer) + continue; + + ExPolygonsWithIds layer_shapes = get_shapes_for_layer(selected_shapes, layer_info, layer_idx); + if (layer_shapes.empty()) + continue; + + EmbossShape layer_shape = shape; + layer_shape.shapes_with_ids = std::move(layer_shapes); + const auto [layer_from_mm, layer_to_mm] = get_layer_from_to_mm(layer_idx, options); + layer_shape.projection.depth = layer_to_mm - layer_from_mm; + layer_shape.final_shape = {}; + TriangleMesh layer_mesh = create_mesh_from_emboss_shape(layer_shape); + + const ModelVolumeType layer_type = get_layer_type(layer_idx, options); + ModelVolume *part = object->add_volume(std::move(layer_mesh), layer_type); + part->name = get_layer_name(layer_info, layer_idx); + part->emboss_shape = std::move(layer_shape); + part->translate(Vec3d(0.0, 0.0, layer_from_mm)); + } + + object->invalidate_bounding_box(); + return true; +} } bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &filenames) @@ -1395,6 +2097,11 @@ std::vector Plater::priv::load_files(const std::vector& input_ continue; } + if (load_model && boost::algorithm::iends_with(path.string(), ".svg")) { + if (!process_svg_import_options(q, path.string(), model)) + continue; + } + if (load_config) { if (!config_loaded.empty()) {