From f1fd49c12f10254948e8f2bd30901d1d8c71fe56 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 15 Jul 2026 01:49:04 +0800 Subject: [PATCH] Add slice-validation sweep for shipped profiles --- .github/workflows/check_profiles.yml | 22 +- .../OrcaSlicer_profile_validator.cpp | 282 ++++++++++++++++++ src/libslic3r/GCode/GCodeProcessor.cpp | 5 +- tests/CMakeLists.txt | 6 +- tests/fff_print/test_gcodewriter.cpp | 130 ++++++++ 5 files changed, 441 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml index 5f8f3d9e07..59c92e3ec0 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -49,6 +49,15 @@ jobs: set +e ./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log exit ${PIPESTATUS[0]} + # Slice a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode) + # is expanded - catches undefined-placeholder / invalid-flow bugs the static checks above cannot see. + - name: validate slice (expand custom g-code) + id: validate_slice + continue-on-error: true + run: | + set +e + ./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -s -l 2 2>&1 | tee ${{ runner.temp }}/validate_slice.log + exit ${PIPESTATUS[0]} # For now run filament subtype check only for BBL profiles until we fix other vendors' profiles. - name: validate filament subtype check for BBL profiles id: validate_filament_subtypes @@ -166,7 +175,7 @@ jobs: echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt - name: Prepare comment artifact - if: ${{ always() && github.event_name == 'pull_request' && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} + if: ${{ always() && github.event_name == 'pull_request' && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} run: | { # Marker matched by check_profiles_comment.yml to delete prior comments. @@ -192,6 +201,15 @@ jobs: echo "" fi + if [ "${{ steps.validate_slice.outcome }}" = "failure" ]; then + echo "### Slice Validation Failed (custom g-code expansion)" + echo "" + echo '```' + head -c 30000 ${{ runner.temp }}/validate_slice.log || echo "No output captured" + echo '```' + echo "" + fi + if [ "${{ steps.validate_filament_subtypes.outcome }}" = "failure" ]; then echo "### BBL Filament Subtype Validation Failed" echo "" @@ -223,7 +241,7 @@ jobs: retention-days: 1 - name: Fail if any check failed - if: ${{ always() && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} + if: ${{ always() && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} run: | echo "One or more profile checks failed. See above for details." exit 1 diff --git a/src/dev-utils/OrcaSlicer_profile_validator.cpp b/src/dev-utils/OrcaSlicer_profile_validator.cpp index 3208f55452..051758fc19 100644 --- a/src/dev-utils/OrcaSlicer_profile_validator.cpp +++ b/src/dev-utils/OrcaSlicer_profile_validator.cpp @@ -1,12 +1,32 @@ +// This single-TU executable links libslic3r, whose SVG/emboss objects (pulled in by the slice mode +// below) reference the header-only nanosvg implementation. Provide it here BEFORE any libslic3r header: +// several of them transitively include nanosvg.h without the implementation macro, and its include +// guard would then suppress the implementation if the macro were defined afterwards. Same pattern as +// the test mains. +#define NANOSVG_IMPLEMENTATION +#include "nanosvg/nanosvg.h" +#define NANOSVGRAST_IMPLEMENTATION +#include "nanosvg/nanosvgrast.h" + #include "libslic3r/GCode.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/Print.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/TriangleMesh.hpp" #include "libslic3r/Utils.hpp" #include #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include @@ -83,6 +103,254 @@ void generate_custom_presets(PresetBundle* preset_bundle, AppConfig& app_config) std::cout << "Custom presets generated successfully" << std::endl; } + +namespace { + +Vec2d printable_area_center(const DynamicPrintConfig &cfg) +{ + const auto *opt = cfg.option("printable_area"); + if (opt == nullptr || opt->values.empty()) + return Vec2d(100., 100.); + Vec2d lo = opt->values.front(), hi = opt->values.front(); + for (const Vec2d &p : opt->values) { lo = lo.cwiseMin(p); hi = hi.cwiseMax(p); } + return 0.5 * (lo + hi); +} + +// Slice one centered cube that switches from filament 1 to filament 2 partway up, so exactly one +// filament change fires, then export. The change drives the printer's own change_filament_gcode: on a +// single-nozzle machine it rides the AMS prime tower (append_tcr), on a multi-nozzle machine it routes +// through the nozzle swap (set_extruder / append_tcr2) - the engine picks the path from the printer's +// topology, so one model covers both. An undefined placeholder in any shipped custom g-code throws +// Slic3r::PlaceholderParserError from export. +std::string slice_two_color_cube_and_export(const DynamicPrintConfig &cfg, bool is_bbl) +{ + const Vec2d center = printable_area_center(cfg); + TriangleMesh m = make_cube(10, 10, 10); + m.translate(float(center.x() - 5.), float(center.y() - 5.), 0.f); + + Model model; + Print print; + ModelObject *obj = model.add_object(); + obj->name = "cube"; // populates [input_filename_base] the way a loaded model does + obj->add_volume(m); + obj->add_instance(); + // Filament 2 is used only above z=4, so the upper layers carry a single filament change. + DynamicPrintConfig range_config; + range_config.set_key_value("extruder", new ConfigOptionInt(2)); + // Every range must carry a layer_height; use the process's own so a fine nozzle (e.g. 0.15 mm + // printing ~0.1 mm layers) isn't forced to a height its extrusion width can't support - that + // trips Flow::with_spacing. + range_config.set_key_value("layer_height", new ConfigOptionFloat(cfg.opt_float("layer_height"))); + obj->layer_config_ranges[{4.0, 10.0}].assign_config(std::move(range_config)); + + print.is_BBL_printer() = is_bbl; + obj->ensure_on_bed(); + print.auto_assign_extruders(obj); + print.apply(model, cfg); + print.validate(); + + // Process + export to a temp file, then read it back (the app's own export path is where the + // custom *_gcode placeholders expand). + print.set_status_silent(); + print.process(); + const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca-validate-%%%%-%%%%.gcode"); + print.export_gcode(tmp.string(), nullptr, nullptr); + std::ifstream in(tmp.string()); + std::string out((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + in.close(); + boost::system::error_code ec; + fs::remove(tmp, ec); + return out; +} + +// Select the printer's OWN default process + filament (as the app does on a printer change) so we +// slice with settings the printer actually ships, not the generic "Default Setting" that stays +// selected because it is compatible with every printer. +void select_printer_default_presets(PresetBundle &bundle) +{ + const Preset &printer_preset = bundle.printers.get_selected_preset(); + const std::string def_print = printer_preset.config.opt_string("default_print_profile"); + if (!def_print.empty()) + bundle.prints.select_preset_by_name(def_print, /*force=*/true); + if (const auto *def_fil = printer_preset.config.option("default_filament_profile"); + def_fil != nullptr && !def_fil->values.empty()) + bundle.filaments.select_preset_by_name(def_fil->values.front(), /*force=*/true); +} + +// The vendor/printer currently being sliced, stamped onto every engine log record by the sink below so +// the interleaved [error] lines can be attributed to a profile. Updated once per loop iteration; safe as +// a plain global because the sweep is single-threaded and synchronous (see slice_all_printers). +static std::string g_slice_context; + +// Route Boost.Log through a sink that prefixes every record with g_slice_context. Without this the +// engine's [error] lines (emitted deep inside process()/export_gcode()) carry no printer context, so a +// failing profile cannot be told apart from the ~1000 others in the sweep. Drops the default trivial +// sink's timestamp/thread columns - noise here - in favour of the vendor/printer tag. +void install_slice_context_log_sink() +{ + namespace logging = boost::log; + namespace sinks = boost::log::sinks; + namespace expr = boost::log::expressions; + + auto backend = boost::make_shared(); + backend->add_stream(boost::shared_ptr(&std::clog, boost::null_deleter())); + backend->auto_flush(true); + + auto sink = boost::make_shared>(backend); + sink->set_formatter([](const logging::record_view &rec, logging::formatting_ostream &strm) { + strm << "[" << rec[logging::trivial::severity] << "]"; + if (!g_slice_context.empty()) + strm << " [" << g_slice_context << "]"; + strm << " " << rec[expr::smessage]; + }); + + logging::core::get()->remove_all_sinks(); // drop the default trivial sink so lines are not doubled + logging::core::get()->add_sink(sink); +} + +// Slice-and-export a two-colour cube through every shipped printer (optionally scoped to one vendor via +// -v). Unlike the static reference/placeholder checks, this expands every custom *_gcode - including +// change_filament_gcode at the one filament change - against the printer's fully-resolved config, so +// undefined-placeholder / invalid-flow bugs surface here. Reports every offending printer and returns 1 +// if any failed, 0 otherwise. The sweep is SEQUENTIAL by necessity: Print::process() keeps +// process-global state, so slicing printers concurrently in one process races even with per-slice +// Model+Print. Load in validation mode so the vendors are read straight from the -p profiles dir +// (no data_dir/system tree) and -v scoping is honoured for free. +int slice_all_printers(const std::string &vendor) +{ + install_slice_context_log_sink(); + + PresetBundle bundle; + bundle.set_is_validation_mode(true); + bundle.set_vendor_to_validate(vendor); // empty == all vendors + AppConfig app_config; + app_config.set("preset_folder", "default"); + try { + bundle.load_presets(app_config, ForwardCompatibilitySubstitutionRule::Disable); + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(error) << ex.what(); + std::cout << "Validation failed" << std::endl; + return 1; + } + + // Enable every instantiable model/variant in AppConfig - system printers are hidden until enabled; + // without this select_preset_by_name silently falls back to the "Default Printer". + std::vector> printers; // (vendor name, preset name) + for (const Preset &p : bundle.printers.get_presets()) { + if (p.vendor == nullptr) continue; // skips the Default Printer + const std::string model = p.config.opt_string("printer_model"); + const std::string variant = p.config.opt_string("printer_variant"); + if (model.empty() || variant.empty()) continue; // skip non-instantiable base/common configs + app_config.set_variant(p.vendor->id, model, variant, true); + printers.push_back({p.vendor->name, p.name}); + } + bundle.load_installed_printers(app_config); + + if (printers.empty()) { + BOOST_LOG_TRIVIAL(error) << "No instantiable printer presets found" + << (vendor.empty() ? "" : " for vendor " + vendor); + std::cout << "Validation failed" << std::endl; + return 1; + } + std::cout << "Slicing " << printers.size() << " printer preset(s)" + << (vendor.empty() ? "" : " for vendor " + vendor) << "..." << std::endl; + + int failures = 0; + for (const auto &[vendor_name, printer] : printers) { + g_slice_context = vendor_name + " / " + printer; // tag every engine log line from this slice + const bool selected = bundle.printers.select_preset_by_name(printer, /*force=*/true); + if (!selected || bundle.printers.get_selected_preset_name() != printer) { + BOOST_LOG_TRIVIAL(error) << "Printer preset \"" << printer << "\" could not be selected"; + ++failures; + continue; + } + + select_printer_default_presets(bundle); // slice with the printer's shipped process/filament + bundle.update_multi_material_filament_presets(); // size filament_presets to nozzle count + bundle.update_compatible(PresetSelectCompatibleType::Always); + + // Never slice with a generic default preset - that would validate stand-in settings, not the + // real profile (a legit per-profile error). + if (bundle.prints.get_selected_preset().is_default || bundle.filaments.get_selected_preset().is_default) { + BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" fell back to a default preset (process=\"" + << bundle.prints.get_selected_preset_name() << "\", filament=\"" + << bundle.filaments.get_selected_preset_name() << "\")"; + ++failures; + continue; + } + + // Grow to a 2nd filament so the cube can change colour; never shrink a multi-nozzle printer + // below its nozzle count, or full_config()'s flush-volume matrix no longer matches validate(). + const size_t nozzles = bundle.printers.get_selected_preset().config.option("nozzle_diameter")->size(); + bundle.set_num_filaments((unsigned int) std::max(2, nozzles)); + + // Mirror the app's manual filament->nozzle assignment for a multi-nozzle BBL printer: put each + // filament on its own nozzle and pin the map (fmmManual) so full_config() collapses every filament to + // the variant of the nozzle it actually prints from, and the engine keeps that assignment instead of + // auto-remapping it during process(). Without this the synthetic 2nd filament keeps nozzle 1's variant + // while the auto map moves it to nozzle 2 - harmless, but on the one printer whose nozzles differ in + // type (Direct Drive + Bowden) the mismatched lookup spams [error] lines. Single-nozzle and non-BBL + // printers keep the default map (their toolchange rides the AMS/tool-changer path unchanged). + const bool pin_filament_map = bundle.is_bbl_vendor() && nozzles > 1; + if (pin_filament_map) { + auto &fmap = bundle.project_config.option("filament_map", true)->values; + for (size_t i = 0; i < fmap.size(); ++i) + fmap[i] = int(i % nozzles) + 1; + } + + DynamicPrintConfig cfg = bundle.full_config(); + cfg.set_key_value("enable_prime_tower", new ConfigOptionBool(true)); // force a purge tower so the change is detectable + // The map above drives full_config()'s per-filament variant collapse; fmmManual on the sliced config + // stops process() from auto-remapping filaments back onto a different nozzle (which would re-introduce + // the variant mismatch this pinning avoids). + if (pin_filament_map) + cfg.set_key_value("filament_map_mode", new ConfigOptionEnum(fmmManual)); + + // full_config() grows filament_extruder_variant to one entry per filament, but because the synthetic + // 2nd filament is a duplicate of the first (set_num_filaments copies the same preset), it leaves + // filament_self_index at size 1. That makes update_values_to_printer_extruders_for_multiple_filaments + // fail to resolve the 2nd filament's variant - a benign fallback that spams [error] lines. A real + // 2-colour project ships filament_self_index = 1,2,...; mirror that so the sweep log stays clean. The + // slice output is unaffected: the duplicated filament's per-variant values are identical to the first. + if (auto *variants = cfg.option("filament_extruder_variant")) { + auto &self_index = cfg.option("filament_self_index", true)->values; + if (self_index.size() != variants->size()) { + self_index.resize(variants->size()); + for (size_t i = 0; i < self_index.size(); ++i) + self_index[i] = int(i) + 1; + } + } + + try { + const std::string out = slice_two_color_cube_and_export(cfg, bundle.is_bbl_vendor()); + if (out.empty() || out.find("G1") == std::string::npos) { + BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" produced no g-code"; + ++failures; + } else if (out.find("CP TOOLCHANGE START") == std::string::npos) { + // The filament change never rode the tower, so change_filament_gcode was not exercised. + BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer + << "\" sliced but the filament change never fired (no CP TOOLCHANGE START)"; + ++failures; + } + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" failed to slice: " << ex.what(); + ++failures; + } + } + g_slice_context.clear(); + + if (failures > 0) { + std::cout << failures << " of " << printers.size() << " printer preset(s) failed to slice" << std::endl; + std::cout << "Validation failed" << std::endl; + return 1; + } + std::cout << "All " << printers.size() << " printer preset(s) sliced successfully" << std::endl; + std::cout << "Validation completed successfully" << std::endl; + return 0; +} + +} // namespace + int main(int argc, char* argv[]) { po::options_description desc("Orca Profile Validator\nUsage"); @@ -95,6 +363,7 @@ int main(int argc, char* argv[]) #endif ("vendor,v", po::value()->default_value(""), "Vendor name. Optional, all profiles present in the folder will be validated if not specified") ("generate_presets,g", po::value()->default_value(false), "Generate user presets for mock test") + ("slice,s", po::bool_switch()->default_value(false), "Slice a two-colour cube through every printer to expand all custom g-code (catches placeholder/flow errors that static checks miss). Off unless this flag is present.") ("check_filament_subtypes,f", po::bool_switch()->default_value(false), "Also flag printers with duplicate (ambiguous) filament subtypes. Off unless this flag is present.") ("log_level,l", po::value()->default_value(2), "Log level. Optional, default is 2 (warning). Higher values produce more detailed logs."); // clang-format on @@ -119,6 +388,7 @@ int main(int argc, char* argv[]) std::string vendor = vm["vendor"].as(); int log_level = vm["log_level"].as(); bool generate_user_preset = vm["generate_presets"].as(); + bool slice_mode = vm["slice"].as(); bool check_filament_subtypes = vm["check_filament_subtypes"].as(); // check if path is valid, and return error if not @@ -132,6 +402,12 @@ int main(int argc, char* argv[]) // std::cout<<"log_level: "</profiles, so point resources_dir() at that + // parent. Without this, resources_dir() is empty and slice mode's HRC lookup + // (info/nozzle_info.json) resolves to a non-existent relative path and falls back to a + // built-in table (logging a spurious parse error and dropping the E3D entry). + if (fs::exists(fs::path(path).parent_path() / "info")) + set_resources_dir(fs::path(path).parent_path().string()); auto user_dir = fs::path(Slic3r::data_dir()) / PRESET_USER_DIR; user_dir.make_preferred(); @@ -139,6 +415,12 @@ int main(int argc, char* argv[]) fs::create_directory(user_dir); set_logging_level(log_level); + + // Slice mode expands every printer's custom g-code by actually slicing (see slice_all_printers). + // A distinct opt-in mode so the default static checks stay fast for every profile PR. + if (slice_mode) + return slice_all_printers(vendor); + auto preset_bundle = new PresetBundle(); // preset_bundle->setup_directories(); preset_bundle->set_is_validation_mode(true); diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index b5d12fe50f..4691af7e0e 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -6551,8 +6551,11 @@ void GCodeProcessor::process_T(const std::string_view command, int nozzle_id) if (command.length() > 1) { if (eid < 0 || eid > 254) { //BBS: T255, T1000 and T1100 is used as special command for BBL machine and does not cost time. return directly + // Orca: T1001 (hotend-type detection) and T65535/T65279 (AMS unload virtual-tool selects, paired with + // M620/M621 S65535/S65279) are firmware opcodes emitted verbatim by BBL machine start/end g-code, not + // real tool changes - whitelist them so the time estimator stops flagging these valid lines. if ((m_flavor == gcfMarlinLegacy || m_flavor == gcfMarlinFirmware) && (command == "Tx" || command == "Tc" || command == "T?" || - eid == 1000 || eid == 1100 || eid == 255)) + eid == 1000 || eid == 1100 || eid == 255 || eid == 1001 || eid == 65279 || eid == 65535)) return; // T-1 is a valid gcode line for RepRap Firmwares (used to deselects all tools) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e0530e3df7..3cbdc25f5f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,13 +13,17 @@ endif() set(TEST_DATA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data) file(TO_NATIVE_PATH "${TEST_DATA_DIR}" TEST_DATA_DIR) +# Shipped vendor profiles, so tests can exercise the real machine/filament gcode. +set(PROFILES_DIR ${CMAKE_SOURCE_DIR}/resources/profiles) +file(TO_NATIVE_PATH "${PROFILES_DIR}" PROFILES_DIR) + include(CTest) include(Catch) set(CATCH_EXTRA_ARGS "" CACHE STRING "Extra arguments for catch2 test suites.") # Unknown if this still works and/or should be replaced with something else. add_library(test_common INTERFACE) -target_compile_definitions(test_common INTERFACE TEST_DATA_DIR=R"\(${TEST_DATA_DIR}\)" CATCH_CONFIG_FAST_COMPILE) +target_compile_definitions(test_common INTERFACE TEST_DATA_DIR=R"\(${TEST_DATA_DIR}\)" PROFILES_DIR=R"\(${PROFILES_DIR}\)" CATCH_CONFIG_FAST_COMPILE) target_include_directories(test_common INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) if (APPLE) diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index fe04fb1ae1..f75c886eca 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -680,3 +680,133 @@ SCENARIO("Prime-tower visits without a filament change do not advance the toolch } } } + +// --------------------------------------------------------------------------- +// Real-profile toolchange coverage, targeted. The all-vendors sweep in test_profile_slicing.cpp now +// slices a two-colour cube per printer, so it already expands every shipped change_filament_gcode with +// each printer's DEFAULT extruder variants (both the single-nozzle append_tcr and dual-nozzle set_extruder +// paths). What that sweep can't reach is a variant-conditional branch the defaults never select — H2D's +// change gcode has an `== "Direct Drive TPU High Flow"` block. This scenario forces that branch by handing +// the extruders distinct kits, so an unregistered placeholder inside it still throws "Variable does not +// exist" here instead of only in the field. +// --------------------------------------------------------------------------- + +// Two 20mm cubes on separate extruders of a BBL machine, printed by object so exactly +// one real toolchange fires and drives the change_filament_gcode. Returns the g-code. +static std::string slice_two_object_bbl(DynamicPrintConfig &config) +{ + config.set_key_value("print_sequence", new ConfigOptionEnum(PrintSequence::ByObject)); + + Model model; + auto *obj1 = model.add_object(); + obj1->add_volume(cube(20)); + obj1->add_instance(); + auto *obj2 = model.add_object(); + obj2->add_volume(cube(20)); + obj2->add_instance(); + obj2->config.set_key_value("extruder", new ConfigOptionInt(2)); + + Print print; + print.is_BBL_printer() = true; + arrange_objects_on_test_bed(model, config); + for (auto *mo : model.objects) { + mo->ensure_on_bed(); + print.auto_assign_extruders(mo); + } + print.apply(model, config); + print.validate(); + print.set_status_silent(); + print.process(); + return Slic3r::Test::gcode(print); +} + +// The real change_filament_gcode of a shipped " 0.4 nozzle" machine profile. +static std::string shipped_change_filament_gcode(const std::string &printer) +{ + const std::string path = std::string(PROFILES_DIR) + "/BBL/machine/Bambu Lab " + printer + " 0.4 nozzle.json"; + DynamicPrintConfig config; + std::map key_values; + std::string reason; + config.load_from_json(path, ForwardCompatibilitySubstitutionRule::Enable, key_values, reason); + return config.opt_string("change_filament_gcode"); +} + +SCENARIO("Toolchange gcode resolves old/new_extruder_variant from printer_extruder_variant", "[GCodeWriter][H2C]") +{ + GIVEN("a BBL dual-extruder print whose change gcode reads the extruder-variant placeholders") { + DynamicPrintConfig config = dual_extruder_toolchange_config(); + // A distinctive variant that can only reach the g-code through printer_extruder_variant. + // Both entries carry it so the assertion is independent of which physical extruder the + // emitted change routes through. + config.set_key_value("printer_extruder_variant", + new ConfigOptionStrings({"Direct Drive TPU High Flow", "Direct Drive TPU High Flow"})); + config.set_key_value("change_filament_gcode", new ConfigOptionString( + "; VARIANT old={old_extruder_variant} new={new_extruder_variant}\nT[next_filament_id]\n")); + + WHEN("the print is sliced") { + const std::string gcode = slice_two_object_bbl(config); + THEN("both placeholders resolve to the printer_extruder_variant value") { + // The resolved line is the proof: an unresolved token or a parser throw would + // prevent this exact line from being emitted. (A negative "{token}" check is + // unreliable — the g-code's trailing config dump echoes the raw template.) + REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring( + "; VARIANT old=Direct Drive TPU High Flow new=Direct Drive TPU High Flow")); + } + } + } +} + +SCENARIO("Global current-tool placeholders resolve in a context with no local injection", "[GCodeWriter][H2C]") +{ + GIVEN("a BBL dual-extruder print whose before_layer_change_gcode reads the current-tool placeholders") { + DynamicPrintConfig config = dual_extruder_toolchange_config(); + // before_layer_change is one of the contexts that inject NO current_* into their local config + // (unlike change_filament / machine_end / layer_change), so these placeholders can only resolve + // through the GLOBAL parser vars published at each toolchange (and at the initial set_extruder). + // Pre-fix current_filament_id / current_extruder_id / current_nozzle_id were undefined here and the + // whole slice threw a PlaceholderParserError — the same failure mode X2D's layer_change hit. + config.set_key_value("before_layer_change_gcode", new ConfigOptionString( + "; GVAR fid={current_filament_id} eid={current_extruder_id} nid={current_nozzle_id}\n")); + + WHEN("the print is sliced (obj1 on filament 0, obj2 on filament 1)") { + std::string gcode; + REQUIRE_NOTHROW(gcode = slice_two_object_bbl(config)); + THEN("all three globals resolve to the CORRECT active-tool values on both sides of the change") { + // Assert the FULL resolved marker, not just no-throw: obj1 prints on filament 0 (extruder 0, + // nozzle 0) and obj2 on filament 1 (extruder 1, nozzle 1). Locking every field means a + // stale or wrong global (e.g. obj2 still reading fid=0, or a mismatched extruder/nozzle id) + // fails here — this is the value guard that replaces the old "throws on undefined" canary. + // Only the emitted before_layer_change lines carry resolved values; the trailing config dump + // keeps the raw "{current_filament_id}" template, so these are unambiguous. + REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("; GVAR fid=0 eid=0 nid=0")); + REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("; GVAR fid=1 eid=1 nid=1")); + } + } + } +} + +SCENARIO("Shipped dual-nozzle change_filament_gcode resolves during a real slice", "[GCodeWriter][H2C][Profiles]") +{ + const std::string printer = GENERATE(std::string("H2C"), std::string("H2D"), std::string("H2D Pro"), std::string("X2D")); + + GIVEN("the real " + printer + " change_filament_gcode driving a BBL dual-extruder slice") { + DynamicPrintConfig config = dual_extruder_toolchange_config(); + config.set_key_value("change_filament_gcode", new ConfigOptionString(shipped_change_filament_gcode(printer))); + // H2D's gcode branches on the extruder variant; give the extruders distinct kits so the + // "Direct Drive TPU High Flow" branch is reachable. + config.set_key_value("printer_extruder_variant", + new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive TPU High Flow"})); + // Extruder-indexed machine rates the stock gcode divides by (default size 1); size to 2 extruders. + config.set_key_value("hotend_cooling_rate", new ConfigOptionFloatsNullable({2.0, 2.0})); + config.set_key_value("hotend_heating_rate", new ConfigOptionFloatsNullable({2.0, 2.0})); + + THEN("every placeholder resolves (no undefined-variable throw) and the change block runs") { + std::string gcode; + REQUIRE_NOTHROW(gcode = slice_two_object_bbl(config)); + // A resolved marker only the emitted change block produces (the trailing config + // dump keeps the raw "{filament_type[...]}" template), so this confirms the real + // change_filament_gcode was expanded, not merely echoed. + REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("set_filament_type:PLA")); + } + } +}