From 103cf247e592607dbcd2e7b366227cf2b4f18c97 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 29 Apr 2026 19:22:29 +0800 Subject: [PATCH] Update from FS at b3c41fda4. Slicing - align merge_segmented_layers shape with FS apply_mm_segmentation (size = num_facets_states, loop from 0, no -1 shift); painted mixed regions were previously attributed to filament_id-1 of intent. apply_fuzzy_skin_segmentation reads channel 1; apply_mixed_surface_indentation uses segmentation_channel_filament_id - port apply_mixed_surface_indentation, apply_mixed_component_surface_offsets, apply_mixed_region_surface_offsets, apply_surface_emboss_mixed_region_override, plus surface_emboss_mixed_* debug subsystem - refactor apply_mm_segmentation (by-value MM, bias_mode, surface-type- preserving intersection, region normalization, post-MM dump); hoist MM segmentation into slice_volumes so mixed apply_* flow can mutate it - restore clear_local_z_plan() invalidation hooks (PrintObject.cpp:805/1264/1286) GCode - add LayerTools::preserve_extruder_order, honored by collect_extruders, both reorder_extruders overloads, and reorder_filaments_for_minimum_flush_volume; helpers append_unique_preserve_order / remove_duplicates_preserve_order - wire MixedFilamentManager::ordered_perimeter_extruders for grouped manual-pattern walls; set preserve_extruder_order when >= 2 - mixed-aware support: layer_height set for support-only layers, ExtrusionRole-based has_support/has_interface with erMixed short- circuit, support_filament / support_interface_filament routed through resolve_mixed Print - materialize mixed_filament_pointillism_{pixel_size,line_gap} in PrintApply's option-tracking block so in-session edits diff correctly GUI - Tab::on_value_change: dithering_local_z_mode cascading clears, 17-key project_config sync, update_mixed_filament_panel(false) on mixed_filament_component_bias_enabled change - GUI_Factories: physical_filaments_count, ui_ordered_filament_ids, filament_menu_item_name; filaments_count includes enabled mixed virtuals; right-click 'Change filament' submenus iterate UI-ordered IDs Tests - sentinel asserting MultiMaterialSegmentation uses FS-aligned shape (39 -> 40) --- src/libslic3r/GCode/ToolOrdering.cpp | 152 ++- src/libslic3r/GCode/ToolOrdering.hpp | 3 + src/libslic3r/MultiMaterialSegmentation.cpp | 13 +- src/libslic3r/PrintApply.cpp | 16 + src/libslic3r/PrintObject.cpp | 3 + src/libslic3r/PrintObjectSlice.cpp | 1261 +++++++++++++++++-- src/slic3r/GUI/GUI_Factories.cpp | 114 +- src/slic3r/GUI/Plater.cpp | 6 + src/slic3r/GUI/Tab.cpp | 75 ++ tests/libslic3r/test_review_fixes.cpp | 57 + 10 files changed, 1516 insertions(+), 184 deletions(-) diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index d65eeaa1f9..97075079b5 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -90,6 +90,12 @@ bool has_grouped_manual_pattern(const MixedFilamentManager *mixed_mgr, return normalized.find(',') != std::string::npos; } +void append_unique_preserve_order(std::vector &dst, unsigned int value) +{ + if (std::find(dst.begin(), dst.end(), value) == dst.end()) + dst.emplace_back(value); +} + bool internal_solid_infill_uses_sparse_filament(const PrintRegion ®ion, ExtrusionRole role) { return role == erSolidInfill && std::abs(region.config().sparse_infill_density.value - 100.) < EPSILON; @@ -141,6 +147,15 @@ unsigned int grouped_manual_pattern_infill_filament_1based(const LayerTools& la float(layer_tools.layer_height)); } +void remove_duplicates_preserve_order(std::vector &values) +{ + std::vector ordered; + ordered.reserve(values.size()); + for (unsigned int value : values) + append_unique_preserve_order(ordered, value); + values = std::move(ordered); +} + } // anonymous namespace // --------------------------------------------------------------------------- @@ -323,27 +338,29 @@ void ToolOrdering::handle_dontcare_extruder(const std::vector& too // Reorder the extruders of first layer { LayerTools& lt = m_layer_tools[0]; - std::vector layer0_extruders = lt.extruders; - lt.extruders.clear(); - for (unsigned int extruder_id : tool_order_layer0) { - auto iter = std::find(layer0_extruders.begin(), layer0_extruders.end(), extruder_id); - if (iter != layer0_extruders.end()) { - lt.extruders.push_back(extruder_id); - *iter = (unsigned int)-1; + if (!lt.preserve_extruder_order) { + std::vector layer0_extruders = lt.extruders; + lt.extruders.clear(); + for (unsigned int extruder_id : tool_order_layer0) { + auto iter = std::find(layer0_extruders.begin(), layer0_extruders.end(), extruder_id); + if (iter != layer0_extruders.end()) { + lt.extruders.push_back(extruder_id); + *iter = (unsigned int)-1; + } } - } - for (unsigned int extruder_id : layer0_extruders) { - if (extruder_id == 0) - continue; + for (unsigned int extruder_id : layer0_extruders) { + if (extruder_id == 0) + continue; - if (extruder_id != (unsigned int)-1) - lt.extruders.push_back(extruder_id); - } + if (extruder_id != (unsigned int)-1) + lt.extruders.push_back(extruder_id); + } - // all extruders are zero - if (lt.extruders.empty()) { - lt.extruders.push_back(tool_order_layer0[0]); + // all extruders are zero + if (lt.extruders.empty()) { + lt.extruders.push_back(tool_order_layer0[0]); + } } } @@ -359,6 +376,10 @@ void ToolOrdering::handle_dontcare_extruder(const std::vector& too if (lt.extruders.front() == 0) // Pop the "don't care" extruder, the "don't care" region will be merged with the next one. lt.extruders.erase(lt.extruders.begin()); + if (lt.preserve_extruder_order) { + last_extruder_id = lt.extruders.back(); + continue; + } // Reorder the extruders to start with the last one. for (size_t i = 1; i < lt.extruders.size(); ++i) if (lt.extruders[i] == last_extruder_id) { @@ -413,6 +434,10 @@ void ToolOrdering::handle_dontcare_extruder(unsigned int last_extruder_id) if (lt.extruders.front() == 0) // Pop the "don't care" extruder, the "don't care" region will be merged with the next one. lt.extruders.erase(lt.extruders.begin()); + if (lt.preserve_extruder_order) { + last_extruder_id = lt.extruders.back(); + continue; + } // Reorder the extruders to start with the last one. for (size_t i = 1; i < lt.extruders.size(); ++ i) if (lt.extruders[i] == last_extruder_id) { @@ -874,19 +899,40 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto } if (something_nonoverriddable){ - if (extruder_override == 0) { - layer_tools.extruders.emplace_back(layer_tools.wall_filament(region) + 1); - if (layerCount == 0) { - firstLayerExtruders.emplace_back(layer_tools.wall_filament(region) + 1); + const unsigned int configured_wall = (extruder_override == 0) + ? region.config().wall_filament.value + : extruder_override; + unsigned int wall_ext = resolve_mixed(configured_wall, + layerCount, + float(layer->print_z), + float(layer->height)); + const unsigned int grouped_id = + grouped_manual_pattern_mixed_filament_id_for_layer(layer_tools, configured_wall); + if (grouped_id != 0) { + const std::vector ordered = + m_mixed_mgr->ordered_perimeter_extruders(grouped_id, + m_num_physical, + layerCount, + float(layer->print_z), + float(layer->height)); + if (!ordered.empty()) { + if (ordered.size() >= 2) + layer_tools.preserve_extruder_order = true; + for (unsigned int extruder_id : ordered) { + layer_tools.extruders.emplace_back(extruder_id); + if (layerCount == 0 && + std::find(firstLayerExtruders.begin(), firstLayerExtruders.end(), int(extruder_id)) == firstLayerExtruders.end()) + firstLayerExtruders.emplace_back(int(extruder_id)); + } + } else { + layer_tools.extruders.emplace_back(wall_ext); + if (layerCount == 0) + firstLayerExtruders.emplace_back(wall_ext); } } else { - const unsigned int resolved = resolve_mixed(extruder_override, - layerCount, - float(layer->print_z), - float(layer->height)); - layer_tools.extruders.emplace_back(resolved); + layer_tools.extruders.emplace_back(wall_ext); if (layerCount == 0) - firstLayerExtruders.emplace_back(resolved); + firstLayerExtruders.emplace_back(wall_ext); } } @@ -935,19 +981,24 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto // Collect the support extruders. for (auto support_layer : object.support_layers()) { - LayerTools &layer_tools = this->tools_for_layer(support_layer->print_z); - ExtrusionRole role = support_layer->support_fills.role(); - bool has_support = false; - bool has_interface = false; - for (const ExtrusionEntity *ee : support_layer->support_fills.entities) { - ExtrusionRole er = ee->role(); - if (er == erSupportMaterial || er == erSupportTransition) has_support = true; - if (er == erSupportMaterialInterface) has_interface = true; - if (has_support && has_interface) break; - } - unsigned int extruder_support = object.config().support_filament.value; - unsigned int extruder_interface = object.config().support_interface_filament.value; + LayerTools &layer_tools = this->tools_for_layer(support_layer->print_z); + layer_tools.layer_height = support_layer->height; + ExtrusionRole role = support_layer->support_fills.role(); + bool has_support = role == erMixed || role == erSupportMaterial || role == erSupportTransition; + bool has_interface = role == erMixed || role == erSupportMaterialInterface; + + unsigned int extruder_support = resolve_mixed(object.config().support_filament.value, + layer_tools.layer_index, + float(support_layer->print_z), + float(support_layer->height)); + unsigned int extruder_interface = resolve_mixed(object.config().support_interface_filament.value, + layer_tools.layer_index, + float(support_layer->print_z), + float(support_layer->height)); + if (has_support) { + // BP-only fallback: when support_filament is unset and an interface + // exists, pick the lowest-flush non-soluble body extruder. if (extruder_support > 0 || !has_interface || extruder_interface == 0 || layer_tools.has_object) layer_tools.extruders.push_back(extruder_support); else { @@ -956,7 +1007,6 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto std::vector flush_matrix( cast(get_flush_volumes_matrix(object.print()->config().flush_volumes_matrix.values, 0, object.print()->config().nozzle_diameter.values.size()))); const unsigned int number_of_extruders = (unsigned int) (sqrt(flush_matrix.size()) + EPSILON); - // Extract purging volumes for each extruder pair: std::vector> wipe_volumes; for (unsigned int i = 0; i < number_of_extruders; ++i) wipe_volumes.push_back(std::vector(flush_matrix.begin() + i * number_of_extruders, flush_matrix.begin() + (i + 1) * number_of_extruders)); @@ -983,8 +1033,10 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto } for (auto& layer : m_layer_tools) { - // Sort and remove duplicates - sort_remove_duplicates(layer.extruders); + if (layer.preserve_extruder_order) + remove_duplicates_preserve_order(layer.extruders); + else + sort_remove_duplicates(layer.extruders); // make sure that there are some tools for each object layer (e.g. tall wiping object will result in empty extruders vector) if (layer.extruders.empty() && layer.has_object) @@ -1539,6 +1591,9 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first &filament_sequences ); + // TODO(fs-port): for layers with preserve_extruder_order=true the stats + // here reflect the optimized sequence; the guarded writeback below keeps + // the original ordering. UI-only divergence — see line ~1610 below. auto curr_flush_info = calc_filament_change_info_by_toolorder(print_config, filament_maps, nozzle_flush_mtx, filament_sequences); if (nozzle_nums <= 1) m_stats_by_single_extruder = curr_flush_info; @@ -1580,8 +1635,19 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first } } - for (size_t i = 0; i < filament_sequences.size(); ++i) + for (size_t i = 0; i < filament_sequences.size(); ++i) { + // FS preserve_extruder_order guard: keep the layer's existing extruder + // ordering by skipping writeback of the optimized sequence. + // + // Note: BP runs the optimizer (and updates stats) BEFORE this writeback + // because the optimizer is a free function in ToolOrderUtils.cpp without + // LayerTools access. Stats may diverge from g-code reality on + // preserve-order layers. See FS ToolOrdering.cpp:1156-1159 for the + // upstream behavior that guards earlier in the call chain. + if (m_layer_tools[i].preserve_extruder_order) + continue; m_layer_tools[i].extruders = std::move(filament_sequences[i]); + } } // Layers are marked for infinite skirt aka draft shield. Not all the layers have to be printed. void ToolOrdering::mark_skirt_layers(const PrintConfig &config, coordf_t max_layer_height) diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index 8bfd95d54f..2e567ff25c 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -157,6 +157,9 @@ public: bool has_support = false; // Zero based extruder IDs, ordered to minimize tool switches. std::vector extruders; + // When set, downstream reorder passes leave this layer's extruder + // sequence in place (used by grouped manual mixed-filament patterns). + bool preserve_extruder_order = false; // If per layer extruder switches are inserted by the G-code preview slider, this value contains the new (1 based) extruder, with which the whole object layer is being printed with. // If not overriden, it is set to 0. unsigned int extruder_override = 0; diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index 7924a447ec..4e63dbfeba 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1829,15 +1829,14 @@ static std::vector> merge_segmented_layers(const std::ve { const size_t num_layers = segmented_regions.size(); std::vector> segmented_regions_merged(num_layers); - segmented_regions_merged.assign(num_layers, std::vector(num_facets_states - 1)); + segmented_regions_merged.assign(num_layers, std::vector(num_facets_states)); assert(!top_and_bottom_layers.size() || num_facets_states == top_and_bottom_layers.size()); BOOST_LOG_TRIVIAL(debug) << "Print object segmentation - Merging segmented layers in parallel - Begin"; tbb::parallel_for(tbb::blocked_range(0, num_layers), [&segmented_regions, &top_and_bottom_layers, &segmented_regions_merged, &num_facets_states, &throw_on_cancel_callback](const tbb::blocked_range &range) { for (size_t layer_idx = range.begin(); layer_idx < range.end(); ++layer_idx) { assert(segmented_regions[layer_idx].size() == num_facets_states); - // Zero is skipped because it is the default color of the volume - for (size_t extruder_id = 1; extruder_id < num_facets_states; ++extruder_id) { + for (size_t extruder_id = 0; extruder_id < num_facets_states; ++extruder_id) { throw_on_cancel_callback(); if (!segmented_regions[layer_idx][extruder_id].empty()) { ExPolygons segmented_regions_trimmed = segmented_regions[layer_idx][extruder_id]; @@ -1849,16 +1848,16 @@ static std::vector> merge_segmented_layers(const std::ve } } - segmented_regions_merged[layer_idx][extruder_id - 1] = std::move(segmented_regions_trimmed); + segmented_regions_merged[layer_idx][extruder_id] = std::move(segmented_regions_trimmed); } if (!top_and_bottom_layers.empty() && !top_and_bottom_layers[extruder_id][layer_idx].empty()) { - bool was_top_and_bottom_empty = segmented_regions_merged[layer_idx][extruder_id - 1].empty(); - append(segmented_regions_merged[layer_idx][extruder_id - 1], top_and_bottom_layers[extruder_id][layer_idx]); + bool was_top_and_bottom_empty = segmented_regions_merged[layer_idx][extruder_id].empty(); + append(segmented_regions_merged[layer_idx][extruder_id], top_and_bottom_layers[extruder_id][layer_idx]); // Remove dimples (#7235) appearing after merging side segmentation of the model with tops and bottoms painted layers. if (!was_top_and_bottom_empty) - segmented_regions_merged[layer_idx][extruder_id - 1] = offset2_ex(union_ex(segmented_regions_merged[layer_idx][extruder_id - 1]), float(SCALED_EPSILON), -float(SCALED_EPSILON)); + segmented_regions_merged[layer_idx][extruder_id] = offset2_ex(union_ex(segmented_regions_merged[layer_idx][extruder_id]), float(SCALED_EPSILON), -float(SCALED_EPSILON)); } } } diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index b90d8180ac..b207d7f4f1 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1174,6 +1174,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ new_full_config.option("mixed_filament_height_lower_bound", true); new_full_config.option("mixed_filament_height_upper_bound", true); new_full_config.option("mixed_filament_advanced_dithering", true); + new_full_config.option("mixed_filament_pointillism_pixel_size", true); + new_full_config.option("mixed_filament_pointillism_line_gap", true); new_full_config.option("mixed_filament_component_bias_enabled", true); new_full_config.option("mixed_filament_surface_indentation", true); new_full_config.option("mixed_filament_region_collapse", true); @@ -1190,6 +1192,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ m_config.option("mixed_filament_height_lower_bound", true); m_config.option("mixed_filament_height_upper_bound", true); m_config.option("mixed_filament_advanced_dithering", true); + m_config.option("mixed_filament_pointillism_pixel_size", true); + m_config.option("mixed_filament_pointillism_line_gap", true); m_config.option("mixed_filament_component_bias_enabled", true); m_config.option("mixed_filament_surface_indentation", true); m_config.option("mixed_filament_region_collapse", true); @@ -1206,6 +1210,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ m_default_object_config.option("mixed_filament_height_lower_bound", true); m_default_object_config.option("mixed_filament_height_upper_bound", true); m_default_object_config.option("mixed_filament_advanced_dithering", true); + m_default_object_config.option("mixed_filament_pointillism_pixel_size", true); + m_default_object_config.option("mixed_filament_pointillism_line_gap", true); m_default_object_config.option("mixed_filament_component_bias_enabled", true); m_default_object_config.option("mixed_filament_surface_indentation", true); m_default_object_config.option("mixed_filament_region_collapse", true); @@ -1374,6 +1380,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ float mixed_height_lower = 0.04f; float mixed_height_upper = 0.16f; bool mixed_advanced_dither = false; + float mixed_pointillism_pixel_size = 0.f; + float mixed_pointillism_line_gap = 0.f; float mixed_surface_indentation = 0.f; std::string mixed_custom_definitions; @@ -1393,6 +1401,10 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ else mixed_advanced_dither = (new_full_config.opt_int("mixed_filament_advanced_dithering") != 0); } + if (new_full_config.has("mixed_filament_pointillism_pixel_size")) + mixed_pointillism_pixel_size = float(new_full_config.opt_float("mixed_filament_pointillism_pixel_size")); + if (new_full_config.has("mixed_filament_pointillism_line_gap")) + mixed_pointillism_line_gap = float(new_full_config.opt_float("mixed_filament_pointillism_line_gap")); if (new_full_config.has("mixed_filament_surface_indentation")) mixed_surface_indentation = float(new_full_config.opt_float("mixed_filament_surface_indentation")); if (new_full_config.has("mixed_filament_definitions")) @@ -1401,6 +1413,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ mixed_gradient_mode = std::clamp(mixed_gradient_mode, 0, 1); mixed_height_lower = std::max(0.01f, mixed_height_lower); mixed_height_upper = std::max(mixed_height_lower, mixed_height_upper); + mixed_pointillism_pixel_size = std::max(0.f, mixed_pointillism_pixel_size); + mixed_pointillism_line_gap = std::max(0.f, mixed_pointillism_line_gap); mixed_surface_indentation = std::clamp(mixed_surface_indentation, -2.f, 2.f); BOOST_LOG_TRIVIAL(info) << "Print::apply mixed settings" @@ -1408,6 +1422,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ << ", lower=" << mixed_height_lower << ", upper=" << mixed_height_upper << ", advanced_dither=" << (mixed_advanced_dither ? 1 : 0) + << ", pointillism_pixel_size=" << mixed_pointillism_pixel_size + << ", pointillism_line_gap=" << mixed_pointillism_line_gap << ", surface_indentation=" << mixed_surface_indentation << ", custom_definitions_len=" << mixed_custom_definitions.size() << ", physical_extruders=" << num_extruders; diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 89a327f368..71e2289c6f 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -992,6 +992,7 @@ FillLightning::GeneratorPtr PrintObject::prepare_lightning_infill_data() void PrintObject::clear_layers() { + this->clear_local_z_plan(); if (!m_shared_object) { for (Layer *l : m_layers) delete l; @@ -1467,6 +1468,7 @@ bool PrintObject::invalidate_step(PrintObjectStep step) invalidated |= this->invalidate_steps({ posPerimeters, posPrepareInfill, posInfill, posIroning, posContouring, posSupportMaterial, posSimplifyPath, posSimplifyInfill }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); m_slicing_params.valid = false; + this->clear_local_z_plan(); } else if (step == posSupportMaterial) { invalidated |= this->invalidate_steps({ posSimplifySupportPath }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); @@ -1488,6 +1490,7 @@ bool PrintObject::invalidate_all_steps() bool result = Inherited::invalidate_all_steps() | m_print->invalidate_all_steps(); // Then reset some of the depending values. m_slicing_params.valid = false; + this->clear_local_z_plan(); return result; } diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index 4a6cacbf50..a659e5ccb4 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -1,5 +1,8 @@ #include +#include +#include #include +#include #include @@ -12,6 +15,7 @@ #include "Layer.hpp" #include "MultiMaterialSegmentation.hpp" #include "Print.hpp" +#include "SVG.hpp" //BBS #include "ShortestPath.hpp" #include "libslic3r/Feature/Interlocking/InterlockingGenerator.hpp" @@ -24,6 +28,18 @@ namespace Slic3r { bool PrintObject::clip_multipart_objects = true; bool PrintObject::infill_only_where_needed = false; +// Forward declarations for the surface_emboss_mixed debug subsystem (defined further below). +static bool has_surface_emboss_mixed_volume(const PrintObject &print_object); +static std::string surface_emboss_mixed_debug_file_path(const PrintObject &print_object); +static void reset_surface_emboss_mixed_debug_file(const PrintObject &print_object); +static void dump_surface_emboss_mixed_layer_state( + const char *stage, + const PrintObject &print_object, + size_t layer_id, + const Layer &layer, + const PrintObjectRegions::LayerRangeRegions &layer_range, + const std::vector *segmentation_layer = nullptr); + LayerPtrs new_layers( PrintObject *print_object, // Object layers (pairs of bottom/top Z coordinate), without the raft. @@ -819,6 +835,12 @@ void PrintObject::slice() m_typed_slices = false; this->clear_layers(); m_layers = new_layers(this, generate_object_layers(m_slicing_params, layer_height_profile, m_config.precise_z_height.value)); + if (has_surface_emboss_mixed_volume(*this)) { + reset_surface_emboss_mixed_debug_file(*this); + BOOST_LOG_TRIVIAL(warning) << "Surface emboss mixed debug enabled" + << " object=" << (this->model_object() ? this->model_object()->name : std::string("")) + << " debug_file=" << surface_emboss_mixed_debug_file_path(*this); + } this->slice_volumes(); m_print->throw_if_canceled(); int firstLayerReplacedBy = 0; @@ -890,12 +912,26 @@ static void build_local_z_plan(PrintObject &print_object, const std::vector> whole_object_local_z_segmentation_by_mixed_wall(const PrintObject &print_object); static std::vector> local_z_planner_segmentation_with_whole_object_mixed_wall( const PrintObject &print_object, const std::vector> &paint_segmentation); +// Surface-emboss override is defined alongside the surface_emboss_mixed debug subsystem far +// below; declared here so slice_volumes() can call it. +template +static bool apply_surface_emboss_mixed_region_override(PrintObject &print_object, ThrowOnCancel throw_on_cancel); + +// Forward declarations for FS mixed-filament surface helpers, defined further below alongside +// segmentation_channel_filament_id / collect_layer_region_slices. +static inline unsigned int segmentation_channel_filament_id(size_t channel_idx); +static coordf_t clamped_mixed_component_surface_offset(const MixedFilamentManager &mixed_mgr, + const PrintConfig &print_cfg, + unsigned int filament_id, + size_t num_physical, + int layer_index, + float layer_print_z, + float layer_height, + bool force_height_weighted = false); template -static inline void apply_mm_segmentation(PrintObject &print_object, ThrowOnCancel throw_on_cancel) +static inline void apply_mm_segmentation(PrintObject &print_object, std::vector> segmentation, ThrowOnCancel throw_on_cancel) { - // Returns MM segmentation based on painting in MM segmentation gizmo - std::vector> segmentation = multi_material_segmentation_by_painting(print_object, throw_on_cancel); assert(segmentation.size() == print_object.layer_count()); const PrintConfig &print_cfg = print_object.print()->config(); const DynamicPrintConfig &full_cfg = print_object.print()->full_print_config(); @@ -904,55 +940,74 @@ static inline void apply_mm_segmentation(PrintObject &print_object, ThrowOnCance coordf_t(print_cfg.mixed_color_layer_height_a.value)); const coordf_t preferred_b = float_from_full_config(full_cfg, "mixed_color_layer_height_b", coordf_t(print_cfg.mixed_color_layer_height_b.value)); - const coordf_t base_height = std::max(0.01, coordf_t(print_object.config().layer_height.value)); + const coordf_t base_height = std::max(0.01f, coordf_t(print_object.config().layer_height.value)); const bool collapse_mixed_regions = bool_from_full_config(full_cfg, "mixed_filament_region_collapse", print_cfg.mixed_filament_region_collapse.value); + const bool bias_mode_enabled = + bool_from_full_config(full_cfg, "mixed_filament_component_bias_enabled", print_cfg.mixed_filament_component_bias_enabled.value); const MixedFilamentManager &mixed_mgr = print_object.print()->mixed_filament_manager(); - // --- Task 30: Local-Z plan wiring --- - // Build the local-Z plan from the painted segmentation, optionally merged with - // whole-object wall masks when dithering_local_z_whole_objects is enabled. - { - const bool local_z_whole_objects = - bool_from_full_config(full_cfg, "dithering_local_z_whole_objects", - print_cfg.dithering_local_z_whole_objects.value); - if (local_z_whole_objects) { - std::vector> merged_seg = - local_z_planner_segmentation_with_whole_object_mixed_wall(print_object, segmentation); - build_local_z_plan(print_object, merged_seg, throw_on_cancel); - // If still empty (no paint + no mixed walls), try wall-only path. - if (print_object.local_z_intervals().empty()) { - std::vector> wall_only_seg = - whole_object_local_z_segmentation_by_mixed_wall(print_object); - if (!wall_only_seg.empty()) - build_local_z_plan(print_object, wall_only_seg, throw_on_cancel); - } - } else { - build_local_z_plan(print_object, segmentation, throw_on_cancel); - } - } - // --- end Task 30 wiring --- + tbb::parallel_for( tbb::blocked_range(0, segmentation.size(), std::max(segmentation.size() / 128, size_t(1))), - [&print_object, &segmentation, &mixed_mgr, num_physical, preferred_a, preferred_b, base_height, collapse_mixed_regions, throw_on_cancel](const tbb::blocked_range &range) { + [&print_object, &segmentation, &mixed_mgr, num_physical, preferred_a, preferred_b, base_height, collapse_mixed_regions, bias_mode_enabled, throw_on_cancel](const tbb::blocked_range &range) { const auto &layer_ranges = print_object.shared_regions()->layer_ranges; double z = print_object.get_layer(int(range.begin()))->slice_z; auto it_layer_range = layer_range_first(layer_ranges, z); - // Channel 0 in the segmentation output is the background / default colour. - // Channels 1..N correspond to filament IDs (1-based), now including any - // enabled virtual / mixed filaments. Filament IDs are treated as opaque - // integers here; no assertion on id <= num_physical is required. - const size_t num_extruders = segmentation.empty() ? 0 : segmentation.front().size(); + // MM segmentation channel 0 is the underlying / default color of the parent + // region. Remaining channels correspond to filament IDs (1-based), which + // now include enabled mixed / virtual filaments. + const size_t num_channels = segmentation.empty() ? 0 : segmentation.front().size(); + const size_t num_extruders = num_channels > 0 ? num_channels - 1 : 0; struct ByExtruder { ExPolygons expolygons; BoundingBox bbox; }; + auto intersect_surfaces_preserve_types = [](const SurfaceCollection &src, const ExPolygons &mask) { + SurfaceCollection out; + if (src.empty() || mask.empty()) + return out; + + std::array by_surface; + for (const Surface &surface : src.surfaces) + by_surface[size_t(surface.surface_type)].emplace_back(&surface); + + for (size_t surface_type = 0; surface_type < size_t(stCount); ++surface_type) { + const SurfacesPtr &typed_surfaces = by_surface[surface_type]; + if (typed_surfaces.empty()) + continue; + ExPolygons clipped = intersection_ex(typed_surfaces, mask); + if (!clipped.empty()) + out.append(std::move(clipped), SurfaceType(surface_type)); + } + return out; + }; + struct ByRegion { - ExPolygons expolygons; + SurfaceCollection surfaces; bool needs_merge { false }; }; + auto normalize_region_surfaces = [](SurfaceCollection &src) { + if (src.surfaces.empty()) + return; + + std::array by_surface; + for (Surface &surface : src.surfaces) + by_surface[size_t(surface.surface_type)].emplace_back(std::move(surface.expolygon)); + + src.surfaces.clear(); + for (size_t surface_type = 0; surface_type < size_t(stCount); ++surface_type) { + ExPolygons &typed = by_surface[surface_type]; + if (typed.empty()) + continue; + if (typed.size() > 1) + typed = closing_ex(std::move(typed), scaled(10. * EPSILON)); + src.append(std::move(typed), SurfaceType(surface_type)); + } + }; + std::vector by_extruder; std::vector by_region; for (size_t layer_id = range.begin(); layer_id < range.end(); ++layer_id) { @@ -961,20 +1016,23 @@ static inline void apply_mm_segmentation(PrintObject &print_object, ThrowOnCance it_layer_range = layer_range_next(layer_ranges, it_layer_range, layer.slice_z); const PrintObjectRegions::LayerRangeRegions &layer_range = *it_layer_range; // Gather per extruder expolygons. - // segmentation[layer_id] has one entry per filament (0-based, i.e. index = filament_id - 1). - // When collapse_mixed_regions is on, two virtual filament channels that resolve to - // the same physical extruder on this layer use the same merge key so their painted - // regions collapse into one ByExtruder slot. - by_extruder.assign(num_physical, ByExtruder()); + assert(segmentation[layer_id].size() == num_channels); + by_extruder.assign(num_extruders, ByExtruder()); by_region.assign(layer.region_count(), ByRegion()); bool layer_split = false; - for (size_t extruder_id = 0; extruder_id < num_extruders; ++ extruder_id) { - // filament_id is 1-based; extruder_id is 0-based channel index - const unsigned int filament_id = unsigned(extruder_id + 1); - // Compute effective merge key: when region-collapse is on, virtual filaments - // that map to the same physical extruder on this layer share a slot. - const unsigned int effective_id = collapse_mixed_regions ? - mixed_mgr.effective_painted_region_filament_id(filament_id, + size_t missing_target_regions = 0; + std::vector missing_target_extruders; + ExPolygons default_segmentation = num_channels > 0 ? std::move(segmentation[layer_id][0]) : ExPolygons(); + BoundingBox default_bbox; + bool layer_has_component_bias = false; + if (!default_segmentation.empty()) { + default_bbox = get_extents(default_segmentation); + layer_split = true; + } + for (size_t channel_idx = 1; channel_idx < num_channels; ++ channel_idx) { + const unsigned int channel_id = unsigned(channel_idx); + const unsigned int effective_filament_id = collapse_mixed_regions ? + mixed_mgr.effective_painted_region_filament_id(channel_id, num_physical, int(layer_id), float(layer.print_z), @@ -982,40 +1040,75 @@ static inline void apply_mm_segmentation(PrintObject &print_object, ThrowOnCance float(preferred_a), float(preferred_b), float(base_height)) : - filament_id; + channel_id; const size_t effective_idx = - effective_id >= 1 && effective_id <= num_physical ? size_t(effective_id - 1) : size_t(extruder_id % num_physical); + effective_filament_id >= 1 && effective_filament_id <= num_extruders ? size_t(effective_filament_id - 1) : size_t(channel_idx - 1); ByExtruder ®ion = by_extruder[effective_idx]; - append(region.expolygons, std::move(segmentation[layer_id][extruder_id])); + append(region.expolygons, std::move(segmentation[layer_id][channel_idx])); if (! region.expolygons.empty()) { region.bbox = get_extents(region.expolygons); layer_split = true; } + + if (!region.expolygons.empty() && + bias_mode_enabled && + mixed_mgr.is_mixed(channel_id, num_physical) && + std::abs(mixed_mgr.component_surface_offset(channel_id, + num_physical, + int(layer_id), + float(layer.print_z), + float(layer.height))) > EPSILON) + layer_has_component_bias = true; } if (!layer_split) continue; + ExPolygons layer_geometry_mask; + BoundingBox layer_geometry_bbox; + if (layer_has_component_bias) { + if (!default_segmentation.empty()) + append(layer_geometry_mask, default_segmentation); + for (const ByExtruder &segmented : by_extruder) { + if (!segmented.expolygons.empty()) + append(layer_geometry_mask, segmented.expolygons); + } + if (!layer_geometry_mask.empty()) { + if (layer_geometry_mask.size() > 1) + layer_geometry_mask = closing_ex(union_ex(std::move(layer_geometry_mask)), scaled(5. * EPSILON)); + layer_geometry_bbox = get_extents(layer_geometry_mask); + } + } + // Split LayerRegions by by_extruder regions. // layer_range.painted_regions are sorted by extruder ID and parent PrintObject region ID. auto it_painted_region_begin = layer_range.painted_regions.cbegin(); for (int parent_layer_region_idx = 0; parent_layer_region_idx < layer.region_count(); ++parent_layer_region_idx) { - if (it_painted_region_begin == layer_range.painted_regions.cend()) - continue; - const LayerRegion &parent_layer_region = *layer.get_region(parent_layer_region_idx); const PrintRegion &parent_print_region = parent_layer_region.region(); assert(parent_print_region.print_object_region_id() == parent_layer_region_idx); if (parent_layer_region.slices.empty()) continue; + auto preserve_parent_region = [&by_region, &parent_layer_region, &parent_print_region]() { + if (!parent_layer_region.slices.empty()) + by_region[parent_print_region.print_object_region_id()].surfaces = parent_layer_region.slices; + }; + + if (it_painted_region_begin == layer_range.painted_regions.cend()) { + preserve_parent_region(); + continue; + } + // Find the first PaintedRegion, which overrides the parent PrintRegion. auto it_first_painted_region = std::find_if(it_painted_region_begin, layer_range.painted_regions.cend(), [&layer_range, &parent_print_region](const auto &painted_region) { return layer_range.volume_regions[painted_region.parent].region->print_object_region_id() == parent_print_region.print_object_region_id(); }); - if (it_first_painted_region == layer_range.painted_regions.cend()) + if (it_first_painted_region == layer_range.painted_regions.cend()) { + preserve_parent_region(); continue; // This LayerRegion isn't overrides by any PaintedRegion. + } assert(&parent_print_region == layer_range.volume_regions[it_first_painted_region->parent].region); @@ -1023,74 +1116,178 @@ static inline void apply_mm_segmentation(PrintObject &print_object, ThrowOnCance it_painted_region_begin = it_first_painted_region; const BoundingBox parent_layer_region_bbox = get_extents(parent_layer_region.slices.surfaces); - bool self_trimmed = false; + const bool clamp_parent_to_geometry = + layer_has_component_bias && + layer_geometry_bbox.defined && + parent_layer_region_bbox.overlap(layer_geometry_bbox); + ExPolygons clamped_parent_expolygons; + if (clamp_parent_to_geometry) + clamped_parent_expolygons = intersection_ex(parent_layer_region.slices.surfaces, layer_geometry_mask); + int self_extruder_id = -1; // 1-based extruder ID + ExPolygons explicit_self_expolygons; + ExPolygons default_self_expolygons; + if (const int cfg_wall = parent_print_region.config().wall_filament.value; + cfg_wall >= 1 && cfg_wall <= int(by_extruder.size())) + self_extruder_id = cfg_wall; + if (default_bbox.defined && parent_layer_region_bbox.overlap(default_bbox)) + default_self_expolygons = intersection_ex(parent_layer_region.slices.surfaces, default_segmentation); + std::vector assigned_extruder(by_extruder.size(), false); + std::vector alias_to_self_extruders; for (int extruder_id = 1; extruder_id <= int(by_extruder.size()); ++extruder_id) { const ByExtruder &segmented = by_extruder[extruder_id - 1]; if (!segmented.bbox.defined || !parent_layer_region_bbox.overlap(segmented.bbox)) continue; - // Find the first target region iterator. - auto it_target_region = std::find_if(it_painted_region_begin, layer_range.painted_regions.cend(), [extruder_id](const auto &painted_region) { - return int(painted_region.extruder_id) >= extruder_id; + // Find the matching target region for this parent and extruder ID. + auto it_target_region = std::find_if(it_painted_region_begin, layer_range.painted_regions.cend(), [&layer_range, &parent_print_region, extruder_id](const auto &painted_region) { + return layer_range.volume_regions[painted_region.parent].region == &parent_print_region && + int(painted_region.extruder_id) == extruder_id; }); - assert(it_target_region != layer_range.painted_regions.end()); - assert(layer_range.volume_regions[it_target_region->parent].region == &parent_print_region && int(it_target_region->extruder_id) == extruder_id); + if (it_target_region == layer_range.painted_regions.cend()) { + ++missing_target_regions; + missing_target_extruders.emplace_back(extruder_id); + continue; + } // Update the beginning PaintedRegion iterator for the next iteration. it_painted_region_begin = it_target_region; // FIXME: Don't trim by self, it is not reliable. if (it_target_region->region == &parent_print_region) { - self_extruder_id = extruder_id; + if (self_extruder_id < 0) + self_extruder_id = extruder_id; + if (extruder_id != self_extruder_id) + alias_to_self_extruders.emplace_back(extruder_id); + ExPolygons self_segmented = intersection_ex(parent_layer_region.slices.surfaces, segmented.expolygons); + if (!self_segmented.empty()) { + if (explicit_self_expolygons.empty()) + explicit_self_expolygons = std::move(self_segmented); + else + append(explicit_self_expolygons, std::move(self_segmented)); + } continue; } + assigned_extruder[size_t(extruder_id - 1)] = true; + // Steal from this region. int target_region_id = it_target_region->region->print_object_region_id(); ExPolygons stolen = intersection_ex(parent_layer_region.slices.surfaces, segmented.expolygons); if (!stolen.empty()) { ByRegion &dst = by_region[target_region_id]; - if (dst.expolygons.empty()) { - dst.expolygons = std::move(stolen); + SurfaceCollection stolen_surfaces = intersect_surfaces_preserve_types(parent_layer_region.slices, stolen); + if (stolen_surfaces.empty()) + continue; + if (dst.surfaces.empty()) { + dst.surfaces = std::move(stolen_surfaces); } else { - append(dst.expolygons, std::move(stolen)); + dst.surfaces.append(std::move(stolen_surfaces)); dst.needs_merge = true; } } } - if (!self_trimmed) { - // Trim slices of this LayerRegion with all the MM regions. - Polygons mine = to_polygons(parent_layer_region.slices.surfaces); - for (auto &segmented : by_extruder) { - if (&segmented - by_extruder.data() + 1 != self_extruder_id && segmented.bbox.defined && parent_layer_region_bbox.overlap(segmented.bbox)) { - mine = diff(mine, segmented.expolygons); - if (mine.empty()) - break; - } + // Trim slices of this LayerRegion with all the MM regions. + // Mixed bias can intentionally shrink a painted layer's true silhouette. + // Clamp the parent region to the post-bias segmentation union so the + // vacated area stays empty instead of falling back to the parent tool. + Polygons mine = clamp_parent_to_geometry ? to_polygons(clamped_parent_expolygons) : + to_polygons(parent_layer_region.slices.surfaces); + for (size_t extruder_idx = 0; extruder_idx < by_extruder.size(); ++extruder_idx) { + const ByExtruder &segmented = by_extruder[extruder_idx]; + if (!assigned_extruder[extruder_idx]) + continue; + if (int(extruder_idx + 1) != self_extruder_id && segmented.bbox.defined && parent_layer_region_bbox.overlap(segmented.bbox)) { + mine = diff(mine, segmented.expolygons); + if (mine.empty()) + break; } + } + + if (!explicit_self_expolygons.empty()) + explicit_self_expolygons = union_ex(explicit_self_expolygons); + if (!default_self_expolygons.empty()) + default_self_expolygons = union_ex(default_self_expolygons); + + ExPolygons preserved_self_expolygons; + if (!explicit_self_expolygons.empty()) + append(preserved_self_expolygons, explicit_self_expolygons); + if (!default_self_expolygons.empty()) + append(preserved_self_expolygons, default_self_expolygons); + if (!preserved_self_expolygons.empty()) + preserved_self_expolygons = union_ex(preserved_self_expolygons); + + ExPolygons mine_expolygons; + if (!mine.empty()) { + if (!preserved_self_expolygons.empty()) + mine = diff(mine, preserved_self_expolygons); // Filter out unprintable polygons produced by subtraction multi-material painted regions from layerm.region(). // ExPolygon returned from multi-material segmentation does not precisely match ExPolygons in layerm.region() // (because of preprocessing of the input regions in multi-material segmentation). Therefore, subtraction from // layerm.region() could produce a huge number of small unprintable regions for the model's base extruder. // This could, on some models, produce bulges with the model's base color (#7109). - if (!mine.empty()) { + if (!mine.empty()) mine = opening(union_ex(mine), scaled(5. * EPSILON), scaled(5. * EPSILON)); - } - if (!mine.empty()) { + if (!mine.empty()) + mine_expolygons = union_ex(mine); + } + + if (!preserved_self_expolygons.empty()) { + append(mine_expolygons, preserved_self_expolygons); + mine_expolygons = union_ex(mine_expolygons); + } + + if (!mine_expolygons.empty()) { + SurfaceCollection mine_surfaces = intersect_surfaces_preserve_types(parent_layer_region.slices, mine_expolygons); + if (!mine_surfaces.empty()) { ByRegion &dst = by_region[parent_print_region.print_object_region_id()]; - if (dst.expolygons.empty()) { - dst.expolygons = union_ex(mine); + if (dst.surfaces.empty()) { + dst.surfaces = std::move(mine_surfaces); } else { - append(dst.expolygons, union_ex(mine)); + dst.surfaces.append(std::move(mine_surfaces)); dst.needs_merge = true; } } } + + if (!alias_to_self_extruders.empty()) { + std::sort(alias_to_self_extruders.begin(), alias_to_self_extruders.end()); + alias_to_self_extruders.erase(std::unique(alias_to_self_extruders.begin(), alias_to_self_extruders.end()), alias_to_self_extruders.end()); + std::string alias_ids; + for (size_t i = 0; i < alias_to_self_extruders.size(); ++i) { + if (i > 0) + alias_ids += ","; + alias_ids += std::to_string(alias_to_self_extruders[i]); + } + BOOST_LOG_TRIVIAL(warning) << "MM segmentation alias-to-parent channels ignored" + << " object=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << " layer_id=" << layer_id + << " parent_region_id=" << parent_print_region.print_object_region_id() + << " self_extruder_id=" << self_extruder_id + << " alias_extruders=[" << alias_ids << "]"; + } + } + + if (missing_target_regions > 0) { + std::sort(missing_target_extruders.begin(), missing_target_extruders.end()); + missing_target_extruders.erase(std::unique(missing_target_extruders.begin(), missing_target_extruders.end()), missing_target_extruders.end()); + std::string missing_ids; + for (size_t i = 0; i < missing_target_extruders.size(); ++i) { + if (i > 0) + missing_ids += ","; + missing_ids += std::to_string(missing_target_extruders[i]); + } + BOOST_LOG_TRIVIAL(warning) << "MM segmentation missing painted target regions" + << " object=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << " layer_id=" << layer_id + << " missing_targets=" << missing_target_regions + << " missing_extruders=[" << missing_ids << "]" + << " segmentation_channels=" << num_extruders + << " painted_regions=" << layer_range.painted_regions.size(); } // Re-create Surfaces of LayerRegions. @@ -1098,15 +1295,417 @@ static inline void apply_mm_segmentation(PrintObject &print_object, ThrowOnCance ByRegion &src = by_region[region_id]; if (src.needs_merge) { // Multiple regions were merged into one. - src.expolygons = closing_ex(src.expolygons, scaled(10. * EPSILON)); + normalize_region_surfaces(src.surfaces); } - layer.get_region(region_id)->slices.set(std::move(src.expolygons), stInternal); + layer.get_region(region_id)->slices.set(std::move(src.surfaces)); } + + dump_surface_emboss_mixed_layer_state("post-mm-segmentation", + print_object, + layer_id, + layer, + layer_range, + &segmentation[layer_id]); } }); } +// Adjust virtual mixed-state masks by `mixed_filament_surface_indentation` mm. Positive values +// shrink the mixed footprint (revealing the parent masks beneath); negative values expand the +// mixed footprint outward, clipping against already-occupied physical-channel masks. +static bool apply_mixed_surface_indentation(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(); + coordf_t indentation_mm = float_from_full_config(full_cfg, "mixed_filament_surface_indentation", + coordf_t(print_cfg.mixed_filament_surface_indentation.value)); + indentation_mm = std::clamp(indentation_mm, coordf_t(-2.f), coordf_t(2.f)); + if (std::abs(indentation_mm) <= EPSILON) + 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) + return false; + + const MixedFilamentManager &mixed_mgr = print->mixed_filament_manager(); + const bool expand_outward = indentation_mm < 0.f; + const float delta_scaled = float(scale_(std::abs(double(indentation_mm)))); + if (delta_scaled <= float(EPSILON)) + return false; + + size_t changed_layers = 0; + size_t changed_states = 0; + size_t emptied_states = 0; + size_t overlap_clipped_states = 0; + size_t outside_trimmed_states = 0; + + for (size_t layer_id = 0; layer_id < segmentation.size(); ++layer_id) { + if (segmentation[layer_id].size() != num_channels) + continue; + + bool layer_changed = false; + ExPolygons outside_trim_band; + ExPolygons occupied; + if (expand_outward) { + for (size_t channel_idx = 0; channel_idx < num_channels; ++channel_idx) { + const ExPolygons &state_masks = segmentation[layer_id][channel_idx]; + if (state_masks.empty()) + continue; + + const unsigned int state_id = unsigned(segmentation_channel_filament_id(channel_idx)); + if (!mixed_mgr.is_mixed(state_id, num_physical)) + append(occupied, state_masks); + } + if (occupied.size() > 1) + occupied = union_ex(occupied); + } else { + ExPolygons layer_masks; + for (size_t channel_idx = 0; channel_idx < num_channels; ++channel_idx) { + const ExPolygons &state_masks = segmentation[layer_id][channel_idx]; + if (!state_masks.empty()) + append(layer_masks, state_masks); + } + if (!layer_masks.empty()) { + if (layer_masks.size() > 1) + layer_masks = union_ex(layer_masks); + + ExPolygons layer_inner = offset_ex(layer_masks, -delta_scaled); + if (!layer_inner.empty() && layer_inner.size() > 1) + layer_inner = union_ex(layer_inner); + + outside_trim_band = layer_inner.empty() ? layer_masks : diff_ex(layer_masks, layer_inner, ApplySafetyOffset::Yes); + if (!outside_trim_band.empty() && outside_trim_band.size() > 1) + outside_trim_band = union_ex(outside_trim_band); + } + } + + for (size_t channel_idx = num_physical; channel_idx < num_channels; ++channel_idx) { + ExPolygons &state_masks = segmentation[layer_id][channel_idx]; + if (state_masks.empty()) + continue; + + const unsigned int state_id = unsigned(segmentation_channel_filament_id(channel_idx)); + if (!mixed_mgr.is_mixed(state_id, num_physical)) + continue; + + ExPolygons adjusted; + if (expand_outward) { + adjusted = offset_ex(state_masks, delta_scaled); + if (!adjusted.empty() && adjusted.size() > 1) + adjusted = union_ex(adjusted); + + if (!adjusted.empty() && !occupied.empty()) { + ExPolygons clipped = diff_ex(adjusted, occupied, 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); + } + } else { + adjusted = outside_trim_band.empty() ? state_masks : diff_ex(state_masks, outside_trim_band, ApplySafetyOffset::Yes); + if (std::abs(area(adjusted)) + EPSILON < std::abs(area(state_masks))) + ++outside_trimmed_states; + if (!adjusted.empty() && adjusted.size() > 1) + adjusted = union_ex(adjusted); + } + + state_masks = std::move(adjusted); + if (state_masks.empty()) + ++emptied_states; + ++changed_states; + layer_changed = true; + + if (expand_outward && !state_masks.empty()) { + append(occupied, state_masks); + if (occupied.size() > 1) + occupied = union_ex(occupied); + } + } + + if (layer_changed) + ++changed_layers; + } + + if (changed_states == 0) + return false; + + BOOST_LOG_TRIVIAL(warning) << "Mixed surface indentation applied" + << " object=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << " indentation_mm=" << indentation_mm + << " direction=" << (expand_outward ? "outward" : "inward") + << " changed_layers=" << changed_layers + << " changed_states=" << changed_states + << " emptied_states=" << emptied_states + << " overlap_clipped_states=" << overlap_clipped_states + << " outside_trimmed_states=" << outside_trimmed_states; + return true; +} + +// Apply per-component surface offset bias to mixed-channel segmentation masks. Positive offsets +// contract the channel (revealing siblings); negative offsets expand the channel and steal area +// from siblings. Disabled when dithering local-Z mode owns the layer instead. +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; + if (!bool_from_full_config(full_cfg, "mixed_filament_component_bias_enabled", print_cfg.mixed_filament_component_bias_enabled.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; + + 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 = clamped_mixed_component_surface_offset(mixed_mgr, + print_cfg, + 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); + 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; + return true; +} + +// Region-level mixed-component surface bias for objects without MM-painted segmentation. +// Operates directly on LayerRegion::slices after they have been built. Negative offsets +// expand the mixed region and steal area from non-mixed siblings; positive offsets contract. +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; + if (!bool_from_full_config(full_cfg, "mixed_filament_component_bias_enabled", print_cfg.mixed_filament_component_bias_enabled.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 = clamped_mixed_component_surface_offset(mixed_mgr, + print_cfg, + 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; + + const ExPolygons original = to_expolygons(layerm->slices.surfaces); + ExPolygons adjusted = offset_ex(original, 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; +} + template void apply_fuzzy_skin_segmentation(PrintObject &print_object, ThrowOnCancel throw_on_cancel) { @@ -1131,8 +1730,8 @@ void apply_fuzzy_skin_segmentation(PrintObject &print_object, ThrowOnCancel thro it_layer_range = layer_range_next(layer_ranges, it_layer_range, layer.slice_z); const PrintObjectRegions::LayerRangeRegions &layer_range = *it_layer_range; - assert(segmentation[layer_idx].size() == 1); - const ExPolygons &fuzzy_skin_segmentation = segmentation[layer_idx][0]; + assert(segmentation[layer_idx].size() == 2); + const ExPolygons &fuzzy_skin_segmentation = segmentation[layer_idx][1]; const BoundingBox fuzzy_skin_segmentation_bbox = get_extents(fuzzy_skin_segmentation); if (fuzzy_skin_segmentation.empty()) continue; @@ -1283,6 +1882,15 @@ void PrintObject::slice_volumes() this->apply_conical_overhang(); + // Capture once: needed both inside the MMU block and in the wall-only Local-Z + // fallback after fuzzy-skin segmentation, so the dithering_local_z_whole_objects + // path can build a Local-Z plan even when no MM painting is present. + const PrintConfig &print_cfg = m_print->config(); + const DynamicPrintConfig &full_cfg = m_print->full_print_config(); + const bool local_z_whole_objects_enabled = + bool_from_full_config(full_cfg, "dithering_local_z_whole_objects", + print_cfg.dithering_local_z_whole_objects.value); + // Is any ModelVolume multi-material painted? if (const auto& volumes = this->model_object()->volumes; m_print->config().filament_diameter.size() > 1 && // BBS @@ -1299,7 +1907,27 @@ void PrintObject::slice_volumes() } BOOST_LOG_TRIVIAL(debug) << "Slicing volumes - MMU segmentation"; - apply_mm_segmentation(*this, [print]() { print->throw_if_canceled(); }); + 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"; + std::vector> local_z_segmentation = + local_z_whole_objects_enabled + ? local_z_planner_segmentation_with_whole_object_mixed_wall(*this, mm_segmentation) + : mm_segmentation; + build_local_z_plan(*this, local_z_segmentation, [print]() { print->throw_if_canceled(); }); + apply_mm_segmentation(*this, std::move(mm_segmentation), [print]() { print->throw_if_canceled(); }); + } + + apply_mixed_region_surface_offsets(*this); + + if (local_z_whole_objects_enabled && this->local_z_intervals().empty()) { + std::vector> whole_object_local_z_segmentation = + whole_object_local_z_segmentation_by_mixed_wall(*this); + if (!whole_object_local_z_segmentation.empty()) + build_local_z_plan(*this, whole_object_local_z_segmentation, [print]() { print->throw_if_canceled(); }); } // Is any ModelVolume fuzzy skin painted? @@ -1318,6 +1946,8 @@ void PrintObject::slice_volumes() apply_fuzzy_skin_segmentation(*this, [print]() { print->throw_if_canceled(); }); } + apply_surface_emboss_mixed_region_override(*this, [print]() { print->throw_if_canceled(); }); + InterlockingGenerator::generate_interlocking_structure(this, [print]() { print->throw_if_canceled(); }); m_print->throw_if_canceled(); @@ -1684,6 +2314,57 @@ static inline unsigned int segmentation_channel_filament_id(size_t channel_idx) return unsigned(channel_idx); } +// Average nozzle width across a mixed filament's referenced components. Used as +// the reference width when clamping mixed-component surface offsets. +static float mixed_filament_reference_nozzle_mm(const MixedFilament &mixed_row, const ConfigOptionFloats &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.05f, float(nozzle_diameters.get_at(component_id - 1)))); + }; + + append_if_valid(mixed_row.component_a); + append_if_valid(mixed_row.component_b); + + if (samples.empty()) + return 0.4f; + return std::accumulate(samples.begin(), samples.end(), 0.0f) / float(samples.size()); +} + +// Resolve the per-layer mixed-component surface offset for `filament_id` and +// clamp it to MixedFilamentManager::max_component_surface_offset_mm() based on +// the average nozzle width of the mixed row's referenced components. +static coordf_t clamped_mixed_component_surface_offset(const MixedFilamentManager &mixed_mgr, + const PrintConfig &print_cfg, + unsigned int filament_id, + size_t num_physical, + int layer_index, + float layer_print_z, + float layer_height, + bool force_height_weighted) +{ + const MixedFilament *mixed_row = mixed_mgr.mixed_filament_from_id(filament_id, num_physical); + if (mixed_row == nullptr) + return 0.f; + + const coordf_t offset_mm = coordf_t(mixed_mgr.component_surface_offset( + filament_id, + num_physical, + layer_index, + layer_print_z, + layer_height, + force_height_weighted)); + if (std::abs(offset_mm) <= EPSILON) + return 0.f; + + const float reference_nozzle = mixed_filament_reference_nozzle_mm(*mixed_row, print_cfg.nozzle_diameter); + const coordf_t limit_mm = coordf_t(MixedFilamentManager::max_component_surface_offset_mm(reference_nozzle)); + return std::clamp(offset_mm, -limit_mm, limit_mm); +} + // --- pass-height helpers --- static bool fit_pass_heights_to_interval(std::vector &passes, double base_height, double lo, double hi) @@ -2657,6 +3338,420 @@ static ExPolygons collect_layer_region_slices(const Layer &layer) return out; } +// --------------------------------------------------------------------------- +// surface_emboss_mixed debug subsystem +// --------------------------------------------------------------------------- +// Forward declaration for emboss_surface_mixed_shell_override_delta — defined +// below the debug subsystem alongside apply_surface_emboss_mixed_region_override. +static float emboss_surface_mixed_shell_override_delta(const LayerRegion &layerm, const ModelVolume &volume); + +struct SurfaceEmbossMixedDebugCandidate +{ + const ModelVolume *volume { nullptr }; + int region_id { -1 }; +}; + +static bool has_surface_emboss_mixed_volume(const PrintObject &print_object) +{ + const Print *print = print_object.print(); + if (print == nullptr) + return false; + + const size_t num_physical = print->config().filament_diameter.size(); + const MixedFilamentManager &mixed_mgr = print->mixed_filament_manager(); + for (const ModelVolume *volume : print_object.model_object()->volumes) + if (volume->is_model_part() && + volume->emboss_shape.has_value() && + volume->emboss_shape->projection.use_surface && + mixed_mgr.is_mixed(unsigned(std::max(0, volume->extruder_id())), num_physical)) + return true; + return false; +} + +static std::string surface_emboss_mixed_debug_file_path(const PrintObject &print_object) +{ + return debug_out_path("emboss-mixed/obj-%d-debug.txt", int(print_object.id().id)); +} + +static void reset_surface_emboss_mixed_debug_file(const PrintObject &print_object) +{ + std::ofstream out(surface_emboss_mixed_debug_file_path(print_object), std::ios::out | std::ios::trunc); + out << "surface emboss mixed debug" + << " object_id=" << int(print_object.id().id) + << " object_name=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << "\n"; +} + +static void append_surface_emboss_mixed_debug_line(const PrintObject &print_object, const std::string &line) +{ + std::ofstream out(surface_emboss_mixed_debug_file_path(print_object), std::ios::out | std::ios::app); + out << line << '\n'; +} + +static std::vector collect_surface_emboss_mixed_debug_candidates( + const Layer &layer, + const PrintObjectRegions::LayerRangeRegions &layer_range, + const MixedFilamentManager &mixed_mgr, + size_t num_physical) +{ + std::vector out; + std::vector processed_region_ids; + processed_region_ids.reserve(layer_range.volume_regions.size()); + + for (const PrintObjectRegions::VolumeRegion &volume_region : layer_range.volume_regions) { + const ModelVolume *volume = volume_region.model_volume; + if (volume == nullptr || !volume->is_model_part() || !volume->emboss_shape.has_value() || !volume->emboss_shape->projection.use_surface) + continue; + if (volume_region.region == nullptr) + continue; + + const int region_id = volume_region.region->print_object_region_id(); + if (region_id < 0 || region_id >= layer.region_count()) + continue; + if (std::find(processed_region_ids.begin(), processed_region_ids.end(), region_id) != processed_region_ids.end()) + continue; + processed_region_ids.emplace_back(region_id); + + if (!mixed_mgr.is_mixed(unsigned(std::max(0, volume_region.region->config().wall_filament.value)), num_physical)) + continue; + + out.push_back({ volume, region_id }); + } + + return out; +} + +static void export_surface_emboss_mixed_layer_svg( + const char *stage, + const PrintObject &print_object, + size_t layer_id, + const Layer &layer, + const std::vector &candidates, + const ExPolygons *overlay, + const std::string &overlay_legend) +{ + std::vector> items; + items.reserve(size_t(layer.region_count()) + ((overlay != nullptr && !overlay->empty()) ? 1 : 0)); + + for (int region_id = 0; region_id < layer.region_count(); ++region_id) { + const LayerRegion *layerm = layer.get_region(region_id); + if (layerm == nullptr || layerm->slices.empty()) + continue; + + ExPolygons expolygons = to_expolygons(layerm->slices.surfaces); + if (expolygons.empty()) + continue; + + const bool is_candidate = std::find_if(candidates.begin(), candidates.end(), [region_id](const auto &candidate) { + return candidate.region_id == region_id; + }) != candidates.end(); + + SVG::ExPolygonAttributes attrs( + "region " + std::to_string(region_id) + " wall=" + std::to_string(layerm->region().config().wall_filament.value), + is_candidate ? "#3b82f6" : "#bfc5cc", + is_candidate ? 0.35f : 0.14f); + attrs.outline_width = scale_(0.05f); + attrs.color_contour = is_candidate ? "blue" : "black"; + attrs.color_holes = attrs.color_contour; + items.emplace_back(std::move(expolygons), std::move(attrs)); + } + + if (overlay != nullptr && !overlay->empty()) { + SVG::ExPolygonAttributes attrs(overlay_legend, "#ef4444", 0.28f); + attrs.outline_width = scale_(0.05f); + attrs.color_contour = "red"; + attrs.color_holes = "red"; + items.emplace_back(*overlay, std::move(attrs)); + } + + if (!items.empty()) + SVG::export_expolygons(debug_out_path("emboss-mixed/obj-%d-layer-%03d-%s.svg", + int(print_object.id().id), + int(layer_id), + stage), + items); +} + +static void dump_surface_emboss_mixed_layer_state( + const char *stage, + const PrintObject &print_object, + size_t layer_id, + const Layer &layer, + const PrintObjectRegions::LayerRangeRegions &layer_range, + const std::vector *segmentation_layer) +{ + const Print *print = print_object.print(); + if (print == nullptr) + return; + + const size_t num_physical = print->config().filament_diameter.size(); + const MixedFilamentManager &mixed_mgr = print->mixed_filament_manager(); + const std::vector candidates = + collect_surface_emboss_mixed_debug_candidates(layer, layer_range, mixed_mgr, num_physical); + if (candidates.empty()) + return; + + std::ostringstream header; + header << std::fixed << std::setprecision(4) + << "stage=" << stage + << " layer=" << layer_id + << " print_z=" << layer.print_z + << " slice_z=" << layer.slice_z + << " regions=" << layer.region_count() + << " candidates=" << candidates.size(); + append_surface_emboss_mixed_debug_line(print_object, header.str()); + + for (int region_id = 0; region_id < layer.region_count(); ++region_id) { + const LayerRegion *layerm = layer.get_region(region_id); + if (layerm == nullptr) + continue; + const double slice_area = layerm->slices.empty() ? 0.0 : std::abs(area(to_expolygons(layerm->slices.surfaces))); + std::ostringstream line; + line << std::fixed << std::setprecision(4) + << " region=" << region_id + << " wall=" << layerm->region().config().wall_filament.value + << " sparse=" << layerm->region().config().sparse_infill_filament.value + << " solid=" << layerm->region().config().solid_infill_filament.value + << " area=" << slice_area; + append_surface_emboss_mixed_debug_line(print_object, line.str()); + } + + for (const SurfaceEmbossMixedDebugCandidate &candidate : candidates) { + const LayerRegion *layerm = layer.get_region(candidate.region_id); + if (layerm == nullptr) + continue; + + const double slice_area = layerm->slices.empty() ? 0.0 : std::abs(area(to_expolygons(layerm->slices.surfaces))); + const float shell_delta_scaled = emboss_surface_mixed_shell_override_delta(*layerm, *candidate.volume); + std::ostringstream line; + line << std::fixed << std::setprecision(4) + << " candidate region=" << candidate.region_id + << " volume_name=" << candidate.volume->name + << " volume_extruder=" << candidate.volume->extruder_id() + << " cfg_wall=" << layerm->region().config().wall_filament.value + << " depth=" << float(candidate.volume->emboss_shape->projection.depth) + << " shell_delta_mm=" << unscale(shell_delta_scaled) + << " area=" << slice_area; + append_surface_emboss_mixed_debug_line(print_object, line.str()); + + if (segmentation_layer != nullptr) { + const int cfg_wall = layerm->region().config().wall_filament.value; + if (cfg_wall >= 1 && cfg_wall <= int(segmentation_layer->size())) { + const double seg_area = std::abs(area((*segmentation_layer)[size_t(cfg_wall - 1)])); + std::ostringstream seg_line; + seg_line << std::fixed << std::setprecision(4) + << " segmentation channel=" << cfg_wall + << " area=" << seg_area; + append_surface_emboss_mixed_debug_line(print_object, seg_line.str()); + } + } + } + + export_surface_emboss_mixed_layer_svg(stage, print_object, layer_id, layer, candidates, nullptr, ""); +} + +static float emboss_surface_mixed_shell_override_delta(const LayerRegion &layerm, const ModelVolume &volume) +{ + if (!volume.emboss_shape.has_value() || !volume.emboss_shape->projection.use_surface) + return 0.f; + + const float depth_mm = std::max(0.f, float(volume.emboss_shape->projection.depth)); + if (depth_mm <= EPSILON) + return 0.f; + + const PrintRegionConfig &config = layerm.region().config(); + if (config.wall_loops.value <= 0) + return 0.f; + + const Flow ext_flow = layerm.flow(frExternalPerimeter); + const Flow perimeter_flow = layerm.flow(frPerimeter); + const coord_t shell_scaled = ext_flow.scaled_width() / 2 + + ext_flow.scaled_spacing() / 2 + + std::max(0, config.wall_loops.value - 1) * perimeter_flow.scaled_spacing(); + const float shell_depth_mm = float(unscale(shell_scaled)); + const float delta_mm = std::max(0.f, shell_depth_mm - depth_mm); + return delta_mm <= EPSILON ? 0.f : scaled(delta_mm); +} + +// For each LayerRegion that hosts a use_surface emboss volume painted with a mixed filament, +// override sibling regions' slices that intersect the emboss footprint so that the mixed-color +// surface paints on top of the neighbor instead of being trimmed away by the modifier hierarchy. +template +static bool apply_surface_emboss_mixed_region_override(PrintObject &print_object, ThrowOnCancel throw_on_cancel) +{ + const Print *print = print_object.print(); + if (print == nullptr || print_object.layer_count() == 0 || print_object.shared_regions() == nullptr) + return false; + + const size_t num_physical = print->config().filament_diameter.size(); + const MixedFilamentManager &mixed_mgr = print->mixed_filament_manager(); + const auto &volumes = print_object.model_object()->volumes; + if (num_physical == 0 || + std::find_if(volumes.begin(), volumes.end(), [&mixed_mgr, num_physical](const ModelVolume *volume) { + return volume->is_model_part() && + volume->emboss_shape.has_value() && + volume->emboss_shape->projection.use_surface && + mixed_mgr.is_mixed(unsigned(std::max(0, volume->extruder_id())), num_physical); + }) == volumes.end()) + return false; + + const auto &layer_ranges = print_object.shared_regions()->layer_ranges; + auto it_layer_range = layer_range_first(layer_ranges, print_object.get_layer(0)->slice_z); + + size_t changed_layers = 0; + size_t changed_regions = 0; + size_t stolen_regions = 0; + + for (size_t layer_id = 0; layer_id < print_object.layer_count(); ++layer_id) { + throw_on_cancel(); + + Layer &layer = *print_object.get_layer(int(layer_id)); + it_layer_range = layer_range_next(layer_ranges, it_layer_range, layer.slice_z); + const PrintObjectRegions::LayerRangeRegions &layer_range = *it_layer_range; + const std::vector candidates = + collect_surface_emboss_mixed_debug_candidates(layer, layer_range, mixed_mgr, num_physical); + if (!candidates.empty()) + dump_surface_emboss_mixed_layer_state("pre-emboss-override", print_object, layer_id, layer, layer_range); + + bool layer_changed = false; + ExPolygons layer_masks; + std::vector processed_region_ids; + processed_region_ids.reserve(layer_range.volume_regions.size()); + + for (const PrintObjectRegions::VolumeRegion &volume_region : layer_range.volume_regions) { + const ModelVolume *volume = volume_region.model_volume; + if (volume == nullptr || !volume->is_model_part() || !volume->emboss_shape.has_value() || !volume->emboss_shape->projection.use_surface) + continue; + if (volume_region.region == nullptr) + continue; + + const int region_id = volume_region.region->print_object_region_id(); + if (region_id < 0 || region_id >= layer.region_count()) + continue; + if (std::find(processed_region_ids.begin(), processed_region_ids.end(), region_id) != processed_region_ids.end()) + continue; + processed_region_ids.emplace_back(region_id); + + const unsigned int filament_id = unsigned(std::max(0, volume_region.region->config().wall_filament.value)); + if (!mixed_mgr.is_mixed(filament_id, num_physical)) + continue; + + LayerRegion *emboss_layerm = layer.get_region(region_id); + if (emboss_layerm == nullptr || emboss_layerm->slices.empty()) + continue; + + ExPolygons override_mask = to_expolygons(emboss_layerm->slices.surfaces); + if (override_mask.empty()) + continue; + + if (const float delta_scaled = emboss_surface_mixed_shell_override_delta(*emboss_layerm, *volume); + delta_scaled > float(EPSILON)) { + override_mask = offset_ex(override_mask, delta_scaled); + if (override_mask.empty()) + continue; + if (layer_masks.empty()) + layer_masks = collect_layer_region_slices(layer); + if (!layer_masks.empty()) + override_mask = intersection_ex(override_mask, layer_masks); + if (override_mask.empty()) + continue; + } + + { + std::ostringstream line; + line << std::fixed << std::setprecision(4) + << "stage=override-mask" + << " layer=" << layer_id + << " region=" << region_id + << " volume_name=" << volume->name + << " volume_extruder=" << volume->extruder_id() + << " cfg_wall=" << volume_region.region->config().wall_filament.value + << " depth=" << float(volume->emboss_shape->projection.depth) + << " shell_delta_mm=" << unscale(emboss_surface_mixed_shell_override_delta(*emboss_layerm, *volume)) + << " mask_area=" << std::abs(area(override_mask)); + append_surface_emboss_mixed_debug_line(print_object, line.str()); + } + const std::string overlay_stage = "override-mask-r" + std::to_string(region_id); + export_surface_emboss_mixed_layer_svg(overlay_stage.c_str(), + print_object, + layer_id, + layer, + candidates, + &override_mask, + "override mask"); + + ExPolygons emboss_slices = to_expolygons(emboss_layerm->slices.surfaces); + bool emboss_changed = false; + + for (int target_region_id = 0; target_region_id < layer.region_count(); ++target_region_id) { + if (target_region_id == region_id) + continue; + + LayerRegion *target_layerm = layer.get_region(target_region_id); + if (target_layerm == nullptr || target_layerm->slices.empty()) + continue; + if (target_layerm->region().config().wall_filament.value == int(filament_id)) + continue; + + ExPolygons stolen = intersection_ex(target_layerm->slices.surfaces, override_mask); + if (stolen.empty()) + continue; + + append(emboss_slices, stolen); + emboss_changed = true; + ++stolen_regions; + + std::ostringstream line; + line << std::fixed << std::setprecision(4) + << "stage=override-steal" + << " layer=" << layer_id + << " emboss_region=" << region_id + << " from_region=" << target_region_id + << " from_wall=" << target_layerm->region().config().wall_filament.value + << " stolen_area=" << std::abs(area(stolen)); + append_surface_emboss_mixed_debug_line(print_object, line.str()); + + Polygons remaining = diff(to_polygons(target_layerm->slices.surfaces), override_mask); + if (!remaining.empty()) + remaining = opening(union_ex(remaining), scaled(5. * EPSILON), scaled(5. * EPSILON)); + target_layerm->slices.set(union_ex(remaining), stInternal); + layer_changed = true; + } + + if (emboss_changed) { + if (emboss_slices.size() > 1) + emboss_slices = closing_ex(emboss_slices, scaled(10. * EPSILON)); + emboss_layerm->slices.set(std::move(emboss_slices), stInternal); + ++changed_regions; + layer_changed = true; + } else { + std::ostringstream line; + line << "stage=override-no-steal" + << " layer=" << layer_id + << " region=" << region_id + << " volume_name=" << volume->name; + append_surface_emboss_mixed_debug_line(print_object, line.str()); + } + } + + if (!candidates.empty()) + dump_surface_emboss_mixed_layer_state("post-emboss-override", print_object, layer_id, layer, layer_range); + + if (layer_changed) + ++changed_layers; + } + + if (changed_regions == 0) + return false; + + BOOST_LOG_TRIVIAL(warning) << "Surface emboss mixed-region override applied" + << " object=" << (print_object.model_object() ? print_object.model_object()->name : std::string("")) + << " changed_layers=" << changed_layers + << " changed_regions=" << changed_regions + << " stolen_regions=" << stolen_regions; + return true; +} + // Clip fixed-state extruder masks for a single pass of a split interval. static std::vector build_local_z_transition_fixed_masks_for_pass( const std::vector ¤t_masks_by_extruder, diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 2448d053ba..98284ac667 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -35,9 +35,48 @@ static PrinterTechnology printer_technology() return wxGetApp().preset_bundle->printers.get_selected_preset().printer_technology(); } +static int physical_filaments_count() +{ + return std::max(wxGetApp().filaments_cnt(), 0); +} + static int filaments_count() { - return wxGetApp().filaments_cnt(); + if (wxGetApp().preset_bundle == nullptr) + return 0; + const int physical = physical_filaments_count(); + const auto &mixed_mgr = wxGetApp().preset_bundle->mixed_filaments; + return static_cast(mixed_mgr.total_filaments(size_t(physical))); +} + +static std::vector ui_ordered_filament_ids() +{ + if (wxGetApp().plater() == nullptr) + return {}; + return wxGetApp().plater()->sidebar().get_ui_ordered_filament_ids(); +} + +static wxString filament_menu_item_name(const int filament_id_1based, const int display_filament_id_1based) +{ + if (filament_id_1based <= 0) + return _L("Default"); + + if (wxGetApp().preset_bundle == nullptr) + return wxString::Format(_L("Filament %d"), filament_id_1based); + + const int physical = physical_filaments_count(); + if (filament_id_1based <= physical) { + const size_t preset_idx = size_t(filament_id_1based - 1); + const auto &filament_presets = wxGetApp().preset_bundle->filament_presets; + if (preset_idx < filament_presets.size()) { + auto preset = wxGetApp().preset_bundle->filaments.find_preset(filament_presets[preset_idx]); + if (preset != nullptr) + return from_u8(preset->label(false)); + } + return wxString::Format(_L("Filament %d"), filament_id_1based); + } + + return wxString::Format(_L("Mixed Filament %d"), display_filament_id_1based); } static bool is_improper_category(const std::string& category, const int filaments_cnt, const bool is_object_settings = true) @@ -1022,8 +1061,9 @@ void MenuFactory::append_menu_item_change_extruder(wxMenu* menu) menu->Destroy(item_id); } - const int filaments_cnt = filaments_count(); - if (filaments_cnt <= 1) + // Use UI-ordered filament IDs (physical first, then enabled mixed in UI order) + const std::vector ordered_filament_ids = ui_ordered_filament_ids(); + if (ordered_filament_ids.size() <= 1) return; wxDataViewItemArray sels; @@ -1039,24 +1079,16 @@ void MenuFactory::append_menu_item_change_extruder(wxMenu* menu) if (sels.Count() == 1) { const ModelConfig& config = obj_list()->get_item_config(sels[0]); // BBS: set default extruder to 1 - initial_extruder = config.has("extruder") ? config.extruder() : 1; + initial_extruder = config.has("extruder") ? config.extruder() : 0; } - for (int i = 0; i <= filaments_cnt; i++) + for (size_t display_idx = 0; display_idx <= ordered_filament_ids.size(); ++display_idx) { - bool is_active_extruder = i == initial_extruder; - int icon_idx = i == 0 ? 0 : i - 1; + const int actual_filament_id = display_idx == 0 ? 0 : int(ordered_filament_ids[display_idx - 1]); + const bool is_active_extruder = actual_filament_id == initial_extruder; + const int icon_idx = actual_filament_id == 0 ? 0 : actual_filament_id - 1; - wxString item_name = _L("Default"); - - if (i > 0) { - auto preset = wxGetApp().preset_bundle->filaments.find_preset(wxGetApp().preset_bundle->filament_presets[i - 1]); - if (preset == nullptr) { - item_name = wxString::Format(_L("Filament %d"), i); - } else { - item_name = from_u8(preset->label(false)); - } - } + wxString item_name = filament_menu_item_name(actual_filament_id, int(display_idx)); if (is_active_extruder) { item_name << " (" + _L("current") + ")"; @@ -1064,11 +1096,13 @@ void MenuFactory::append_menu_item_change_extruder(wxMenu* menu) if (icon_idx >= 0 && icon_idx < icons.size()) { append_menu_item( - extruder_selection_menu, wxID_ANY, item_name, "", [i](wxCommandEvent &) { obj_list()->set_extruder_for_selected_items(i); }, *icons[icon_idx], menu, + extruder_selection_menu, wxID_ANY, item_name, "", + [actual_filament_id](wxCommandEvent &) { obj_list()->set_extruder_for_selected_items(actual_filament_id); }, *icons[icon_idx], menu, [is_active_extruder]() { return !is_active_extruder; }, m_parent); } else { append_menu_item( - extruder_selection_menu, wxID_ANY, item_name, "", [i](wxCommandEvent &) { obj_list()->set_extruder_for_selected_items(i); }, "", menu, + extruder_selection_menu, wxID_ANY, item_name, "", + [actual_filament_id](wxCommandEvent &) { obj_list()->set_extruder_for_selected_items(actual_filament_id); }, "", menu, [is_active_extruder]() { return !is_active_extruder; }, m_parent); } } @@ -2190,8 +2224,8 @@ void MenuFactory::append_menu_item_change_filament(wxMenu* menu) menu->Destroy(item_id); } - int filaments_cnt = filaments_count(); - if (filaments_cnt <= 1) + const std::vector ordered_filament_ids = ui_ordered_filament_ids(); + if (ordered_filament_ids.size() <= 1) return; wxDataViewItemArray sels; @@ -2206,12 +2240,10 @@ void MenuFactory::append_menu_item_change_filament(wxMenu* menu) } std::vector icons = get_extruder_color_icons(true); - if (icons.size() < filaments_cnt) { - BOOST_LOG_TRIVIAL(warning) << boost::format("Warning: icons size %1%, filaments_cnt=%2%")%icons.size()%filaments_cnt; + if (icons.size() < ordered_filament_ids.size()) { + BOOST_LOG_TRIVIAL(warning) << boost::format("Warning: icons size %1%, filaments_cnt=%2%") % icons.size() % ordered_filament_ids.size(); if (icons.size() <= 1) return; - else - filaments_cnt = icons.size(); } wxMenu* extruder_selection_menu = new wxMenu(); const wxString& name = sels.Count() == 1 ? names[0] : names[1]; @@ -2219,47 +2251,27 @@ void MenuFactory::append_menu_item_change_filament(wxMenu* menu) int initial_extruder = -1; // negative value for multiple object/part selection if (sels.Count() == 1) { const ModelConfig& config = obj_list()->get_item_config(sels[0]); - // BBS const auto sel_vol = obj_list()->get_selected_model_volume(); if (sel_vol && sel_vol->type() == ModelVolumeType::PARAMETER_MODIFIER) initial_extruder = config.has("extruder") ? config.extruder() : 0; else - initial_extruder = config.has("extruder") ? config.extruder() : 1; + initial_extruder = config.has("extruder") ? config.extruder() : 0; } - // BBS - bool has_modifier = false; - for (auto sel : sels) { - if (obj_list()->GetModel()->GetVolumeType(sel) == ModelVolumeType::PARAMETER_MODIFIER) { - has_modifier = true; - break; - } - } - - for (int i = has_modifier ? 0 : 1; i <= filaments_cnt; i++) + for (size_t display_idx = 0; display_idx <= ordered_filament_ids.size(); ++display_idx) { - // BBS - //bool is_active_extruder = i == initial_extruder; + const int actual_filament_id = display_idx == 0 ? 0 : int(ordered_filament_ids[display_idx - 1]); bool is_active_extruder = false; - wxString item_name = _L("Default"); - - if (i > 0) { - auto preset = wxGetApp().preset_bundle->filaments.find_preset(wxGetApp().preset_bundle->filament_presets[i - 1]); - if (preset == nullptr) { - item_name = wxString::Format(_L("Filament %d"), i); - } else { - item_name = from_u8(preset->label(false)); - } - } + wxString item_name = filament_menu_item_name(actual_filament_id, int(display_idx)); if (is_active_extruder) { item_name << " (" + _L("current") + ")"; } - wxBitmap bm = (i == 0 || size_t(i - 1) >= icons.size()) ? wxNullBitmap : *icons[i - 1]; append_menu_item(extruder_selection_menu, wxID_ANY, item_name, "", - [i](wxCommandEvent&) { obj_list()->set_extruder_for_selected_items(i); }, bm, menu, + [actual_filament_id](wxCommandEvent&) { obj_list()->set_extruder_for_selected_items(actual_filament_id); }, + actual_filament_id == 0 || size_t(actual_filament_id - 1) >= icons.size() ? wxNullBitmap : *icons[size_t(actual_filament_id - 1)], menu, [is_active_extruder]() { return !is_active_extruder; }, m_parent); } menu->Append(wxID_ANY, name, extruder_selection_menu, _L("Change Filament")); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c255110973..afd32467eb 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -11429,6 +11429,12 @@ void Plater::priv::on_filament_color_changed(wxCommandEvent &event) if (wxGetApp().app_config->get("auto_calculate_flush") != "disabled") { sidebar->auto_calc_flushing_volumes(modify_id); } + + // Regenerate mixed filaments and refresh the mixed panel only. Color + // changes do not alter filament IDs, so the full on_filaments_change() + // path is unnecessary and can re-enter UI rebuilds mid-update. + wxGetApp().preset_bundle->update_multi_material_filament_presets(); + sidebar->update_mixed_filament_panel(); } void Plater::priv::install_network_plugin(wxCommandEvent &event) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 4c253bd1ef..fe27e289fb 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -1615,6 +1615,49 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) } } + if (opt_key == "dithering_local_z_mode") { + const bool local_z_enabled = boost::any_cast(value); + if (local_z_enabled && + (!m_config->has("mixed_filament_region_collapse") || + m_config->option("mixed_filament_region_collapse") == nullptr || + m_config->opt_bool("mixed_filament_region_collapse"))) { + change_opt_value(*m_config, "mixed_filament_region_collapse", boost::any(false)); + if (m_type == Preset::TYPE_PRINT) { + DynamicPrintConfig &project_cfg = wxGetApp().preset_bundle->project_config; + project_cfg.set_key_value("mixed_filament_region_collapse", new ConfigOptionBool(false)); + } + if (Field *field = this->get_field("mixed_filament_region_collapse")) + field->set_value(boost::any(false), false); + update_dirty(); + } + if (!local_z_enabled && + m_config->has("dithering_local_z_whole_objects") && + m_config->option("dithering_local_z_whole_objects") != nullptr && + m_config->opt_bool("dithering_local_z_whole_objects")) { + change_opt_value(*m_config, "dithering_local_z_whole_objects", boost::any(false)); + if (m_type == Preset::TYPE_PRINT) { + DynamicPrintConfig &project_cfg = wxGetApp().preset_bundle->project_config; + project_cfg.set_key_value("dithering_local_z_whole_objects", new ConfigOptionBool(false)); + } + if (Field *field = this->get_field("dithering_local_z_whole_objects")) + field->set_value(boost::any(false), false); + update_dirty(); + } + if (!local_z_enabled && + m_config->has("dithering_local_z_direct_multicolor") && + m_config->option("dithering_local_z_direct_multicolor") != nullptr && + m_config->opt_bool("dithering_local_z_direct_multicolor")) { + change_opt_value(*m_config, "dithering_local_z_direct_multicolor", boost::any(false)); + if (m_type == Preset::TYPE_PRINT) { + DynamicPrintConfig &project_cfg = wxGetApp().preset_bundle->project_config; + project_cfg.set_key_value("dithering_local_z_direct_multicolor", new ConfigOptionBool(false)); + } + if (Field *field = this->get_field("dithering_local_z_direct_multicolor")) + field->set_value(boost::any(false), false); + update_dirty(); + } + } + // reload scene to update timelapse wipe tower if (opt_key == "timelapse_type") { bool wipe_tower_enabled = m_config->option("enable_prime_tower")->value; @@ -1907,10 +1950,42 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) return; } + const bool refresh_mixed_filament_panel = + m_type == Preset::TYPE_PRINT && opt_key == "mixed_filament_component_bias_enabled"; + + // Keep Mixed Filaments global settings in sync with project_config. In + // full_fff_config(), project_config is applied last and would otherwise + // override the edited print preset value from the Others panel. + if (m_type == Preset::TYPE_PRINT && + (opt_key == "mixed_filament_gradient_mode" || + opt_key == "mixed_filament_height_lower_bound" || + opt_key == "mixed_filament_height_upper_bound" || + opt_key == "mixed_color_layer_height_a" || + opt_key == "mixed_color_layer_height_b" || + opt_key == "mixed_filament_advanced_dithering" || + opt_key == "mixed_filament_pointillism_pixel_size" || + opt_key == "mixed_filament_pointillism_line_gap" || + opt_key == "mixed_filament_component_bias_enabled" || + opt_key == "mixed_filament_surface_indentation" || + opt_key == "mixed_filament_region_collapse" || + opt_key == "dithering_z_step_size" || + opt_key == "dithering_local_z_mode" || + opt_key == "dithering_local_z_whole_objects" || + opt_key == "dithering_local_z_direct_multicolor" || + opt_key == "dithering_step_painted_zones_only" || + opt_key == "mixed_filament_definitions")) { + DynamicPrintConfig &project_cfg = wxGetApp().preset_bundle->project_config; + if (const ConfigOption *opt = m_config->option(opt_key)) + project_cfg.set_key_value(opt_key, opt->clone()); + } + update(); if(m_active_page) m_active_page->update_visibility(m_mode, true); m_page_view->GetParent()->Layout(); + + if (refresh_mixed_filament_panel && wxGetApp().plater() != nullptr) + wxGetApp().sidebar().update_mixed_filament_panel(false); } void Tab::show_timelapse_warning_dialog() { diff --git a/tests/libslic3r/test_review_fixes.cpp b/tests/libslic3r/test_review_fixes.cpp index be901fa92d..ec0146edd7 100644 --- a/tests/libslic3r/test_review_fixes.cpp +++ b/tests/libslic3r/test_review_fixes.cpp @@ -6,6 +6,9 @@ #include "libslic3r/Model.hpp" #include "libslic3r/Slicing.hpp" +#include +#include +#include #include #include @@ -130,3 +133,57 @@ TEST_CASE("[review-fixes] mixed/dithering option keys exist in PrintConfig", REQUIRE(print_config_def.get(k) != nullptr); } } + +TEST_CASE("[review-fixes] clear_local_z_plan called from clear_layers", "[review-fixes][mixed-filament]") +{ + // Sentinel: ensure the source contains the 3 invalidation hooks the + // FullSpectrum verification report flagged as missing. This is a + // smoke-test against the source so we can detect future regressions + // without depending on a full slicing-pipeline harness. + namespace fs = boost::filesystem; + const fs::path repo = fs::path(__FILE__).parent_path().parent_path().parent_path(); + const fs::path src = repo / "src" / "libslic3r" / "PrintObject.cpp"; + REQUIRE(fs::exists(src)); + + std::ifstream in(src.string()); + std::stringstream buf; buf << in.rdbuf(); + const std::string body = buf.str(); + + const auto count_substr = [&](const std::string &needle) { + size_t n = 0, pos = 0; + while ((pos = body.find(needle, pos)) != std::string::npos) { ++n; ++pos; } + return n; + }; + + // Three FS-mandated call sites: clear_layers, invalidate_step(posSlice), + // invalidate_all_steps. Plus the one already-present site inside + // build_local_z_plan(). PrintObject.cpp itself has 3 (4 once the + // backport is complete). + REQUIRE(count_substr("this->clear_local_z_plan()") >= 3); +} + +TEST_CASE("[review-fixes] merge_segmented_layers preserves channel 0", "[review-fixes][mixed-filament]") +{ + // Sentinel: ensure merge_segmented_layers uses the FS shape + // (output sized num_facets_states, channel 0 = default), not the + // pre-FS-a11b70e3a shape (output sized num_facets_states - 1, + // channel 0 dropped). The FS-verbatim apply_mm_segmentation in + // PrintObjectSlice.cpp expects channel 0 to be present; mismatching + // shapes silently shifts every painted region's filament_id by -1 + // (e.g. paint with mixed slot 4 -> applied as physical filament 3). + namespace fs = boost::filesystem; + const fs::path repo = fs::path(__FILE__).parent_path().parent_path().parent_path(); + const fs::path src = repo / "src" / "libslic3r" / "MultiMaterialSegmentation.cpp"; + REQUIRE(fs::exists(src)); + + std::ifstream in(src.string()); + std::stringstream buf; buf << in.rdbuf(); + const std::string body = buf.str(); + + // Producer must NOT subtract one from num_facets_states when sizing the output. + REQUIRE(body.find("num_facets_states - 1") == std::string::npos); + // Producer must NOT shift indexing back by one when writing into the output. + REQUIRE(body.find("[extruder_id - 1]") == std::string::npos); + // Loop must include channel 0 (extruder_id starts at 0, not 1). + REQUIRE(body.find("for (size_t extruder_id = 0; extruder_id < num_facets_states") != std::string::npos); +}