Merge branch 'main' into feat/plugin-feature

This commit is contained in:
SoftFever
2026-07-15 17:19:14 +08:00
7 changed files with 107 additions and 45 deletions

View File

@@ -172,17 +172,17 @@ jobs:
# binary); this covers src/engine PRs with the PR-built binary. Runs in # binary); this covers src/engine PRs with the PR-built binary. Runs in
# parallel off build_linux's artifact so it doesn't lengthen the build leg. # parallel off build_linux's artifact so it doesn't lengthen the build leg.
slice_check_linux: slice_check_linux:
name: Slice check (Linux x86_64) name: Slice check (Linux aarch64)
needs: build_linux needs: build_linux
if: ${{ !cancelled() && success() }} if: ${{ !cancelled() && success() }}
runs-on: ${{ vars.SELF_HOSTED && 'orca-lnx-server' || 'ubuntu-24.04' }} runs-on: ubuntu-24.04-arm
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Download profile validator - name: Download profile validator
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: ${{ github.sha }}-profile-validator-linux-x86_64 name: ${{ github.sha }}-profile-validator-linux-aarch64
path: validator-bin path: validator-bin
- name: Validate slice (expand custom g-code) - name: Validate slice (expand custom g-code)
timeout-minutes: 60 timeout-minutes: 60

View File

@@ -515,13 +515,15 @@ jobs:
# Ship the freshly-built validator so the parallel slice_check_linux job # Ship the freshly-built validator so the parallel slice_check_linux job
# (build_all.yml) can slice-sweep the shipped profiles with this PR's # (build_all.yml) can slice-sweep the shipped profiles with this PR's
# engine. Stable sha-based name mirrors the tests artifact above so the # engine. Taken from the aarch64 leg so the sweep runs on arm64 (its
# GitHub-hosted runner is free, and it also exercises the arm build).
# Stable sha-based name mirrors the tests artifact above so the
# downstream job downloads it by exact name. # downstream job downloads it by exact name.
- name: Upload profile validator (for slice check) - name: Upload profile validator (for slice check)
if: runner.os == 'Linux' && inputs.arch != 'aarch64' if: runner.os == 'Linux' && inputs.arch == 'aarch64'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: ${{ github.sha }}-profile-validator-linux-x86_64 name: ${{ github.sha }}-profile-validator-linux-aarch64
overwrite: true overwrite: true
path: ./build/src/Release/OrcaSlicer_profile_validator path: ./build/src/Release/OrcaSlicer_profile_validator
retention-days: 5 retention-days: 5

View File

