Load profile includes so synced Bambu Lab presets get their G-code back (#15869)

# Description

This brings back the Bambu Lab profile sync with BambuStudio, reverted
from main, along with its bed-model offset fix. The synced printers and
filaments share their start and end G-code and dual-nozzle settings
through template files that Orca did not read, so those printers had no
machine G-code. Orca now loads these templates the same way BambuStudio
does, so every synced preset comes in complete. `profile_include_dump`,
a new developer tool, compares the result with BambuStudio's.

Other vendors' profiles are unaffected. Bambu Lab profiles move to
version 02.08.00.10, so installs that took the earlier sync update too.

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

# Screenshots/Recordings/Graphs

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

## Tests

Unit tests cover how template settings combine with inherited and preset
settings, loaded from JSON and from the preset cache. The profile
validator passes on every shipped vendor, and every Bambu Lab preset
gets the same template values in Orca as in BambuStudio.

<!--
> 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-09-25 13:17:43 +08:00
committed by GitHub
2618 changed files with 199595 additions and 264906 deletions
+34 -9
View File
@@ -1,11 +1,15 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cstdlib>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
#include "nlohmann/json.hpp"
#include "libslic3r/GCodeWriter.hpp"
#include "libslic3r/GCode.hpp"
#include "libslic3r/Model.hpp"
@@ -726,20 +730,41 @@ static std::string slice_two_object_bbl(DynamicPrintConfig &config)
}
// The real change_filament_gcode of a shipped "<printer> 0.4 nozzle" machine profile.
// The profile does not state it inline any more: it names the template carrying it in
// `include`, and the loader layers that template under the preset. Follow the same list
// - so a profile that stops naming one fails here instead of silently slicing G-code it
// no longer ships.
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";
const std::string machine_dir = std::string(PROFILES_DIR) + "/BBL/machine/";
const std::string machine_path = machine_dir + "Bambu Lab " + printer + " 0.4 nozzle.json";
const std::string template_name = "Bambu Lab " + printer + " 0.4 nozzle template change_filament_gcode";
// PROFILES_DIR is an absolute path baked in at build time; a sparse test checkout
// without resources/ leaves it missing. Skip rather than dereference a config that
// never loaded - this is the only fff_print test that reads a shipped profile.
if (!boost::filesystem::exists(path))
SKIP("shipped profile not present in this checkout: " << path);
DynamicPrintConfig config;
std::map<std::string, std::string> key_values;
std::string reason;
config.load_from_json(path, ForwardCompatibilitySubstitutionRule::Enable, key_values, reason);
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
INFO("profile: " << path << (reason.empty() ? "" : (" load reason: " + reason)));
if (!boost::filesystem::exists(machine_path))
SKIP("shipped profile not present in this checkout: " << machine_path);
auto load = [](const std::string &file, std::map<std::string, std::string> &key_values) {
DynamicPrintConfig config;
ConfigSubstitutionContext substitutions{ForwardCompatibilitySubstitutionRule::Enable};
std::string reason;
// false: `inherits` and `include` stay out of the config and land in key_values,
// for the loader to resolve - neither is a slicing setting.
config.load_from_json(file, substitutions, false, key_values, reason);
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
INFO("profile: " << file << (reason.empty() ? "" : (" load reason: " + reason)));
return config;
};
std::map<std::string, std::string> machine_values;
load(machine_path, machine_values);
REQUIRE(machine_values.count("include") == 1);
const nlohmann::json includes = nlohmann::json::parse(machine_values["include"]);
REQUIRE(std::find(includes.begin(), includes.end(), nlohmann::json(template_name)) != includes.end());
std::map<std::string, std::string> template_values;
const DynamicPrintConfig config = load(machine_dir + template_name + ".json", template_values);
REQUIRE(config.has("change_filament_gcode"));
return config.opt_string("change_filament_gcode");
}
+18
View File
@@ -490,6 +490,24 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("load_from_json hands a preset's include list to the caller instead of the config", "[Config]") {
ScopedTemporaryFile tmp(".json");
{
boost::nowide::ofstream ofs(tmp.string());
ofs << R"({"type":"machine","name":"P","instantiation":"true","include":["T start","T end"],"machine_end_gcode":"M84"})";
}
DynamicPrintConfig config;
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
std::map<std::string, std::string> key_values;
std::string reason;
REQUIRE(config.load_from_json(tmp.string(), substitutions, false, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(key_values["include"] == R"(["T start","T end"])");
CHECK_FALSE(config.has("include"));
CHECK(substitutions.unrecogized_keys.empty());
CHECK(config.opt_string("machine_end_gcode") == "M84");
}
TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
+104 -10
View File
@@ -53,41 +53,64 @@ void write_vendor_tree(const fs::path& dir, const std::string& vendor, const std
}
// A small but complete vendor: one machine model, one process, a non-instantiated
// base filament with an instantiated child inheriting it, a second standalone
// filament carrying explicit metadata, and one machine preset with a rename — so
// the equivalence test below sees every CachedPreset field populated.
void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version)
// base filament with an instantiated child that inherits it and includes a
// dual-extruder template, a second standalone filament carrying explicit
// metadata, and one machine preset with a rename that includes a G-code template
// — so the equivalence test below sees every CachedPreset field populated. The
// templates are listed before the presets that include them, as update-index
// orders them, or after them when `templates_last` asks for the broken order.
void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version,
bool templates_last = false)
{
fs::create_directories(dir / vendor / "process");
fs::create_directories(dir / vendor / "filament");
fs::create_directories(dir / vendor / "machine");
const std::string filament_template = R"({"name":")" + vendor + R"( dual template","sub_path":"filament/template.json"})";
const std::string filament_presets = R"({"name":")" + vendor + R"( Base PLA","sub_path":"filament/base.json"},)"
R"({"name":")" + vendor + R"( PLA @0.4","sub_path":"filament/pla.json"},)"
R"({"name":")" + vendor + R"( Silk PLA @0.4","sub_path":"filament/silk.json"})";
const std::string machine_template = R"({"name":")" + vendor + R"( 0.4 template machine_start_gcode","sub_path":"machine/start.json"})";
const std::string machine_presets = R"({"name":")" + vendor + R"( 0.4 nozzle","sub_path":"machine/printer.json"})";
std::ofstream((dir / (vendor + ".json")).string())
<< R"({"version":")" << version << R"(","name":")" << vendor << R"(",)"
<< R"("machine_model_list":[{"name":"Test Model","sub_path":"machine/model.json"}],)"
<< R"("process_list":[{"name":"0.20mm Standard @)" << vendor << R"(","sub_path":"process/standard.json"}],)"
<< R"("filament_list":[)"
<< R"({"name":")" << vendor << R"( Base PLA","sub_path":"filament/base.json"},)"
<< R"({"name":")" << vendor << R"( PLA @0.4","sub_path":"filament/pla.json"},)"
<< R"({"name":")" << vendor << R"( Silk PLA @0.4","sub_path":"filament/silk.json"}],)"
<< R"("machine_list":[{"name":")" << vendor << R"( 0.4 nozzle","sub_path":"machine/printer.json"}]})";
<< (templates_last ? filament_presets + "," + filament_template : filament_template + "," + filament_presets)
<< R"(],"machine_list":[)"
<< (templates_last ? machine_presets + "," + machine_template : machine_template + "," + machine_presets)
<< "]}";
std::ofstream((dir / vendor / "machine" / "model.json").string())
<< R"({"type":"machine_model","name":"Test Model","nozzle_diameter":"0.4"})";
std::ofstream((dir / vendor / "process" / "standard.json").string())
<< R"({"type":"process","name":"0.20mm Standard @)" << vendor
<< R"(","from":"system","instantiation":"true","layer_height":"0.2"})";
// The base sets two per-variant keys; the template restates one of them for
// two variants and adds a third; the child restates the third.
std::ofstream((dir / vendor / "filament" / "base.json").string())
<< R"({"type":"filament","name":")" << vendor
<< R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42"})";
<< R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42",)"
<< R"("activate_air_filtration":["1"],"filament_max_volumetric_speed":["12"]})";
std::ofstream((dir / vendor / "filament" / "template.json").string())
<< R"({"type":"filament","name":")" << vendor << R"( dual template","from":"system","instantiation":"false",)"
<< R"("filament_extruder_variant":["Direct Drive Standard","Direct Drive High Flow"],)"
<< R"("filament_max_volumetric_speed":["20","22"],"filament_flush_temp":["0","0"]})";
std::ofstream((dir / vendor / "filament" / "pla.json").string())
<< R"({"type":"filament","name":")" << vendor
<< R"( PLA @0.4","from":"system","instantiation":"true","filament_id":"GFA00","filament_cost":"20",)"
<< R"("setting_id":"GFSA04","description":"Test PLA description"})";
std::ofstream((dir / vendor / "filament" / "silk.json").string())
<< R"({"type":"filament","name":")" << vendor
<< R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA"})";
<< R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA",)"
<< R"("include":[")" << vendor << R"( dual template"],"filament_flush_temp":["5","5"]})";
// The template states two G-codes; the printer restates one of them.
std::ofstream((dir / vendor / "machine" / "start.json").string())
<< R"({"type":"machine","name":")" << vendor << R"( 0.4 template machine_start_gcode","from":"system",)"
<< R"("instantiation":"false","machine_start_gcode":"G28 ; template","machine_end_gcode":"M84 ; template"})";
std::ofstream((dir / vendor / "machine" / "printer.json").string())
<< R"({"type":"machine","name":")" << vendor
<< R"( 0.4 nozzle","from":"system","instantiation":"true","printer_model":"Test Model","printer_variant":"0.4",)"
<< R"("include":[")" << vendor << R"( 0.4 template machine_start_gcode"],"machine_end_gcode":"M84 ; own",)"
<< R"("renamed_from":")" << vendor << R"( old 0.4 nozzle"})";
}
@@ -598,6 +621,7 @@ TEST_CASE("a cache-loaded vendor is indistinguishable from a JSON-loaded one", "
const Preset* pr = from_cache.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->renamed_from == std::vector<std::string>{"Acme old 0.4 nozzle"});
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // through the include
}
TEST_CASE("a cache-served vendor reports the errors its parse counted", "[VendorCache]")
@@ -1618,3 +1642,73 @@ TEST_CASE("a stamp string with an absurd length is rejected, not allocated", "[V
CHECK(VendorCacheFile::peek_version(cache, "Evil").empty());
}
TEST_CASE("an included template's keys land on the preset, between the parent's and its own", "[VendorCache]")
{
InstallDirs dirs;
write_full_vendor_tree(dirs.system, "Acme", "1.0.0");
PresetBundle bundle;
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
CHECK(bundle.error_count() == 0);
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // from the include
CHECK(pr->config.opt_string("machine_end_gcode") == "M84 ; own"); // the preset's own key wins
CHECK(presets_for(bundle.printers, "Acme").size() == 1); // the template is no preset
const Preset* silk = bundle.filaments.find_preset("Acme Silk PLA @0.4", false);
REQUIRE(silk != nullptr);
const auto* speed = silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed");
REQUIRE(speed != nullptr);
CHECK(speed->values == std::vector<double>{20., 22.}); // the include wins over the parent
const auto* flush = silk->config.option<ConfigOptionInts>("filament_flush_temp");
REQUIRE(flush != nullptr);
CHECK(flush->values == std::vector<int>{5, 5}); // the preset's own key wins
// A per-variant key the template never mentions keeps the parent's value.
// The loader pads every base to its variant count; an include taken from the
// padded copy would carry the padded default [0,0] over the parent's 1.
const auto* air = silk->config.option<ConfigOptionBools>("activate_air_filtration");
REQUIRE(air != nullptr);
CHECK(air->values == std::vector<unsigned char>{1, 1});
CHECK(presets_for(bundle.filaments, "Acme").size() == 2); // the template is no preset
}
TEST_CASE("an include listed after the preset that names it is an error, and the preset loads without it", "[VendorCache]")
{
InstallDirs dirs;
write_full_vendor_tree(dirs.system, "Acme", "1.0.0", /*templates_last=*/true);
PresetBundle bundle;
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
// One error per unresolved include: the printer's and the filament's.
CHECK(bundle.error_count() == 2);
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->config.opt_string("machine_start_gcode") != "G28 ; template");
const Preset* silk = bundle.filaments.find_preset("Acme Silk PLA @0.4", false);
REQUIRE(silk != nullptr);
CHECK(silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>{12.});
}
TEST_CASE("a G-code template that states no instantiation is included, not loaded as a preset", "[VendorCache]")
{
InstallDirs dirs;
write_full_vendor_tree(dirs.system, "Acme", "1.0.0");
const fs::path start = dirs.system / "Acme" / "machine" / "start.json";
SECTION("named as G-code") {
std::ofstream(start.string()) << R"({"type":"machine","name":"Acme 0.4 template machine_start_gcode","from":"system",)"
<< R"("machine_start_gcode":"G28 ; template"})";
}
SECTION("not named, so included by its name in the vendor index") {
std::ofstream(start.string()) << R"({"type":"machine","from":"system","machine_start_gcode":"G28 ; template"})";
}
PresetBundle bundle;
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
CHECK(bundle.error_count() == 0);
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template");
CHECK(presets_for(bundle.printers, "Acme").size() == 1);
}