diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index e7fda3ac4a..4ab589d63d 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -701,20 +701,12 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) info.used_g = used_filament_g; info.used_m = used_filament_m; - // Stamp each filament's logical-nozzle assignment onto the saved 3mf so the - // device/monitor can reconstruct it. NOTE: this block runs for the whole fleet, not just - // multi-nozzle prints: reorder_extruders_for_minimum_flush_volume runs unconditionally and - // set_nozzle_group_result stores a (non-null) 1-nozzle result even for a single-extruder print, - // so result->nozzle_group_result is non-null here for X1/P1/A1/H2S too. Saved-3mf byte-identity - // vs the older filament_maps path therefore holds by VALUE-COINCIDENCE, not by skipping: for a - // standard 0.4/0.2/0.6/0.8 nozzle the stamped group_id/nozzle_diameter/volume_type render the same - // bytes as the filament_maps-derived fallback below (empirically confirmed on an X1C 0.4 saved 3mf: - // slice_info.config emits group_id="0" nozzle_diameter="0.40" volume_type="Standard" and - // == the else-path). - // The one divergence is a non-standard nozzle (e.g. 0.3/0.5): format_diameter_to_str snaps the - // stamped diameter to the nearest of {0.2,0.4,0.6,0.8} (0.5 -> "0.4"), whereas the older path wrote - // the raw config value — a metadata-only carry. No g-code effect; the raw nozzle_diameters plate - // metadata is unchanged. + // Stamp each filament's logical-nozzle assignment onto the saved 3mf so the device/monitor can + // reconstruct it. This block runs for every print: reorder_extruders_for_minimum_flush_volume + // runs unconditionally and stores a (non-null) 1-nozzle result even for a single-extruder print, + // so result->nozzle_group_result is non-null here for single-nozzle printers too. The stamped + // nozzle_diameter is the grouping result's rounded matching-key value; the 3mf writer decides the + // final saved diameter (see has_multi_nozzle_extruder). group_id and volume_type are unaffected. if (result && result->nozzle_group_result) { auto nozzles_for_filament = result->nozzle_group_result->get_nozzles_for_filament(it->first); if (!nozzles_for_filament.empty()) { @@ -8331,6 +8323,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (nozzle_diameter_option) nozzle_diameters_str = nozzle_diameter_option->serialize(); + // True when any extruder carries a cluster of interchangeable nozzles (max nozzle count + // > 1). Such an extruder's per-nozzle diameters are not expressible in the per-extruder + // nozzle_diameter config, so the saved / diameters must come from the + // grouping result. For a single-nozzle-per-extruder printer the true diameter is the raw + // config value; the grouping result rounds it to the nearest of {0.2,0.4,0.6,0.8} for its + // internal matching key, so reading that back would rewrite a non-standard nozzle + // (e.g. 0.5 -> 0.4). Use this flag to keep the exact config value in that case. + auto* extruder_max_nozzle_count_option = dynamic_cast(config.option("extruder_max_nozzle_count")); + const bool has_multi_nozzle_extruder = extruder_max_nozzle_count_option && + std::any_of(extruder_max_nozzle_count_option->values.begin(), extruder_max_nozzle_count_option->values.end(), + [](int v) { return v > 1; }); + stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << PRINTER_MODEL_ID_ATTR << "\" " << VALUE_ATTR << "=\"" << plate_data->printer_model_id << "\"/>\n"; stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << NOZZLE_DIAMETERS_ATTR << "\" " << VALUE_ATTR << "=\"" << nozzle_diameters_str << "\"/>\n"; stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << TIMELAPSE_TYPE_ATTR << "\" " << VALUE_ATTR << "=\"" << timelapse_type << "\"/>\n"; @@ -8463,7 +8467,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (std::find(used_nozzle_groups.begin(), used_nozzle_groups.end(), nozzle_group_id) == used_nozzle_groups.end()) used_nozzle_groups.push_back(nozzle_group_id); const std::string filament_nozzle_group_id = it->group_id.empty() ? std::to_string(nozzle_group_id) : join_int_list_comma(it->group_id); - const double filament_nozzle_diameter = it->nozzle_diameter > 0.0 ? it->nozzle_diameter : get_nozzle_diameter(nozzle_group_id); + // Single-nozzle extruders: exact config diameter; clusters keep the result's rounded + // value (see has_multi_nozzle_extruder). + const double filament_nozzle_diameter = (has_multi_nozzle_extruder && it->nozzle_diameter > 0.0) + ? it->nozzle_diameter : get_nozzle_diameter(nozzle_group_id); const std::string filament_nozzle_volume_type = it->nozzle_volume_type.empty() ? get_nozzle_volume_type(nozzle_group_id) : it->nozzle_volume_type; stream << " <" << FILAMENT_TAG << " " << FILAMENT_ID_TAG << "=\"" << std::to_string(it->id + 1) << "\" " @@ -8483,17 +8490,16 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) stream << " <" << SLICE_WARNING_TAG << " msg=\"" << it->msg << "\" level=\"" << std::to_string(it->level) << "\" error_code =\"" << it->error_code << "\" />\n"; } - // Emit the tags from the grouping result when present. The result is present for - // the whole fleet (see the value-coincidence note in parse_filament_info), so this if-branch - // is normally taken; for single-nozzle-per-extruder prints get_used_nozzles_in_extruder() - // yields one nozzle per used extruder whose serialize() output is byte-identical to the - // filament_maps-derived else-path below for a standard nozzle diameter (a non-standard - // diameter snaps via format_diameter_to_str — the metadata-only carry). Only genuinely - // multi-nozzle prints emit per-nozzle tags the extruder map cannot express. The else - // is a defensive fallback for a missing result. + // Emit the tags from the grouping result. Single-nozzle-per-extruder printers + // override the diameter with the exact config value (see has_multi_nozzle_extruder); the + // else is a defensive fallback for a missing result. if (plate_data->nozzle_group_result) { auto used_nozzle_list = plate_data->nozzle_group_result->get_used_nozzles_in_extruder(); for (auto& used_nozzle : used_nozzle_list) { + if (!has_multi_nozzle_extruder && nozzle_diameter_option && + used_nozzle.extruder_id >= 0 && used_nozzle.extruder_id < (int) nozzle_diameter_option->values.size()) { + used_nozzle.diameter = get_nozzle_diameter_str(used_nozzle.extruder_id); + } stream << " <" << NOZZLE_TAG << " " << used_nozzle.serialize() << "/>\n"; } } else { diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 47126b1a2a..7a692f949f 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -236,6 +236,88 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") { } } +// Saved nozzle diameter for a single-nozzle-per-extruder printer with a non-standard nozzle. +// The grouping result rounds every nozzle diameter to the nearest of {0.2,0.4,0.6,0.8} for its +// internal matching key. That rounded value must NOT reach the saved / metadata on +// a printer whose extruders each carry one nozzle: the exact per-extruder config diameter is written +// instead, so a 0.5 mm nozzle is preserved rather than saved as 0.4. (Only an extruder that carries a +// nozzle cluster, which the per-extruder config cannot express, keeps the grouping result's diameter.) +SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle printer", "[3mf][MultiNozzle]") { + GIVEN("a single-extruder plate whose nozzle is 0.5 mm and whose stamped diameter was rounded to 0.4") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + std::string backup_dir = + (boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_nd_%%%%%%%%")).string(); + boost::filesystem::create_directories(backup_dir); + model.set_backup_path(backup_dir); + + // Single extruder with a non-standard 0.5 mm nozzle; extruder_max_nozzle_count stays at its + // default (no nozzle cluster), so the writer must emit the exact config diameter. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.5 })); + + PlateData* plate = new PlateData(); + plate->plate_index = 0; + plate->is_sliced_valid = true; // gate for the slice_info.config writer + plate->filament_maps = { 1 }; + + // Seed the stamped diameter with the grouping result's rounded value (0.5 -> 0.4) so the + // assertion proves the writer ignores it and emits the exact config diameter instead. + FilamentInfo fi; + fi.id = 0; + fi.type = "PLA"; + fi.color = "#FFFFFFFF"; + fi.group_id = { 0 }; + fi.nozzle_diameter = 0.4; // rounded; must NOT be the value written + plate->slice_filaments_info.push_back(fi); + + WHEN("stored to and reloaded from a .3mf") { + std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/nd_roundtrip.3mf"; + + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.plate_data_list.push_back(plate); + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + boost::filesystem::remove(test_file); + + THEN("the saved nozzle diameter is the exact 0.5, not the rounded 0.4") { + REQUIRE(loaded); + REQUIRE(dst_plates.size() >= 1); + PlateData* rt = dst_plates.front(); + + // tag: device-facing per-nozzle diameter string, written verbatim. + REQUIRE(rt->nozzles_info.size() >= 1); + REQUIRE(rt->nozzles_info.front().diameter == "0.5"); + + // tag: per-filament nozzle_diameter parsed back as 0.5, not 0.4. + REQUIRE(rt->slice_filaments_info.size() >= 1); + REQUIRE_THAT(rt->slice_filaments_info.front().nozzle_diameter, Catch::Matchers::WithinAbs(0.5, 1e-6)); + } + + release_PlateData_list(dst_plates); + } + delete plate; // store_bbs_3mf does not take ownership of the source plate + boost::filesystem::remove_all(backup_dir); + } +} + // A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS // importer and must not fabricate a filament_volume_map. SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {