Add real slice validation for all printers (#14771)

# Description

Adds a --slice (-s) mode to the profile validator that slices a
two-colour cube through every shipped printer, expanding all custom
g-code (change_filament_gcode, machine start/end, etc.). This catches
invalid-placeholder / bad-flow / slicing errors that the static JSON
checks and unit tests can't see.

Included:
- Validator: new -s sweep mode; per-profile error attribution in the
log; resolves the synthetic 2nd-filament nozzle-mapping so multi-nozzle
BBL printers (incl. the Direct-Drive+Bowden X2D) validate cleanly.
- CI, two complementary paths:
- check_profiles.yml — runs the sweep on profile-only PRs (nightly
binary).
- build_all.yml — new parallel slice_check_linux job runs it on
engine/src PRs with the PR-built binary (build_all doesn't trigger on
resources/**, so no overlap). Runs off the build's artifact, so it
doesn't lengthen the build leg.
- Profile fixes surfaced by the sweep: Creality, FLSun, Ginger, Qidi,
RatRig, iQ.
- Engine: whitelist BBL firmware T-opcodes (T1001/T65279/T65535) in the
time estimator (log-only, no g-code change); dedupe a
per-filament/per-layer log flood in get_config_index.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
SoftFever
2026-07-15 16:32:09 +08:00
committed by GitHub
46 changed files with 1462 additions and 454 deletions

View File

@@ -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 <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp>
#include <boost/log/core.hpp>
#include <boost/log/expressions.hpp>
#include <boost/log/sinks/sync_frontend.hpp>
#include <boost/log/sinks/text_ostream_backend.hpp>
#include <boost/core/null_deleter.hpp>
#include <boost/make_shared.hpp>
#include <boost/program_options.hpp>
#include <algorithm>
#include <fstream>
#include <iostream>
#include <string>
@@ -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<ConfigOptionPoints>("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<char>(in)), std::istreambuf_iterator<char>());
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<ConfigOptionStrings>("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<sinks::text_ostream_backend>();
backend->add_stream(boost::shared_ptr<std::ostream>(&std::clog, boost::null_deleter()));
backend->auto_flush(true);
auto sink = boost::make_shared<sinks::synchronous_sink<sinks::text_ostream_backend>>(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<std::pair<std::string, std::string>> 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<ConfigOptionFloats>("nozzle_diameter")->size();
bundle.set_num_filaments((unsigned int) std::max<size_t>(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<ConfigOptionInts>("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<FilamentMapMode>(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<ConfigOptionStrings>("filament_extruder_variant")) {
auto &self_index = cfg.option<ConfigOptionInts>("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<std::string>()->default_value(""), "Vendor name. Optional, all profiles present in the folder will be validated if not specified")
("generate_presets,g", po::value<bool>()->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<int>()->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<std::string>();
int log_level = vm["log_level"].as<int>();
bool generate_user_preset = vm["generate_presets"].as<bool>();
bool slice_mode = vm["slice"].as<bool>();
bool check_filament_subtypes = vm["check_filament_subtypes"].as<bool>();
// check if path is valid, and return error if not
@@ -132,6 +402,12 @@ int main(int argc, char* argv[])
// std::cout<<"log_level: "<<log_level<<std::endl;
set_data_dir(path);
// Orca: the profiles folder lives at <resources>/profiles, so point resources_dir() at that
// <resources> 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);

View File

@@ -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)

View File

@@ -3677,6 +3677,8 @@ int Print::get_filament_config_indx(int filament_id, int layer_id)
void Print::update_filament_self_index_cache()
{
m_missing_nozzle_group_logged.clear(); // reset the per-slice get_config_index log dedupe
std::vector<int> values;
if (m_full_print_config.has("filament_self_index")) {
values = m_full_print_config.option<ConfigOptionInts>("filament_self_index")->values;
@@ -3722,9 +3724,12 @@ int Print::get_config_index(int filament_id, int layer_id, const std::vector<std
return filament_id;
auto nozzle_info = group_result->get_nozzle_for_filament(filament_id, layer_id);
if (!nozzle_info.has_value()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__
<< boost::format(", Line %1%: could not found group_nozzle_info corresponding to filament_id %2%, layer_id %3%") % __LINE__ % filament_id %
layer_id;
// Orca: this fallback runs per-filament/per-layer in the g-code hot path — log once per filament
// (reset each slice) instead of flooding thousands of identical lines that bury the real error.
if (m_missing_nozzle_group_logged.insert(filament_id).second)
BOOST_LOG_TRIVIAL(error) << __FUNCTION__
<< boost::format(", Line %1%: could not found group_nozzle_info corresponding to filament_id %2%, layer_id %3% (further occurrences for this filament suppressed)") % __LINE__ % filament_id %
layer_id;
return 0;
}
@@ -3751,9 +3756,12 @@ int Print::get_config_index(int filament_id, int layer_id, const std::vector<std
return (int)get_extruder_id(filament_id);
auto nozzle_info = group_result->get_nozzle_for_filament(filament_id, layer_id);
if (!nozzle_info.has_value()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__
<< boost::format(", Line %1%: could not found group_nozzle_info corresponding to filament_id %2%, layer_id %3%") % __LINE__ % filament_id %
layer_id;
// Orca: this fallback runs per-filament/per-layer in the g-code hot path — log once per filament
// (reset each slice) instead of flooding thousands of identical lines that bury the real error.
if (m_missing_nozzle_group_logged.insert(filament_id).second)
BOOST_LOG_TRIVIAL(error) << __FUNCTION__
<< boost::format(", Line %1%: could not found group_nozzle_info corresponding to filament_id %2%, layer_id %3% (further occurrences for this filament suppressed)") % __LINE__ % filament_id %
layer_id;
return 0;
}

View File

@@ -1303,6 +1303,10 @@ private:
FilamentIndexMap m_filament_index_map;
// Used to cache printer and process parameter information
PrintIndexMap m_nozzle_index_map;
// Orca: filament ids already reported as missing a nozzle-group entry this slice. get_config_index()
// falls back per-filament/per-layer in the g-code hot path, so this dedupes its log to once per
// filament instead of flooding thousands of identical error lines. Cleared with the caches each slice.
std::set<int> m_missing_nozzle_group_logged;
// save the config value of "filament_self_index"
std::vector<int> m_filament_self_index;

View File

@@ -22,7 +22,7 @@ struct Params
: /*max_acceleration(max_acceleration), */raft_layers_count(raft_layers_count), brim_type(brim_type), brim_width(brim_width)
{
if (filament_types.size() > 1) {
BOOST_LOG_TRIVIAL(warning)
BOOST_LOG_TRIVIAL(debug)
<< "SupportSpotsGenerator does not currently handle different materials properly, only first will be used";
}
if (filament_types.empty() || filament_types[0].empty()) {