diff --git a/src/libslic3r/MixedFilament.cpp b/src/libslic3r/MixedFilament.cpp index 72a914802f..7ea2f7d067 100644 --- a/src/libslic3r/MixedFilament.cpp +++ b/src/libslic3r/MixedFilament.cpp @@ -211,6 +211,25 @@ static int clamp_int(int v, int lo, int hi) return std::max(lo, std::min(hi, v)); } +static float clamp_surface_offset(float v) +{ + return std::clamp(v, -2.f, 2.f); +} + +static std::string format_surface_offset_token(float value) +{ + std::ostringstream ss; + ss << std::fixed << std::setprecision(4) << clamp_surface_offset(value); + std::string out = ss.str(); + while (!out.empty() && out.back() == '0') + out.pop_back(); + if (!out.empty() && out.back() == '.') + out.pop_back(); + if (out == "-0") + out = "0"; + return out.empty() ? std::string("0") : out; +} + static int safe_ratio_from_height(float h, float unit) { if (unit <= 1e-6f) @@ -341,6 +360,8 @@ static bool parse_row_definition(const std::string &row, std::string &manual_pattern, int &distribution_mode, int &local_z_max_sublayers, + float &component_a_surface_offset, + float &component_b_surface_offset, bool &deleted) { auto trim_copy = [](const std::string &s) { @@ -385,6 +406,22 @@ static bool parse_row_definition(const std::string &row, } }; + auto parse_float_token = [&trim_copy](const std::string &tok, float &out) { + const std::string t = trim_copy(tok); + if (t.empty()) + return false; + try { + size_t consumed = 0; + const float v = std::stof(t, &consumed); + if (consumed != t.size()) + return false; + out = v; + return true; + } catch (...) { + return false; + } + }; + std::vector tokens; std::stringstream ss(row); std::string token; @@ -425,6 +462,8 @@ static bool parse_row_definition(const std::string &row, manual_pattern.clear(); distribution_mode = int(MixedFilament::Simple); local_z_max_sublayers = 0; + component_a_surface_offset = 0.f; + component_b_surface_offset = 0.f; deleted = false; size_t token_idx = 5; @@ -473,6 +512,19 @@ static bool parse_row_definition(const std::string &row, local_z_max_sublayers = std::max(0, parsed_max_sublayers); continue; } + if ((tok[0] == 'x' || tok[0] == 'X') && tok.size() >= 3) { + const char component = char(std::tolower(static_cast(tok[1]))); + if (component == 'a' || component == 'b') { + float parsed_offset = component == 'a' ? component_a_surface_offset : component_b_surface_offset; + if (parse_float_token(tok.substr(2), parsed_offset)) { + if (component == 'a') + component_a_surface_offset = clamp_surface_offset(parsed_offset); + else + component_b_surface_offset = clamp_surface_offset(parsed_offset); + } + continue; + } + } if (tok[0] == 'd' || tok[0] == 'D') { int parsed_deleted = deleted ? 1 : 0; if (parse_int_token(tok.substr(1), parsed_deleted)) @@ -942,6 +994,8 @@ void MixedFilamentManager::add_custom_filament(unsigned int component_a, mf.pointillism_all_filaments = false; mf.distribution_mode = int(MixedFilament::Simple); mf.local_z_max_sublayers = 0; + mf.component_a_surface_offset = 0.f; + mf.component_b_surface_offset = 0.f; mf.enabled = true; mf.deleted = false; mf.custom = true; @@ -1030,6 +1084,8 @@ std::string MixedFilamentManager::serialize_custom_entries() << 'w' << normalized_weights << ',' << 'm' << clamp_int(mf.distribution_mode, int(MixedFilament::LayerCycle), int(MixedFilament::Simple)) << ',' << 'z' << std::max(0, mf.local_z_max_sublayers) << ',' + << "xa" << format_surface_offset_token(mf.component_a_surface_offset) << ',' + << "xb" << format_surface_offset_token(mf.component_b_surface_offset) << ',' << 'd' << (mf.deleted ? 1 : 0) << ',' << 'o' << (mf.origin_auto ? 1 : 0) << ',' << 'u' << mf.stable_id; @@ -1101,10 +1157,12 @@ void MixedFilamentManager::load_custom_entries(const std::string &serialized, co std::string manual_pattern; int distribution_mode = int(MixedFilament::Simple); int local_z_max_sublayers = 0; + float component_a_surface_offset = 0.f; + float component_b_surface_offset = 0.f; bool deleted = false; if (!parse_row_definition(row, a, b, stable_id, enabled, custom, origin_auto, mix, pointillism_all_filaments, gradient_component_ids, gradient_component_weights, manual_pattern, distribution_mode, - local_z_max_sublayers, deleted)) { + local_z_max_sublayers, component_a_surface_offset, component_b_surface_offset, deleted)) { ++skipped_rows; BOOST_LOG_TRIVIAL(warning) << "MixedFilamentManager::load_custom_entries invalid row format: " << row; continue; @@ -1152,6 +1210,8 @@ void MixedFilamentManager::load_custom_entries(const std::string &serialized, co mf.manual_pattern = normalize_manual_pattern(manual_pattern); mf.distribution_mode = clamp_int(distribution_mode, int(MixedFilament::LayerCycle), int(MixedFilament::Simple)); mf.local_z_max_sublayers = std::max(0, local_z_max_sublayers); + mf.component_a_surface_offset = clamp_surface_offset(component_a_surface_offset); + mf.component_b_surface_offset = clamp_surface_offset(component_b_surface_offset); mf.mix_b_percent = mf.manual_pattern.empty() ? mix : mix_percent_from_normalized_pattern(mf.manual_pattern); mf.deleted = deleted; if (mf.deleted) @@ -1179,6 +1239,8 @@ void MixedFilamentManager::load_custom_entries(const std::string &serialized, co mf.manual_pattern = normalize_manual_pattern(manual_pattern); mf.distribution_mode = clamp_int(distribution_mode, int(MixedFilament::LayerCycle), int(MixedFilament::Simple)); mf.local_z_max_sublayers = std::max(0, local_z_max_sublayers); + mf.component_a_surface_offset = clamp_surface_offset(component_a_surface_offset); + mf.component_b_surface_offset = clamp_surface_offset(component_b_surface_offset); if (!mf.manual_pattern.empty()) mf.mix_b_percent = mix_percent_from_normalized_pattern(mf.manual_pattern); mf.enabled = enabled; @@ -1358,6 +1420,37 @@ unsigned int MixedFilamentManager::effective_painted_region_filament_id(unsigned return resolve(filament_id, num_physical, layer_index, layer_print_z, layer_height); } +float MixedFilamentManager::component_surface_offset(unsigned int filament_id, + size_t num_physical, + int layer_index, + float layer_print_z, + float layer_height, + bool force_height_weighted) const +{ + const MixedFilament *mixed_row = mixed_filament_from_id(filament_id, num_physical); + if (mixed_row == nullptr) + return 0.f; + + if (mixed_row->distribution_mode == int(MixedFilament::SameLayerPointillisme)) + return 0.f; + + const std::string normalized_pattern = normalize_manual_pattern(mixed_row->manual_pattern); + if (normalized_pattern.find(',') != std::string::npos) + return 0.f; + + const unsigned int resolved = resolve(filament_id, + num_physical, + layer_index, + layer_print_z, + layer_height, + force_height_weighted); + if (resolved == mixed_row->component_a) + return clamp_surface_offset(mixed_row->component_a_surface_offset); + if (resolved == mixed_row->component_b) + return clamp_surface_offset(mixed_row->component_b_surface_offset); + return 0.f; +} + std::vector MixedFilamentManager::ordered_perimeter_extruders(unsigned int filament_id, size_t num_physical, int layer_index, @@ -1498,6 +1591,17 @@ std::string MixedFilamentManager::blend_color(const std::string &color_a, return rgb_to_hex({int(out_r), int(out_g), int(out_b)}); } +int MixedFilamentManager::apparent_mix_b_percent(int mix_b_percent, + float component_a_surface_offset, + float component_b_surface_offset, + float reference_width_mm) +{ + const float safe_reference = std::max(0.05f, std::abs(reference_width_mm)); + const float shift_pct = 100.f * (clamp_surface_offset(component_a_surface_offset) - + clamp_surface_offset(component_b_surface_offset)) / safe_reference; + return clamp_int(int(std::lround(float(clamp_int(mix_b_percent, 0, 100)) + shift_pct)), 0, 100); +} + void MixedFilamentManager::refresh_display_colors(const std::vector &filament_colours) { for (MixedFilament &mf : m_mixed) { @@ -1535,8 +1639,14 @@ void MixedFilamentManager::refresh_display_colors(const std::vector mf.display_color = "#26A69A"; continue; } - const int ratio_a = std::max(0, 100 - clamp_int(mf.mix_b_percent, 0, 100)); - const int ratio_b = clamp_int(mf.mix_b_percent, 0, 100); + const std::string normalized_pattern = normalize_manual_pattern(mf.manual_pattern); + const int ratio_b = + (mf.distribution_mode != int(MixedFilament::SameLayerPointillisme) && + normalized_pattern.empty() && + gradient_ids.size() < 3) ? + apparent_mix_b_percent(mf.mix_b_percent, mf.component_a_surface_offset, mf.component_b_surface_offset) : + clamp_int(mf.mix_b_percent, 0, 100); + const int ratio_a = std::max(0, 100 - ratio_b); mf.display_color = blend_color( filament_colours[mf.component_a - 1], filament_colours[mf.component_b - 1], diff --git a/src/libslic3r/MixedFilament.hpp b/src/libslic3r/MixedFilament.hpp index 8421beab7d..a62c3492a4 100644 --- a/src/libslic3r/MixedFilament.hpp +++ b/src/libslic3r/MixedFilament.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -63,6 +64,12 @@ struct MixedFilament // Optional Local-Z cap for this mixed row. 0 disables the cap. int local_z_max_sublayers = 0; + // Additional XY surface offsets, in mm, applied when this mixed row + // resolves to component A or B for an entire layer. Positive values + // contract inward; negative values expand outward. + float component_a_surface_offset = 0.f; + float component_b_surface_offset = 0.f; + // Whether this mixed filament is enabled (available for assignment). bool enabled = true; @@ -82,6 +89,7 @@ struct MixedFilament bool operator==(const MixedFilament &rhs) const { + constexpr float k_surface_offset_epsilon = 1e-6f; return component_a == rhs.component_a && component_b == rhs.component_b && stable_id == rhs.stable_id && @@ -94,6 +102,8 @@ struct MixedFilament pointillism_all_filaments == rhs.pointillism_all_filaments && distribution_mode == rhs.distribution_mode && local_z_max_sublayers == rhs.local_z_max_sublayers && + std::abs(component_a_surface_offset - rhs.component_a_surface_offset) <= k_surface_offset_epsilon && + std::abs(component_b_surface_offset - rhs.component_b_surface_offset) <= k_surface_offset_epsilon && enabled == rhs.enabled && deleted == rhs.deleted && custom == rhs.custom && @@ -191,6 +201,12 @@ public: float layer_height_a = 0.f, float layer_height_b = 0.f, float base_layer_height = 0.2f) const; + float component_surface_offset(unsigned int filament_id, + size_t num_physical, + int layer_index, + float layer_print_z = 0.f, + float layer_height = 0.f, + bool force_height_weighted = false) const; std::vector ordered_perimeter_extruders(unsigned int filament_id, size_t num_physical, int layer_index, @@ -213,6 +229,10 @@ public: static std::string blend_color(const std::string &color_a, const std::string &color_b, int ratio_a, int ratio_b); + static int apparent_mix_b_percent(int mix_b_percent, + float component_a_surface_offset, + float component_b_surface_offset, + float reference_width_mm = 0.4f); // ---- Accessors ------------------------------------------------------ diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index 447ee1582d..4571461fce 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -1031,6 +1031,125 @@ static bool apply_mixed_surface_indentation(PrintObject &print_object, std::vect return true; } +static bool apply_mixed_component_surface_offsets(PrintObject &print_object, std::vector> &segmentation) +{ + const Print *print = print_object.print(); + if (print == nullptr || segmentation.empty()) + return false; + + const PrintConfig &print_cfg = print->config(); + const DynamicPrintConfig &full_cfg = print->full_print_config(); + if (bool_from_full_config(full_cfg, "dithering_local_z_mode", print_cfg.dithering_local_z_mode.value)) + return false; + + const size_t num_physical = print_cfg.filament_colour.size(); + const size_t num_channels = segmentation.front().size(); + if (num_channels <= num_physical + 1) + return false; + + const MixedFilamentManager &mixed_mgr = print->mixed_filament_manager(); + bool has_component_offsets = false; + for (const MixedFilament &mf : mixed_mgr.mixed_filaments()) { + if (!mf.enabled || mf.deleted) + continue; + if (std::abs(mf.component_a_surface_offset) > EPSILON || std::abs(mf.component_b_surface_offset) > EPSILON) { + has_component_offsets = true; + break; + } + } + if (!has_component_offsets) + return false; + + size_t changed_layers = 0; + size_t changed_states = 0; + size_t emptied_states = 0; + size_t expanded_states = 0; + size_t contracted_states = 0; + size_t overlap_clipped_states = 0; + + for (size_t layer_id = 0; layer_id < segmentation.size(); ++layer_id) { + if (segmentation[layer_id].size() != num_channels) + continue; + + const Layer *layer = layer_id < size_t(print_object.layer_count()) ? print_object.get_layer(int(layer_id)) : nullptr; + const float layer_print_z = layer ? float(layer->print_z) : 0.f; + const float layer_height = layer ? float(layer->height) : 0.f; + bool layer_changed = false; + + for (size_t channel_idx = 1; channel_idx < num_channels; ++channel_idx) { + ExPolygons &state_masks = segmentation[layer_id][channel_idx]; + if (state_masks.empty()) + continue; + + const unsigned int state_id = segmentation_channel_filament_id(channel_idx); + if (!mixed_mgr.is_mixed(state_id, num_physical)) + continue; + + const coordf_t offset_mm = coordf_t(mixed_mgr.component_surface_offset(state_id, + num_physical, + int(layer_id), + layer_print_z, + layer_height)); + if (std::abs(offset_mm) <= EPSILON) + continue; + + const float delta_scaled = float(scale_(std::abs(double(offset_mm)))); + if (delta_scaled <= float(EPSILON)) + continue; + + ExPolygons adjusted = offset_mm > 0 ? offset_ex(state_masks, -delta_scaled) : offset_ex(state_masks, delta_scaled); + if (!adjusted.empty() && adjusted.size() > 1) + adjusted = union_ex(adjusted); + + if (offset_mm < 0 && !adjusted.empty()) { + ExPolygons occupied_other; + for (size_t other_idx = 0; other_idx < num_channels; ++other_idx) { + if (other_idx == channel_idx) + continue; + if (!segmentation[layer_id][other_idx].empty()) + append(occupied_other, segmentation[layer_id][other_idx]); + } + if (occupied_other.size() > 1) + occupied_other = union_ex(occupied_other); + if (!occupied_other.empty()) { + ExPolygons clipped = diff_ex(adjusted, occupied_other, ApplySafetyOffset::Yes); + if (std::abs(area(clipped)) + EPSILON < std::abs(area(adjusted))) + ++overlap_clipped_states; + adjusted = std::move(clipped); + if (!adjusted.empty() && adjusted.size() > 1) + adjusted = union_ex(adjusted); + } + } + + state_masks = std::move(adjusted); + if (state_masks.empty()) + ++emptied_states; + if (offset_mm < 0) + ++expanded_states; + else + ++contracted_states; + ++changed_states; + layer_changed = true; + } + + if (layer_changed) + ++changed_layers; + } + + if (changed_states == 0) + return false; + + BOOST_LOG_TRIVIAL(warning) << "Mixed component surface offsets applied" + << " object=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << " changed_layers=" << changed_layers + << " changed_states=" << changed_states + << " contracted_states=" << contracted_states + << " expanded_states=" << expanded_states + << " emptied_states=" << emptied_states + << " overlap_clipped_states=" << overlap_clipped_states; + return true; +} + static bool fit_pass_heights_to_interval(std::vector &passes, double base_height, double lo, double hi) { if (passes.empty() || base_height <= EPSILON) @@ -2182,6 +2301,134 @@ static ExPolygons collect_layer_region_slices(const Layer &layer) return out; } +static bool apply_mixed_region_surface_offsets(PrintObject &print_object) +{ + const Print *print = print_object.print(); + if (print == nullptr || print_object.layer_count() == 0) + return false; + + const PrintConfig &print_cfg = print->config(); + const DynamicPrintConfig &full_cfg = print->full_print_config(); + if (bool_from_full_config(full_cfg, "dithering_local_z_mode", print_cfg.dithering_local_z_mode.value)) + return false; + + const size_t num_physical = print_cfg.filament_diameter.size(); + if (num_physical == 0) + return false; + + const MixedFilamentManager &mixed_mgr = print->mixed_filament_manager(); + bool has_component_offsets = false; + for (const MixedFilament &mf : mixed_mgr.mixed_filaments()) { + if (!mf.enabled || mf.deleted) + continue; + if (std::abs(mf.component_a_surface_offset) > EPSILON || std::abs(mf.component_b_surface_offset) > EPSILON) { + has_component_offsets = true; + break; + } + } + if (!has_component_offsets) + return false; + + size_t changed_layers = 0; + size_t changed_regions = 0; + size_t contracted_regions = 0; + size_t expanded_regions = 0; + size_t stolen_regions = 0; + + struct PendingRegionOffset { + int region_id { -1 }; + coordf_t offset_mm { 0.f }; + ExPolygons adjusted; + }; + + for (size_t layer_id = 0; layer_id < print_object.layer_count(); ++layer_id) { + Layer &layer = *print_object.get_layer(int(layer_id)); + std::vector pending; + pending.reserve(size_t(layer.region_count())); + + for (int region_id = 0; region_id < layer.region_count(); ++region_id) { + LayerRegion *layerm = layer.get_region(region_id); + if (layerm == nullptr || layerm->slices.empty()) + continue; + + const unsigned int filament_id = unsigned(std::max(0, layerm->region().config().wall_filament.value)); + if (!mixed_mgr.is_mixed(filament_id, num_physical)) + continue; + + const coordf_t offset_mm = coordf_t(mixed_mgr.component_surface_offset(filament_id, + num_physical, + int(layer_id), + float(layer.print_z), + float(layer.height))); + if (std::abs(offset_mm) <= EPSILON) + continue; + + const float delta_scaled = float(scale_(std::abs(double(offset_mm)))); + if (delta_scaled <= float(EPSILON)) + continue; + + ExPolygons adjusted = offset_ex(to_expolygons(layerm->slices.surfaces), offset_mm > 0 ? -delta_scaled : delta_scaled); + if (!adjusted.empty() && adjusted.size() > 1) + adjusted = union_ex(adjusted); + + pending.push_back({ region_id, offset_mm, std::move(adjusted) }); + } + + if (pending.empty()) + continue; + + bool layer_changed = false; + for (const PendingRegionOffset &entry : pending) { + LayerRegion *layerm = layer.get_region(entry.region_id); + if (layerm == nullptr) + continue; + + if (entry.offset_mm < 0 && !entry.adjusted.empty()) { + for (int other_region_id = 0; other_region_id < layer.region_count(); ++other_region_id) { + if (other_region_id == entry.region_id) + continue; + + LayerRegion *other = layer.get_region(other_region_id); + if (other == nullptr || other->slices.empty()) + continue; + + ExPolygons stolen = intersection_ex(other->slices.surfaces, entry.adjusted); + if (stolen.empty()) + continue; + + Polygons remaining = diff(to_polygons(other->slices.surfaces), entry.adjusted); + other->slices.set(union_ex(remaining), stInternal); + ++stolen_regions; + layer_changed = true; + } + } + + layerm->slices.set(entry.adjusted, stInternal); + ++changed_regions; + if (entry.offset_mm > 0) + ++contracted_regions; + else + ++expanded_regions; + layer_changed = true; + } + + if (layer_changed) + ++changed_layers; + } + + if (changed_regions == 0) + return false; + + BOOST_LOG_TRIVIAL(warning) << "Mixed region surface offsets applied" + << " object=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << " changed_layers=" << changed_layers + << " changed_regions=" << changed_regions + << " contracted_regions=" << contracted_regions + << " expanded_regions=" << expanded_regions + << " stolen_regions=" << stolen_regions; + return true; +} + static void export_local_z_plan_debug(const PrintObject &print_object, coordf_t lower_bound, coordf_t upper_bound) { const std::vector &intervals = print_object.local_z_intervals(); @@ -3935,6 +4182,7 @@ void PrintObject::slice_volumes() BOOST_LOG_TRIVIAL(debug) << "Slicing volumes - MMU segmentation"; std::vector> mm_segmentation = multi_material_segmentation_by_painting(*this, [print]() { print->throw_if_canceled(); }); apply_mixed_surface_indentation(*this, mm_segmentation); + apply_mixed_component_surface_offsets(*this, mm_segmentation); // Same-layer pointillisme is applied in G-code path domain (segment-level assignment), // not by XY state mask splitting, to avoid boolean-induced voids. BOOST_LOG_TRIVIAL(info) << "Same-layer pointillisme uses path-domain G-code segmentation"; @@ -3942,6 +4190,8 @@ void PrintObject::slice_volumes() apply_mm_segmentation(*this, std::move(mm_segmentation), [print]() { print->throw_if_canceled(); }); } + apply_mixed_region_surface_offsets(*this); + // Is any ModelVolume fuzzy skin painted? if (this->model_object()->is_fuzzy_skin_painted()) { // If XY Size compensation is also enabled, notify the user that XY Size compensation diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 47920c348e..e4b9c8461b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -4580,6 +4580,7 @@ public: const MixedFilament &mf, size_t num_physical, const std::vector &physical_colors, + const std::vector &nozzle_diameters, const std::vector &palette, const MixedFilamentPreviewSettings &preview_settings, OnChangeFn on_change = {}); @@ -4600,6 +4601,7 @@ private: MixedFilament m_mf; size_t m_num_physical; std::vector m_physical_colors; + std::vector m_nozzle_diameters; std::vector m_palette; MixedFilamentPreviewSettings m_preview_settings; bool m_has_changes = false; @@ -4620,11 +4622,14 @@ private: wxStaticText *m_picker_b_label = nullptr; wxStaticText *m_picker_c_label = nullptr; wxStaticText *m_picker_d_label = nullptr; + wxPanel *m_surface_offset_target_swatch = nullptr; + wxChoice *m_surface_offset_target_choice = nullptr; MixedGradientSelector *m_blend_selector = nullptr; wxStaticText *m_blend_label = nullptr; wxTextCtrl *m_pattern_ctrl = nullptr; wxCheckBox *m_local_z_limit_checkbox = nullptr; wxSpinCtrl *m_local_z_limit_spin = nullptr; + wxSpinCtrlDouble *m_surface_offset_spin = nullptr; std::vector m_pattern_quick_buttons; MixedMixPreview *m_mix_preview = nullptr; wxStaticText *m_breakdown_label = nullptr; @@ -4675,6 +4680,7 @@ public: void set_data(const std::vector &palette, const std::vector &sequence, bool same_layer_mode, + const std::vector &surface_offsets_mm, const wxColour &fallback, const wxString &left_overlay, const wxString &right_overlay) @@ -4682,6 +4688,7 @@ public: m_palette = palette; m_sequence = sequence; m_same_layer = same_layer_mode; + m_surface_offsets_mm = surface_offsets_mm; m_fallback = fallback; m_left_overlay = left_overlay; m_right_overlay = right_overlay; @@ -4704,6 +4711,19 @@ private: return m_fallback; } + int vertical_inset_for_extruder(unsigned int extruder_id, int rect_height) const + { + if (extruder_id == 0 || extruder_id >= m_surface_offsets_mm.size() || rect_height <= 2) + return 0; + + const double offset_mm = m_surface_offsets_mm[extruder_id]; + if (offset_mm <= 0.0) + return 0; + + const double normalized = std::clamp(offset_mm / 2.0, 0.0, 1.0); + return std::clamp(int(std::round(normalized * rect_height * 0.30)), 0, std::max(0, rect_height / 3)); + } + void on_paint(wxPaintEvent &) { wxAutoBufferedPaintDC dc(this); @@ -4723,10 +4743,12 @@ private: const size_t seq_len = m_sequence.size(); for (int s = 0; s < stripes; ++s) { const size_t idx = size_t(s % int(seq_len)); + const unsigned int extruder_id = m_sequence[idx]; dc.SetBrush(wxBrush(color_for_extruder(m_sequence[idx]))); const int x = rect.GetLeft() + s * stripe_w; const int w = (s == stripes - 1) ? (rect.GetRight() - x + 1) : stripe_w; - dc.DrawRectangle(x, rect.GetTop(), std::max(1, w), rect.GetHeight()); + const int inset_y = vertical_inset_for_extruder(extruder_id, rect.GetHeight()); + dc.DrawRectangle(x, rect.GetTop() + inset_y, std::max(1, w), std::max(1, rect.GetHeight() - inset_y * 2)); } } else { const int bars = 24; @@ -4741,7 +4763,8 @@ private: dc.SetBrush(wxBrush(color_for_extruder(extruder_id))); const int x = rect.GetLeft() + i * bar_w; const int w = (i == bars - 1) ? (rect.GetRight() - x + 1) : bar_w; - dc.DrawRectangle(x, rect.GetTop(), std::max(1, w), rect.GetHeight()); + const int inset_y = vertical_inset_for_extruder(extruder_id, rect.GetHeight()); + dc.DrawRectangle(x, rect.GetTop() + inset_y, std::max(1, w), std::max(1, rect.GetHeight() - inset_y * 2)); } } } @@ -4782,6 +4805,7 @@ private: private: std::vector m_palette; std::vector m_sequence; + std::vector m_surface_offsets_mm; bool m_same_layer { false }; wxColour m_fallback { wxColour(38, 166, 154) }; wxString m_left_overlay; @@ -5557,6 +5581,98 @@ int MixedFilamentConfigPanel::effective_local_z_preview_mix_b_percent(const Mixe return std::clamp(int(std::lround(100.0 * total_b / total)), 0, 100); } +static bool mixed_filament_supports_bias_apparent_color(const MixedFilament &mf, + const MixedFilamentPreviewSettings &preview_settings) +{ + if (preview_settings.local_z_mode) + return false; + if (mf.distribution_mode == int(MixedFilament::SameLayerPointillisme)) + return false; + if (!MixedFilamentManager::normalize_manual_pattern(mf.manual_pattern).empty()) + return false; + if (mf.gradient_component_ids.size() >= 3) + return false; + return mf.component_a >= 1 && mf.component_b >= 1 && mf.component_a != mf.component_b; +} + +static std::pair mixed_filament_single_surface_offset_ui_state(const MixedFilament &mf) +{ + const float offset_a = mf.component_a_surface_offset; + const float offset_b = mf.component_b_surface_offset; + const bool has_a = std::abs(offset_a) > 1e-6f; + const bool has_b = std::abs(offset_b) > 1e-6f; + + if (has_b && (!has_a || std::abs(offset_b) > std::abs(offset_a))) + return { 1, offset_b }; + return { 0, has_a ? offset_a : 0.f }; +} + +static std::pair mixed_filament_single_surface_offset_pair(int target_index, float value) +{ + const float clamped = std::clamp(value, -2.f, 2.f); + return target_index == 1 ? std::make_pair(0.f, clamped) : std::make_pair(clamped, 0.f); +} + +static double mixed_filament_reference_nozzle_mm(unsigned int component_a, + unsigned int component_b, + const std::vector &nozzle_diameters) +{ + std::vector samples; + samples.reserve(2); + + auto append_if_valid = [&samples, &nozzle_diameters](unsigned int component_id) { + if (component_id >= 1 && component_id <= nozzle_diameters.size()) + samples.emplace_back(std::max(0.05, nozzle_diameters[size_t(component_id - 1)])); + }; + + append_if_valid(component_a); + append_if_valid(component_b); + + if (samples.empty()) + return 0.4; + return std::accumulate(samples.begin(), samples.end(), 0.0) / double(samples.size()); +} + +static std::pair mixed_filament_apparent_pair_percentages(const MixedFilament &mf, + const MixedFilamentPreviewSettings &preview_settings, + const std::vector &nozzle_diameters) +{ + const int base_b = MixedFilamentConfigPanel::effective_local_z_preview_mix_b_percent(mf, preview_settings); + if (!mixed_filament_supports_bias_apparent_color(mf, preview_settings)) + return { 100 - base_b, base_b }; + + const double reference_nozzle_mm = mixed_filament_reference_nozzle_mm(mf.component_a, mf.component_b, nozzle_diameters); + const int apparent_b = MixedFilamentManager::apparent_mix_b_percent(base_b, + mf.component_a_surface_offset, + mf.component_b_surface_offset, + float(reference_nozzle_mm)); + return { 100 - apparent_b, apparent_b }; +} + +static std::string mixed_filament_apparent_pair_summary(const MixedFilament &mf, + const MixedFilamentPreviewSettings &preview_settings, + const std::vector &nozzle_diameters) +{ + if (!mixed_filament_supports_bias_apparent_color(mf, preview_settings)) + return {}; + + const int base_b = MixedFilamentConfigPanel::effective_local_z_preview_mix_b_percent(mf, preview_settings); + const int base_a = 100 - base_b; + const auto [apparent_a, apparent_b] = + mixed_filament_apparent_pair_percentages(mf, preview_settings, nozzle_diameters); + + if (std::abs(mf.component_a_surface_offset - mf.component_b_surface_offset) > 1e-4f && + (apparent_a != base_a || apparent_b != base_b)) { + std::ostringstream ss; + ss << '~' << apparent_a << '/' << apparent_b; + return ss.str(); + } + + std::ostringstream ss; + ss << apparent_a << "%/" << apparent_b << '%'; + return ss.str(); +} + std::string MixedFilamentConfigPanel::summarize_sequence(const std::vector &seq) { if (seq.empty()) return ""; @@ -5764,6 +5880,7 @@ MixedFilamentConfigPanel::MixedFilamentConfigPanel(wxWindow *parent, const MixedFilament &mf, size_t num_physical, const std::vector &physical_colors, + const std::vector &nozzle_diameters, const std::vector &palette, const MixedFilamentPreviewSettings &preview_settings, OnChangeFn on_change) @@ -5772,6 +5889,7 @@ MixedFilamentConfigPanel::MixedFilamentConfigPanel(wxWindow *parent, , m_mf(mf) , m_num_physical(num_physical) , m_physical_colors(physical_colors) + , m_nozzle_diameters(nozzle_diameters) , m_palette(palette) , m_preview_settings(preview_settings) , m_selected_weight_state(std::make_shared>()) @@ -5969,9 +6087,62 @@ void MixedFilamentConfigPanel::build_ui() auto *preview_row = new wxBoxSizer(wxHORIZONTAL); m_mix_preview = new MixedMixPreview(this); m_mix_preview->SetBackgroundColour(panel_bg); - preview_row->Add(m_mix_preview, 1, wxEXPAND | wxALIGN_CENTER_VERTICAL); + preview_row->Add(m_mix_preview, 3, wxEXPAND | wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); + + auto *bias_controls = new wxBoxSizer(wxHORIZONTAL); + const auto initial_surface_offset_state = mixed_filament_single_surface_offset_ui_state(m_mf); + + auto *surface_offset_label = new wxStaticText(this, wxID_ANY, _L("XY-Bias")); + surface_offset_label->SetForegroundColour(is_dark ? wxColour(236, 236, 236) : wxColour(20, 20, 20)); + surface_offset_label->SetToolTip( + _L("Additional XY offset applied only on layers where this mixed filament resolves to component A or B.\n\n" + "Positive values contract inward. Negative values expand outward.\n\n" + "Currently this is intended for ordinary layer/height cadence rows. Grouped wall patterns, same-layer pointillisme, and Local-Z dithering ignore it.")); + bias_controls->Add(surface_offset_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, std::max(FromDIP(4), gap / 2)); + + auto *target_group = new wxBoxSizer(wxHORIZONTAL); + m_surface_offset_target_swatch = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(10), FromDIP(10)), wxBORDER_SIMPLE); + m_surface_offset_target_swatch->SetMinSize(wxSize(FromDIP(10), FromDIP(10))); + m_surface_offset_target_swatch->SetMaxSize(wxSize(FromDIP(10), FromDIP(10))); + m_surface_offset_target_swatch->SetToolTip(_L("Choose which filament in this pair receives the XY bias.")); + target_group->Add(m_surface_offset_target_swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(3)); + + wxArrayString surface_offset_targets; + surface_offset_targets.Add(wxString::Format("F%d", int(m_mf.component_a))); + surface_offset_targets.Add(wxString::Format("F%d", int(m_mf.component_b))); + m_surface_offset_target_choice = new wxChoice(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(62), -1), surface_offset_targets); + m_surface_offset_target_choice->SetSelection(std::clamp(initial_surface_offset_state.first, 0, 1)); + m_surface_offset_target_choice->SetToolTip(_L("Choose which filament in this pair receives the XY bias.")); + target_group->Add(m_surface_offset_target_choice, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + bias_controls->Add(target_group, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + + m_surface_offset_spin = new wxSpinCtrlDouble(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(64), -1), + wxSP_ARROW_KEYS | wxALIGN_RIGHT | wxTE_PROCESS_ENTER, + -2.0, 2.0, std::clamp(double(initial_surface_offset_state.second), -2.0, 2.0), 0.01); + m_surface_offset_spin->SetDigits(3); + m_surface_offset_spin->SetToolTip( + _L("XY bias for the selected filament on layers where this row resolves to it. Positive values contract inward. Negative values expand outward.")); + bias_controls->Add(m_surface_offset_spin, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + + auto *surface_offset_units = new wxStaticText(this, wxID_ANY, _L("mm")); + surface_offset_units->SetForegroundColour(is_dark ? wxColour(210, 210, 210) : wxColour(72, 72, 72)); + bias_controls->Add(surface_offset_units, 0, wxALIGN_CENTER_VERTICAL); + preview_row->Add(bias_controls, 1, wxEXPAND | wxALIGN_CENTER_VERTICAL); root->Add(preview_row, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, gap); + const auto initial_surface_offset_pair = + mixed_filament_single_surface_offset_pair(initial_surface_offset_state.first, initial_surface_offset_state.second); + m_mf.component_a_surface_offset = initial_surface_offset_pair.first; + m_mf.component_b_surface_offset = initial_surface_offset_pair.second; + + const bool initial_component_surface_offsets_supported = !pattern_row_mode && + row_distribution_mode != int(MixedFilament::SameLayerPointillisme) && + !m_preview_settings.local_z_mode; + if (m_surface_offset_target_choice) + m_surface_offset_target_choice->Enable(initial_component_surface_offsets_supported); + if (m_surface_offset_spin) + m_surface_offset_spin->Enable(initial_component_surface_offsets_supported); + const bool local_z_limit_supported = multi_gradient_row && row_distribution_mode != int(MixedFilament::SameLayerPointillisme); if (local_z_limit_supported) { @@ -6023,6 +6194,14 @@ void MixedFilamentConfigPanel::build_ui() const bool preserve_same_layer_mode = m_mf.distribution_mode == int(MixedFilament::SameLayerPointillisme); m_mf.component_a = unsigned(a); m_mf.component_b = unsigned(b); + const int surface_offset_target_index = + m_surface_offset_target_choice ? std::clamp(m_surface_offset_target_choice->GetSelection(), 0, 1) : 0; + const float surface_offset_value = + m_surface_offset_spin ? std::clamp(float(m_surface_offset_spin->GetValue()), -2.f, 2.f) : 0.f; + const auto surface_offset_pair = + mixed_filament_single_surface_offset_pair(surface_offset_target_index, surface_offset_value); + m_mf.component_a_surface_offset = surface_offset_pair.first; + m_mf.component_b_surface_offset = surface_offset_pair.second; m_mf.local_z_max_sublayers = (m_local_z_limit_checkbox != nullptr && m_local_z_limit_checkbox->GetValue() && m_local_z_limit_spin != nullptr) ? std::max(2, m_local_z_limit_spin->GetValue()) : @@ -6111,6 +6290,13 @@ void MixedFilamentConfigPanel::build_ui() m_mf.custom = true; const std::vector selected_gradient_ids = decode_gradient_ids(m_mf.gradient_component_ids); + const bool component_surface_offsets_supported = (m_pattern_ctrl == nullptr) && + !same_layer_mode && + !m_preview_settings.local_z_mode; + if (m_surface_offset_target_choice) + m_surface_offset_target_choice->Enable(component_surface_offsets_supported); + if (m_surface_offset_spin) + m_surface_offset_spin->Enable(component_surface_offsets_supported); if (preview_sequence.empty()) preview_sequence = build_weighted_pair_sequence(m_mf.component_a, m_mf.component_b, preview_mix_b_percent, same_layer_mode); @@ -6125,7 +6311,17 @@ void MixedFilamentConfigPanel::build_ui() m_blend_selector->set_multi_preview(corner_colors, *m_selected_weight_state); } - if (selected_gradient_ids.size() >= 3 || !preview_sequence.empty()) { + if (mixed_filament_supports_bias_apparent_color(m_mf, m_preview_settings) && + m_mf.component_a >= 1 && m_mf.component_b >= 1 && + m_mf.component_a <= m_physical_colors.size() && m_mf.component_b <= m_physical_colors.size()) { + const auto [apparent_pct_a, apparent_pct_b] = + mixed_filament_apparent_pair_percentages(m_mf, m_preview_settings, m_nozzle_diameters); + m_mf.display_color = MixedFilamentManager::blend_color( + m_physical_colors[size_t(m_mf.component_a - 1)], + m_physical_colors[size_t(m_mf.component_b - 1)], + apparent_pct_a, + apparent_pct_b); + } else if (selected_gradient_ids.size() >= 3 || !preview_sequence.empty()) { m_mf.display_color = blend_from_sequence(m_physical_colors, preview_sequence, "#26A69A"); if (m_blend_label) { if (selected_gradient_ids.size() >= 3) { @@ -6148,8 +6344,14 @@ void MixedFilamentConfigPanel::build_ui() } if (m_mix_preview) { - const std::string summary = summarize_sequence(preview_sequence); - m_mix_preview->set_data(m_palette, preview_sequence, same_layer_mode, wxColour(m_mf.display_color), + const std::string bias_summary = mixed_filament_apparent_pair_summary(m_mf, m_preview_settings, m_nozzle_diameters); + const std::string summary = bias_summary.empty() ? summarize_sequence(preview_sequence) : bias_summary; + std::vector preview_surface_offsets(m_palette.size() + 1, 0.0); + if (m_mf.component_a >= 1 && m_mf.component_a < preview_surface_offsets.size()) + preview_surface_offsets[m_mf.component_a] = std::max(0.0, double(m_mf.component_a_surface_offset)); + if (m_mf.component_b >= 1 && m_mf.component_b < preview_surface_offsets.size()) + preview_surface_offsets[m_mf.component_b] = std::max(0.0, double(m_mf.component_b_surface_offset)); + m_mix_preview->set_data(m_palette, preview_sequence, same_layer_mode, preview_surface_offsets, wxColour(m_mf.display_color), _L("Preview"), summary.empty() ? wxString() : from_u8(summary)); } update_local_z_breakdown(); @@ -6246,6 +6448,16 @@ void MixedFilamentConfigPanel::build_ui() evt.Skip(); }); } + if (m_surface_offset_target_choice) + m_surface_offset_target_choice->Bind(wxEVT_CHOICE, [apply_changes](wxCommandEvent &) { apply_changes(); }); + if (m_surface_offset_spin) { + m_surface_offset_spin->Bind(wxEVT_SPINCTRLDOUBLE, [apply_changes](wxSpinDoubleEvent &) { apply_changes(); }); + m_surface_offset_spin->Bind(wxEVT_TEXT_ENTER, [apply_changes](wxCommandEvent &) { apply_changes(); }); + m_surface_offset_spin->Bind(wxEVT_KILL_FOCUS, [apply_changes](wxFocusEvent &evt) { + apply_changes(); + evt.Skip(); + }); + } if (m_blend_selector) { m_blend_selector->Bind(wxEVT_BUTTON, [this, apply_changes](wxCommandEvent&) { @@ -6344,6 +6556,24 @@ void MixedFilamentConfigPanel::update_component_picker_visuals() update_one(m_choice_b, m_picker_b_container, m_picker_b_swatch, m_picker_b_label); update_one(m_choice_c, m_picker_c_container, m_picker_c_swatch, m_picker_c_label); update_one(m_choice_d, m_picker_d_container, m_picker_d_swatch, m_picker_d_label); + + if (m_surface_offset_target_choice) { + const int a_filament = std::clamp(m_choice_a ? (m_choice_a->GetSelection() + 1) : int(m_mf.component_a), 1, int(std::max(1, m_num_physical))); + const int b_filament = std::clamp(m_choice_b ? (m_choice_b->GetSelection() + 1) : int(m_mf.component_b), 1, int(std::max(1, m_num_physical))); + if (m_surface_offset_target_choice->GetCount() >= 2) { + m_surface_offset_target_choice->SetString(0, wxString::Format("F%d", a_filament)); + m_surface_offset_target_choice->SetString(1, wxString::Format("F%d", b_filament)); + } + + const int selected_target = std::clamp(m_surface_offset_target_choice->GetSelection(), 0, 1); + const int color_idx = (selected_target == 1 ? b_filament : a_filament) - 1; + const wxColour color = (color_idx >= 0 && size_t(color_idx) < m_palette.size()) ? m_palette[size_t(color_idx)] : wxColour("#26A69A"); + if (m_surface_offset_target_swatch) { + m_surface_offset_target_swatch->SetBackgroundColour(color); + m_surface_offset_target_swatch->Refresh(); + } + m_surface_offset_target_choice->Refresh(); + } } void MixedFilamentConfigPanel::update_preview() @@ -6383,8 +6613,26 @@ void MixedFilamentConfigPanel::update_preview() } if (m_mix_preview) { - const std::string summary = summarize_sequence(initial_sequence); - m_mix_preview->set_data(m_palette, initial_sequence, same_layer_mode, wxColour(m_mf.display_color), + if (mixed_filament_supports_bias_apparent_color(m_mf, m_preview_settings) && + m_mf.component_a >= 1 && m_mf.component_b >= 1 && + m_mf.component_a <= m_physical_colors.size() && m_mf.component_b <= m_physical_colors.size()) { + const auto [apparent_pct_a, apparent_pct_b] = + mixed_filament_apparent_pair_percentages(m_mf, m_preview_settings, m_nozzle_diameters); + m_mf.display_color = MixedFilamentManager::blend_color( + m_physical_colors[size_t(m_mf.component_a - 1)], + m_physical_colors[size_t(m_mf.component_b - 1)], + apparent_pct_a, + apparent_pct_b); + } + + const std::string bias_summary = mixed_filament_apparent_pair_summary(m_mf, m_preview_settings, m_nozzle_diameters); + const std::string summary = bias_summary.empty() ? summarize_sequence(initial_sequence) : bias_summary; + std::vector preview_surface_offsets(m_palette.size() + 1, 0.0); + if (m_mf.component_a >= 1 && m_mf.component_a < preview_surface_offsets.size()) + preview_surface_offsets[m_mf.component_a] = std::max(0.0, double(m_mf.component_a_surface_offset)); + if (m_mf.component_b >= 1 && m_mf.component_b < preview_surface_offsets.size()) + preview_surface_offsets[m_mf.component_b] = std::max(0.0, double(m_mf.component_b_surface_offset)); + m_mix_preview->set_data(m_palette, initial_sequence, same_layer_mode, preview_surface_offsets, wxColour(m_mf.display_color), _L("Preview"), summary.empty() ? wxString() : from_u8(summary)); } update_local_z_breakdown(); @@ -6543,6 +6791,14 @@ void Sidebar::update_mixed_filament_panel(bool sync_manager) ConfigOptionStrings *color_opt = preset_bundle->project_config.option("filament_colour"); std::vector physical_colors = color_opt ? color_opt->values : std::vector(); physical_colors.resize(num_physical, "#26A69A"); + std::vector nozzle_diameters(num_physical, 0.4); + if (const ConfigOptionFloats *opt = preset_bundle->printers.get_edited_preset().config.option("nozzle_diameter")) { + const size_t opt_count = opt->values.size(); + if (opt_count > 0) { + for (size_t i = 0; i < num_physical; ++i) + nozzle_diameters[i] = std::max(0.05, opt->get_at(unsigned(std::min(i, opt_count - 1)))); + } + } auto get_mixed_bool = [preset_bundle, print_cfg](const std::string &key, bool fallback) { if (const ConfigOptionBool *opt = preset_bundle->project_config.option(key)) @@ -6929,7 +7185,20 @@ void Sidebar::update_mixed_filament_panel(bool sync_manager) const bool same_layer_mode = entry.distribution_mode == int(MixedFilament::SameLayerPointillisme); return build_effective_pair_preview_sequence(entry.component_a, entry.component_b, effective_mix_b, same_layer_mode); }; - auto compute_entry_display_color = [num_physical, &physical_colors, blend_from_sequence, build_entry_preview_sequence](const MixedFilament &entry) { + auto compute_entry_display_color = [num_physical, &physical_colors, &nozzle_diameters, blend_from_sequence, build_entry_preview_sequence, preview_settings](const MixedFilament &entry) { + if (mixed_filament_supports_bias_apparent_color(entry, preview_settings) && + entry.component_a >= 1 && entry.component_b >= 1 && + entry.component_a <= num_physical && entry.component_b <= num_physical && + entry.component_a <= physical_colors.size() && entry.component_b <= physical_colors.size()) { + const auto [apparent_pct_a, apparent_pct_b] = + mixed_filament_apparent_pair_percentages(entry, preview_settings, nozzle_diameters); + return MixedFilamentManager::blend_color( + physical_colors[entry.component_a - 1], + physical_colors[entry.component_b - 1], + apparent_pct_a, + apparent_pct_b); + } + const std::vector sequence = build_entry_preview_sequence(entry); if (!sequence.empty()) return blend_from_sequence(physical_colors, sequence, "#26A69A"); @@ -7371,7 +7640,7 @@ void Sidebar::update_mixed_filament_panel(bool sync_manager) return row->GetClientRect().Contains(local); }; - auto ensure_editor = [this, mixed_id, num_physical, physical_colors, palette, preview_settings, preset_bundle, + auto ensure_editor = [this, mixed_id, num_physical, physical_colors, nozzle_diameters, palette, preview_settings, preset_bundle, editor_host, editor_sizer, swatch, summary_label, header_panel, row, rows_scroller, mixed_summary_text, apply_mixed_entry_changes]() { if (!preset_bundle || !editor_sizer || editor_sizer->GetItemCount() > 0) @@ -7382,7 +7651,7 @@ void Sidebar::update_mixed_filament_panel(bool sync_manager) if (mixed_id >= mfs.size()) return; - auto *editor = new MixedFilamentConfigPanel(editor_host, mixed_id, mfs[mixed_id], num_physical, physical_colors, palette, preview_settings, + auto *editor = new MixedFilamentConfigPanel(editor_host, mixed_id, mfs[mixed_id], num_physical, physical_colors, nozzle_diameters, palette, preview_settings, [this, mixed_id, swatch, summary_label, header_panel, row, rows_scroller, mixed_summary_text, apply_mixed_entry_changes](const MixedFilament &updated_mf) { apply_mixed_entry_changes(mixed_id, updated_mf, true); @@ -19337,6 +19606,38 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us // if physical_printer is selected, send gcode for this printer // DynamicPrintConfig* physical_printer_config = wxGetApp().preset_bundle->physical_printers.get_selected_printer_config(); + auto prepare_upload_filename_for_dialog = [this, use_3mf](fs::path output_file) { + output_file = fs::path(Slic3r::fold_utf8_to_ascii(output_file.string())); + if (use_3mf) + output_file.replace_extension("3mf"); + + PartPlate *current_plate = this->get_partplate_list().get_curr_plate(); + if (current_plate != nullptr) { + const Print *current_print = current_plate->fff_print(); + if (current_print != nullptr && !current_print->print_statistics().estimated_normal_print_time.empty()) + return fs::path(current_print->print_statistics().finalize_output_path(output_file.string())); + } + + if (current_plate != nullptr && current_plate->is_slice_result_valid() && current_plate->get_slice_result() != nullptr) { + const auto &estimated_stats = current_plate->get_slice_result()->print_statistics; + const float normal_time = estimated_stats.modes[static_cast(PrintEstimatedStatistics::ETimeMode::Normal)].time; + if (normal_time > 0.0f) { + std::string filename = output_file.string(); + const std::string normal_time_str = short_time(get_time_dhms(normal_time)); + boost::replace_all(filename, "{print_time}", normal_time_str); + boost::replace_all(filename, "{normal_print_time}", normal_time_str); + + const float silent_time = estimated_stats.modes[static_cast(PrintEstimatedStatistics::ETimeMode::Stealth)].time; + if (silent_time > 0.0f) + boost::replace_all(filename, "{silent_print_time}", short_time(get_time_dhms(silent_time))); + + output_file = fs::path(filename); + } + } + + return output_file; + }; + // 校验机型 auto devices = wxGetApp().app_config->get_devices(); std::string connect_preset = ""; @@ -19403,10 +19704,7 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us show_error(this, ex.what(), false); return; } - default_output_file = fs::path(Slic3r::fold_utf8_to_ascii(default_output_file.string())); - if (use_3mf) { - default_output_file.replace_extension("3mf"); - } + default_output_file = prepare_upload_filename_for_dialog(std::move(default_output_file)); // 获取文件路径 auto file_path = get_partplate_list().get_curr_plate()->get_tmp_gcode_path(); @@ -19478,10 +19776,7 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us show_error(this, ex.what(), false); return; } - default_output_file = fs::path(Slic3r::fold_utf8_to_ascii(default_output_file.string())); - if (use_3mf) { - default_output_file.replace_extension("3mf"); - } + default_output_file = prepare_upload_filename_for_dialog(std::move(default_output_file)); // Repetier specific: Query the server for the list of file groups. wxArrayString groups; diff --git a/tests/libslic3r/test_mixed_filament.cpp b/tests/libslic3r/test_mixed_filament.cpp index acacd9666d..aca4281de4 100644 --- a/tests/libslic3r/test_mixed_filament.cpp +++ b/tests/libslic3r/test_mixed_filament.cpp @@ -164,6 +164,43 @@ TEST_CASE("Mixed filament grouped manual patterns normalize and round-trip", "[M CHECK(loaded.mixed_filaments().front().mix_b_percent == 13); } +TEST_CASE("Mixed filament component surface offsets round-trip and follow the active layer component", "[MixedFilament]") +{ + const std::vector colors = {"#FF0000", "#FFFF00"}; + + MixedFilamentManager mgr; + mgr.add_custom_filament(1, 2, 50, colors); + REQUIRE(mgr.mixed_filaments().size() == 1); + + MixedFilament &row = mgr.mixed_filaments().front(); + row.ratio_a = 1; + row.ratio_b = 1; + row.component_a_surface_offset = 0.02f; + row.component_b_surface_offset = -0.01f; + + const std::string serialized = mgr.serialize_custom_entries(); + CHECK(serialized.find("xa0.02") != std::string::npos); + CHECK(serialized.find("xb-0.01") != std::string::npos); + + MixedFilamentManager loaded; + loaded.load_custom_entries(serialized, colors); + REQUIRE(loaded.mixed_filaments().size() == 1); + + const MixedFilament &loaded_row = loaded.mixed_filaments().front(); + CHECK(loaded_row.component_a_surface_offset == Approx(0.02f)); + CHECK(loaded_row.component_b_surface_offset == Approx(-0.01f)); + CHECK(loaded.component_surface_offset(3, 2, 0) == Approx(0.02f)); + CHECK(loaded.component_surface_offset(3, 2, 1) == Approx(-0.01f)); +} + +TEST_CASE("Mixed filament apparent mix percent shifts toward the less-contracted component", "[MixedFilament]") +{ + CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.02f, 0.00f, 0.4f) == 55); + CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.00f, 0.02f, 0.4f) == 45); + CHECK(MixedFilamentManager::apparent_mix_b_percent(50, -0.02f, 0.00f, 0.4f) == 45); + CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.00f, -0.02f, 0.4f) == 55); +} + TEST_CASE("Mixed filament auto generation can be disabled without dropping custom rows", "[MixedFilament]") { const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; @@ -365,10 +402,14 @@ TEST_CASE("Mixed filament painted-region resolver preserves virtual channels for MixedFilament &row = mgr.mixed_filaments().front(); row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("12,21"); CHECK(mgr.effective_painted_region_filament_id(3, 2, 0) == 3); + row.component_a_surface_offset = 0.02f; + row.component_b_surface_offset = -0.02f; + CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.0f)); row.manual_pattern.clear(); row.distribution_mode = int(MixedFilament::SameLayerPointillisme); CHECK(mgr.effective_painted_region_filament_id(3, 2, 0) == 3); + CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.0f)); } TEST_CASE("ExtrusionPath copies preserve inset index", "[MixedFilament]")