@@ -212,14 +212,26 @@ void install_slice_context_log_sink()
// -v). Unlike the static reference/placeholder checks, this expands every custom *_gcode - including // -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 // 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 // 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 // if any failed, 0 otherwise. When outdir is non-empty, each printer's g-code is also written there as
// process-global state, so slicing printers concurrently in one process races even with per-slice // "<vendor>__<printer>.gcode" for manual inspection. The sweep is SEQUENTIAL by necessity:
// Model+Print. Load in validation mode so the vendors are read straight from the -p profiles dir // Print::process() keeps process-global state, so slicing printers concurrently in one process races
// (no data_dir/system tree) and -v scoping is honoured for free. // even with per-slice Model+Print. Load in validation mode so the vendors are read straight from the -p
int slice_all_printers(const std::string &vendor) // profiles dir (no data_dir/system tree) and -v scoping is honoured for free.
int slice_all_printers(const std::string &vendor, const std::string &outdir)
{ {
install_slice_context_log_sink(); install_slice_context_log_sink();
if (!outdir.empty()) {
boost::system::error_code ec;
fs::create_directories(outdir, ec);
if (ec) {
BOOST_LOG_TRIVIAL(error) << "Could not create output directory \"" << outdir << "\": " << ec.message();
std::cout << "Validation failed" << std::endl;
return 1;
}
std::cout << "Saving sliced g-code to " << outdir << std::endl;
}
PresetBundle bundle; PresetBundle bundle;
bundle.set_is_validation_mode(true); bundle.set_is_validation_mode(true);
bundle.set_vendor_to_validate(vendor); // empty == all vendors bundle.set_vendor_to_validate(vendor); // empty == all vendors
@@ -323,6 +335,10 @@ int slice_all_printers(const std::string &vendor)
try { try {
const std::string out = slice_two_color_cube_and_export(cfg, bundle.is_bbl_vendor()); const std::string out = slice_two_color_cube_and_export(cfg, bundle.is_bbl_vendor());
if (!outdir.empty() && !out.empty()) {
const fs::path f = fs::path(outdir) / (sanitize_filename(vendor_name) + "__" + sanitize_filename(printer) + ".gcode");
save_string_file(f, out);
}
if (out.empty() || out.find("G1") == std::string::npos) { if (out.empty() || out.find("G1") == std::string::npos) {
BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" produced no g-code"; BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" produced no g-code";
++failures; ++failures;
@@ -364,6 +380,7 @@ int main(int argc, char* argv[])
("vendor,v", po::value<std::string>()->default_value(""), "Vendor name. Optional, all profiles present in the folder will be validated if not specified") ("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") ("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.") ("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.")
("outdir,o", po::value<std::string>()->default_value(""), "With -s, also save each printer's g-code to this folder (as <vendor>__<printer>.gcode) for manual inspection. Optional.")
("check_filament_subtypes,f", po::bool_switch()->default_value(false), "Also flag printers with duplicate (ambiguous) filament subtypes. 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."); ("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 // clang-format on
@@ -389,6 +406,7 @@ int main(int argc, char* argv[])
int log_level = vm["log_level"].as<int>(); int log_level = vm["log_level"].as<int>();
bool generate_user_preset = vm["generate_presets"].as<bool>(); bool generate_user_preset = vm["generate_presets"].as<bool>();
bool slice_mode = vm["slice"].as<bool>(); bool slice_mode = vm["slice"].as<bool>();
std::string slice_outdir = vm["outdir"].as<std::string>();
bool check_filament_subtypes = vm["check_filament_subtypes"].as<bool>(); bool check_filament_subtypes = vm["check_filament_subtypes"].as<bool>();
// check if path is valid, and return error if not // check if path is valid, and return error if not
@@ -419,7 +437,7 @@ int main(int argc, char* argv[])
// Slice mode expands every printer's custom g-code by actually slicing (see slice_all_printers). // 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. // A distinct opt-in mode so the default static checks stay fast for every profile PR.
if (slice_mode) if (slice_mode)
return slice_all_printers(vendor); return slice_all_printers(vendor, slice_outdir);
auto preset_bundle = new PresetBundle(); auto preset_bundle = new PresetBundle();
// preset_bundle->setup_directories(); // preset_bundle->setup_directories();

View File

@@ -352,8 +352,7 @@ namespace Slic3r
int k, int k,
const std::vector<unsigned int>& used_filaments, const std::vector<unsigned int>& used_filaments,
const std::unordered_map<int, std::vector<int>>& unplaceable_limits, const std::unordered_map<int, std::vector<int>>& unplaceable_limits,
int* cost, int* cost)
int timeout_ms)
{ {
auto distance_evaluator = std::make_shared<FlushDistanceEvaluator>(ctx.model_info.flush_matrix, used_filaments, ctx.model_info.layer_filaments); auto distance_evaluator = std::make_shared<FlushDistanceEvaluator>(ctx.model_info.flush_matrix, used_filaments, ctx.model_info.layer_filaments);
KMediods PAM(k, (int)used_filaments.size(), distance_evaluator, ctx.machine_info.master_extruder_id); KMediods PAM(k, (int)used_filaments.size(), distance_evaluator, ctx.machine_info.master_extruder_id);
@@ -369,7 +368,7 @@ namespace Slic3r
} }
PAM.set_cluster_group_size(cluster_size_limit); PAM.set_cluster_group_size(cluster_size_limit);
PAM.do_clustering(ctx, timeout_ms, 30); PAM.do_clustering(ctx, m_clustering_budget);
m_memoryed_heap = PAM.get_memoryed_groups(); m_memoryed_heap = PAM.get_memoryed_groups();
@@ -793,7 +792,7 @@ namespace Slic3r
2.1 In each cluster, make the point that minimizes the sum of distances within the cluster the medoid 2.1 In each cluster, make the point that minimizes the sum of distances within the cluster the medoid
2.2 Reassign each point to the cluster defined by the closest medoid determined in the previous step 2.2 Reassign each point to the cluster defined by the closest medoid determined in the previous step
*/ */
void KMediods::do_clustering(const FilamentGroupContext &context, int timeout_ms, int retry) void KMediods::do_clustering(const FilamentGroupContext& context, const ClusteringBudget& budget)
{ {
FlushTimeMachine T; FlushTimeMachine T;
T.time_machine_start(); T.time_machine_start();
@@ -817,7 +816,11 @@ namespace Slic3r
double best_cluster_cost = std::numeric_limits<double>::max(); double best_cluster_cost = std::numeric_limits<double>::max();
int retry_count = 0; int retry_count = 0;
while (retry_count < retry && T.time_machine_end() < timeout_ms) { // Run at least one restart; otherwise every filament would stay in the default group.
const int retry = std::max(1, budget.max_restarts);
auto within_budget = [&]() { return budget.timeout_ms <= 0 || T.time_machine_end() < budget.timeout_ms; };
while (retry_count < retry && within_budget()) {
std::vector<int> curr_cluster_centers = init_cluster_center(m_placeable_limits, m_unplaceable_limits, m_max_cluster_size, m_cluster_group_size, retry_count); std::vector<int> curr_cluster_centers = init_cluster_center(m_placeable_limits, m_unplaceable_limits, m_max_cluster_size, m_cluster_group_size, retry_count);
std::vector<int> curr_cluster_labels = assign_cluster_label(curr_cluster_centers, m_placeable_limits, m_unplaceable_limits, m_max_cluster_size, m_cluster_group_size); std::vector<int> curr_cluster_labels = assign_cluster_label(curr_cluster_centers, m_placeable_limits, m_unplaceable_limits, m_max_cluster_size, m_cluster_group_size);
double curr_cluster_cost = evaluate_labels(curr_cluster_labels); double curr_cluster_cost = evaluate_labels(curr_cluster_labels);
@@ -826,7 +829,7 @@ namespace Slic3r
update_memoryed_groups(g, memory_threshold, memoryed_groups); update_memoryed_groups(g, memory_threshold, memoryed_groups);
bool mediods_changed = true; bool mediods_changed = true;
while (mediods_changed && T.time_machine_end() < timeout_ms) { while (mediods_changed && within_budget()) {
mediods_changed = false; mediods_changed = false;
double best_swap_cost = curr_cluster_cost; double best_swap_cost = curr_cluster_cost;
int best_swap_cluster = -1; int best_swap_cluster = -1;
@@ -889,7 +892,7 @@ namespace Slic3r
if (estimated < ENUM_THRESHOLD) if (estimated < ENUM_THRESHOLD)
result = calc_group_by_enum(k, used_filaments, unplaceable_limits, cost); result = calc_group_by_enum(k, used_filaments, unplaceable_limits, cost);
else else
result = calc_group_by_kmedoids(k, used_filaments, unplaceable_limits, cost, 3000); result = calc_group_by_kmedoids(k, used_filaments, unplaceable_limits, cost);
change_memoryed_heaps_to_arrays(m_memoryed_heap, ctx.group_info.total_filament_num, used_filaments, m_memoryed_groups); change_memoryed_heaps_to_arrays(m_memoryed_heap, ctx.group_info.total_filament_num, used_filaments, m_memoryed_groups);

View File

@@ -142,6 +142,16 @@ namespace Slic3r
FilamentGroupContext::SpeedInfo m_speed_info; FilamentGroupContext::SpeedInfo m_speed_info;
}; };
// Search budget for the k-medoids clustering, an anytime search. Each restart is seeded from its
// own index, so what it returns depends on how many restarts complete before the clock expires,
// and therefore on the speed of the machine. A timeout_ms <= 0 removes the clock and bounds the
// search by max_restarts alone.
struct ClusteringBudget
{
int timeout_ms = 3000;
int max_restarts = 30;
};
class FilamentGroup class FilamentGroup
{ {
using MemoryedGroup = FilamentGroupUtils::MemoryedGroup; using MemoryedGroup = FilamentGroupUtils::MemoryedGroup;
@@ -149,6 +159,8 @@ namespace Slic3r
public: public:
explicit FilamentGroup(const FilamentGroupContext& ctx_) :ctx(ctx_) {} explicit FilamentGroup(const FilamentGroupContext& ctx_) :ctx(ctx_) {}
public: public:
void set_clustering_budget(const ClusteringBudget& budget) { m_clustering_budget = budget; }
std::vector<int> calc_filament_group(int * cost = nullptr); std::vector<int> calc_filament_group(int * cost = nullptr);
std::vector<std::vector<int>> get_memoryed_groups()const { return m_memoryed_groups; } std::vector<std::vector<int>> get_memoryed_groups()const { return m_memoryed_groups; }
@@ -162,7 +174,7 @@ namespace Slic3r
std::vector<int> calc_group_by_enum(int k, const std::vector<unsigned int>& used_filaments, std::vector<int> calc_group_by_enum(int k, const std::vector<unsigned int>& used_filaments,
const std::unordered_map<int, std::vector<int>>& unplaceable_limits, int* cost = nullptr); const std::unordered_map<int, std::vector<int>>& unplaceable_limits, int* cost = nullptr);
std::vector<int> calc_group_by_kmedoids(int k, const std::vector<unsigned int>& used_filaments, std::vector<int> calc_group_by_kmedoids(int k, const std::vector<unsigned int>& used_filaments,
const std::unordered_map<int, std::vector<int>>& unplaceable_limits, int* cost = nullptr, int timeout_ms = 500); const std::unordered_map<int, std::vector<int>>& unplaceable_limits, int* cost = nullptr);
std::map<int, int> rebuild_unprintables(const std::vector<unsigned int>& used_filaments, const std::map<int,int>& extruder_unprintables); std::map<int, int> rebuild_unprintables(const std::vector<unsigned int>& used_filaments, const std::map<int,int>& extruder_unprintables);
std::unordered_map<int, std::vector<int>> rebuild_nozzle_unprintables(const std::vector<unsigned int>& used_filaments, const std::unordered_map<int, std::vector<int>>& extruder_unprintables, const std::vector<int>& filament_volume_map); std::unordered_map<int, std::vector<int>> rebuild_nozzle_unprintables(const std::vector<unsigned int>& used_filaments, const std::unordered_map<int, std::vector<int>>& extruder_unprintables, const std::vector<int>& filament_volume_map);
@@ -175,6 +187,7 @@ namespace Slic3r
FilamentGroupContext ctx; FilamentGroupContext ctx;
MemoryedGroupHeap m_memoryed_heap; MemoryedGroupHeap m_memoryed_heap;
std::vector<std::vector<int>> m_memoryed_groups; std::vector<std::vector<int>> m_memoryed_groups;
ClusteringBudget m_clustering_budget;
public: public:
std::optional<std::function<bool(int, std::vector<int>&)>> get_custom_seq; std::optional<std::function<bool(int, std::vector<int>&)>> get_custom_seq;
}; };
@@ -220,7 +233,7 @@ namespace Slic3r
void set_memory_threshold(double threshold) { memory_threshold = threshold; } void set_memory_threshold(double threshold) { memory_threshold = threshold; }
MemoryedGroupHeap get_memoryed_groups()const { return memoryed_groups; } MemoryedGroupHeap get_memoryed_groups()const { return memoryed_groups; }
void do_clustering(const FilamentGroupContext& context, int timeout_ms = 100, int retry = 10); void do_clustering(const FilamentGroupContext& context, const ClusteringBudget& budget);
std::vector<int> get_cluster_labels()const { return m_cluster_labels; } std::vector<int> get_cluster_labels()const { return m_cluster_labels; }
protected: protected:

View File

@@ -210,13 +210,15 @@ inline FullEvalResult full_evaluate_map(const FilamentGroupContext& ctx,
return result; return result;
} }
inline TestResult run_and_evaluate(const FilamentGroupContext& ctx) { inline TestResult run_and_evaluate(const FilamentGroupContext& ctx,
const ClusteringBudget& budget = {}) {
TestResult result; TestResult result;
auto start = std::chrono::high_resolution_clock::now(); auto start = std::chrono::high_resolution_clock::now();
int algo_cost = 0; int algo_cost = 0;
FilamentGroup fg(ctx); FilamentGroup fg(ctx);
fg.set_clustering_budget(budget);
result.filament_map = fg.calc_filament_group(&algo_cost); result.filament_map = fg.calc_filament_group(&algo_cost);
auto end = std::chrono::high_resolution_clock::now(); auto end = std::chrono::high_resolution_clock::now();

View File

@@ -211,19 +211,23 @@ static std::vector<PropertySpec>& get_property_specs() {
return specs; return specs;
} }
// Orca: a small number of config_c "stress" goldens run the nozzle-centric kmedoids clustering path, // Under the default wall clock the result depends on how fast the machine is (see ClusteringBudget),
// which is bounded by a 3000 ms wall-clock budget (FilamentGroup.cpp calc_group_by_kmedoids). On // so the goldens are graded under a fixed budget instead. Two restarts is the fewest that reaches
// slower hardware the clustering explores fewer restarts and lands on a deterministically-worse-but- // parity with the reference on every golden, stress_79 being the last to get there. Four leaves
// valid grouping than the stored golden. We regression-lock those against Orca's own deterministic // margin, since the search follows a different path on each standard library (see below).
// score (bit-stable across runs on this machine — verified twice) so the gate stays green while the static constexpr ClusteringBudget FIXED_SEARCH_BUDGET{
// divergence is documented; every other golden is a true parity gate at 3% tolerance. /*timeout_ms*/ 0, // no wall clock
static std::optional<double> orca_locked_base_score(const std::string& stem) { /*max_restarts*/ 4};
if (stem == "stress_66") return 125103.0; // config_c 15-filament kmedoids case; golden 117843
return std::nullopt;
}
// ============ Layer 1: Golden Regression (all configs) ============ // ============ Layer 1: Golden Regression (all configs) ============
// Graded against the BambuStudio golden the harness was ported from, one-directional at 3%.
//
// The tolerance is a parity allowance, and it also covers a small spread across standard libraries.
// The k-medoids search seeds each restart with std::shuffle, whose algorithm the C++ standard leaves
// unspecified, so libstdc++, libc++ and the MSVC STL permute the same seed differently, start from
// different medoids, and settle on slightly different groupings, about 3e-4 apart on either side of
// the reference, and only on the goldens heavy enough to reach the k-medoids search.
TEST_CASE("FilamentGroup golden regression", "[filament_group][golden]") { TEST_CASE("FilamentGroup golden regression", "[filament_group][golden]") {
auto files = get_golden_files(); auto files = get_golden_files();
if (files.empty()) { if (files.empty()) {
@@ -238,29 +242,49 @@ TEST_CASE("FilamentGroup golden regression", "[filament_group][golden]") {
auto tc = load_test_case(file_path); auto tc = load_test_case(file_path);
REQUIRE(tc.base_result.has_value()); REQUIRE(tc.base_result.has_value());
auto result = run_and_evaluate(tc.context); auto result = run_and_evaluate(tc.context, FIXED_SEARCH_BUDGET);
auto eval = full_evaluate_map(tc.context, result.filament_map); auto eval = full_evaluate_map(tc.context, result.filament_map);
auto& base = *tc.base_result; auto& base = *tc.base_result;
// Reference score: the stored golden by default; Orca's deterministic score for the
// documented heuristic-divergent config_c stress golden (see orca_locked_base_score).
std::string stem = fs::path(file_path).stem().string();
double base_score = base.full_score;
if (auto locked = orca_locked_base_score(stem))
base_score = *locked;
INFO("Case: " << tc.metadata.id); INFO("Case: " << tc.metadata.id);
INFO("Reference score: " << base_score << " (BBS golden " << base.full_score << ")"); INFO("Golden score: " << base.full_score);
INFO("Actual score: " << eval.full_score); INFO("Actual score: " << eval.full_score);
INFO("Flush cost: " << eval.flush_cost << " (BBS golden " << base.flush_cost << ")"); INFO("Flush cost: " << eval.flush_cost << " (golden " << base.flush_cost << ")");
INFO("Elapsed: " << result.elapsed_ms << " ms"); INFO("Elapsed: " << result.elapsed_ms << " ms");
int tolerance = std::max(50, (int)(base_score * 0.03)); int tolerance = std::max(50, (int)(base.full_score * 0.03));
REQUIRE(result.constraints_ok); REQUIRE(result.constraints_ok);
REQUIRE(eval.full_score <= base_score + tolerance); REQUIRE(eval.full_score <= base.full_score + tolerance);
// RelWithDebInfo runaway guard; the Release-calibrated 20 s limit is raised for the slower build.
// A slower search still scores the same above, since it searches just as far, but in slicing
// it would mean fewer restarts fit in the wall clock and so worse groupings. Loose on
// purpose, so it never becomes a proxy for how loaded the runner is.
const double throughput_ceiling_ms = 10.0 * ClusteringBudget{}.timeout_ms;
REQUIRE(result.elapsed_ms < throughput_ceiling_ms);
}
}
// Covers the path real slicing takes, under the default wall clock. The score there depends on the
// runner rather than on the code (see FIXED_SEARCH_BUDGET), so the only things worth asserting are
// that the grouping comes back valid and that the search terminates.
TEST_CASE("FilamentGroup returns a valid grouping under the default budget", "[filament_group][budget]") {
auto files = get_golden_files();
REQUIRE(!files.empty());
auto file_path = GENERATE_REF(from_range(files));
DYNAMIC_SECTION("Golden: " << fs::path(file_path).stem().string()) {
auto tc = load_test_case(file_path);
auto result = run_and_evaluate(tc.context); // the default budget, as real slicing runs it
INFO("Case: " << tc.metadata.id);
INFO("Elapsed: " << result.elapsed_ms << " ms");
REQUIRE(result.constraints_ok);
// A hang guard. The clock is only checked between swaps, so a sweep can overshoot.
REQUIRE(result.elapsed_ms < 40000.0); REQUIRE(result.elapsed_ms < 40000.0);
} }
} }