mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-10 10:47:16 +00:00
Merge branch 'main' into feature/filament_id
Brings in 560 commits (merge base 2026-07-04 .. origin/main af9fd10d7a).
19 conflicts resolved; the other 138 touched files auto-merged.
Conflict resolutions
- Snapmaker/Polymaker (7): main normalised JSON key order in #15039 while this
branch inserted filament_id/filament_vendor. Took main's key order and kept
this branch's values, inserting a single filament_id key rather than letting
git's line merge leave duplicate "from"/"instantiation" keys.
- re3D rPETG @0.8/@1.75 nozzle (2): main renamed these from "re3D Greengate
rPETG @*" and re-vendored GreenGate3D -> re3D (#15169). Git's rename-aware
merge produced a file with two filament_vendor keys; took main's version
wholesale instead. The id is reconciled by the tooling, not by hand.
- re3D rPP (1): kept main's inherits change (fdm_filament_pet -> fdm_filament_pp,
local filament_type override dropped) and its widened compatible_printers.
The flattened type is still PP, so the product triple and id OFfHGM1D are
unchanged.
- re3D Greengate rPETG.json, Afinia {ABS+,ABS,PLA,TPU,Value ABS,Value PLA}.json
(7 modify/delete): accepted main's deletions. The Afinia ids are not orphaned
- the surviving @HS presets resolve the same triple and already carry the
same ids, so nothing is retired.
- .github/workflows/check_profiles.yml: kept both main's new "validate slice"
step and this branch's tree-wide filament-subtype check.
- tests/libslic3r/CMakeLists.txt: kept both test_filament_id_succession.cpp and
main's test_preset_diff.cpp.
Verified
- No conflict markers; all 12795 profile JSONs parse with no duplicate keys.
- All branch artifacts (tooling, snapshot, ledger, doc, tests) intact and
unmodified by the merge.
- The succession-ledger C++ call sites survived main's refactors; audited
Preset.{cpp,hpp}, PresetBundle.cpp, DeviceManager.cpp, PresetComboBoxes.cpp,
CaliHistoryDialog.cpp, MoonrakerPrinterAgent.cpp against base/ours/theirs.
- scripts/tests: 115/116 pass.
Known follow-up: assign_filament_ids.py --check reports 243 errors, all from
filament data main added in the last month (BBL/addnorth, Qidi X5/Plus 5,
Snapmaker U1, re3D). The single failing unit test is the live-tree conformance
test asserting that count is zero. Reconciliation lands separately.
This commit is contained in:
@@ -12,21 +12,30 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_clipper_offset.cpp
|
||||
test_clipper_utils.cpp
|
||||
test_config.cpp
|
||||
test_config_variant_expansion.cpp
|
||||
test_toolordering_nozzle_group.cpp
|
||||
test_preset_bundle_loading.cpp
|
||||
test_preset_setting_id.cpp
|
||||
test_filament_id_succession.cpp
|
||||
test_preset_diff.cpp
|
||||
test_elephant_foot_compensation.cpp
|
||||
test_fill_plane_path.cpp
|
||||
test_geometry.cpp
|
||||
test_multimaterial_segmentation.cpp
|
||||
test_placeholder_parser.cpp
|
||||
test_polygon.cpp
|
||||
test_mutable_polygon.cpp
|
||||
test_mutable_priority_queue.cpp
|
||||
test_nozzle_volume_type.cpp
|
||||
test_stl.cpp
|
||||
test_meshboolean.cpp
|
||||
test_marchingsquares.cpp
|
||||
test_model.cpp
|
||||
test_utils.cpp
|
||||
test_timeutils.cpp
|
||||
test_voronoi.cpp
|
||||
test_optimizers.cpp
|
||||
test_ordering_strategies.cpp
|
||||
# test_png_io.cpp
|
||||
test_indexed_triangle_set.cpp
|
||||
../libnest2d/printer_parts.cpp
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Format/3mf.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/Format/STL.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/ProjectTask.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
|
||||
@@ -103,8 +111,8 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
|
||||
src_object->instances.front()->set_transformation(src_instance_transform);
|
||||
|
||||
WHEN("model is saved+loaded to/from 3mf file") {
|
||||
// save the model to 3mf file
|
||||
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/prusa.3mf";
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
store_3mf(test_file.c_str(), &src_model, nullptr, false);
|
||||
|
||||
// load back the model from the 3mf file
|
||||
@@ -114,7 +122,6 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
|
||||
load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false);
|
||||
}
|
||||
boost::filesystem::remove(test_file);
|
||||
|
||||
// compare meshes
|
||||
TriangleMesh src_mesh = src_model.mesh();
|
||||
@@ -133,63 +140,360 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
|
||||
GIVEN("model") {
|
||||
// load a model
|
||||
// .3mf multi-nozzle round-trip.
|
||||
// Locks the load/save handling for the H2C multi-nozzle plate metadata:
|
||||
// * filament_volume_maps -> plate config "filament_volume_map" (with the >1 -> 0 clamp)
|
||||
// * nozzle_volume_type -> PlateData::nozzle_volume_types (previously write-only)
|
||||
// and pins the deliberately-lossy keys (enable_filament_dynamic_map) so a future change has to
|
||||
// consciously unpin them. Uses a store_bbs_3mf -> load_bbs_3mf cycle (no external fixture needed).
|
||||
SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a plate carrying multi-nozzle filament assignment metadata") {
|
||||
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();
|
||||
|
||||
WHEN("model is rotated, scaled and set as sinking") {
|
||||
ModelObject* object = model.objects[0];
|
||||
object->center_around_origin(false);
|
||||
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
|
||||
// point it at a writable temp dir (the default lives under a read-only root in CI).
|
||||
ScopedTemporaryDir backup_dir("orca_mn");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
// This outputs the same exact data as the Prusaslicer test
|
||||
object->volumes[0]->mesh().write_ascii("/tmp/orca.ascii");
|
||||
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
|
||||
// read-back is a meaningful assertion (High Flow == 1).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("nozzle_volume_type",
|
||||
new ConfigOptionEnumsGeneric({ (int) NozzleVolumeType::nvtHighFlow }));
|
||||
|
||||
// set instance's attitude so that it is rotated, scaled (and sinking? how is it sinking? the rotation? does it matter if it's sinking?)
|
||||
ModelInstance* instance = object->instances[0];
|
||||
instance->set_rotation(X, -M_PI / 4.0);
|
||||
instance->set_offset(Vec3d::Zero());
|
||||
instance->set_scaling_factor({ 2.0, 2.0, 2.0 });
|
||||
PlateData* plate = new PlateData();
|
||||
plate->plate_index = 0;
|
||||
plate->is_sliced_valid = true; // gate for the slice_info.config writer (nozzle_volume_type)
|
||||
plate->filament_maps = { 1, 2, 1 }; // slice_info uses this; keep it == model_settings' value
|
||||
plate->config.set_key_value("filament_map_mode", new ConfigOptionEnum<FilamentMapMode>(fmmManual));
|
||||
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
|
||||
// Deliberately include out-of-range volume-type ids (2 == Hybrid, 3 == TPU High Flow):
|
||||
// the loader must clamp them back to Standard (0).
|
||||
plate->config.set_key_value("filament_volume_map", new ConfigOptionInts({ 0, 2, 1, 3 }));
|
||||
// Known-lossy: a true value must NOT survive the round-trip (slice_info hardcodes false,
|
||||
// model_settings never writes it).
|
||||
plate->config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
|
||||
|
||||
// calculate 2D convex hull
|
||||
auto trafo = instance->get_transformation().get_matrix();
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
// This matrix is the same exact matrix as the Prusaslicer test
|
||||
CAPTURE(trafo);
|
||||
Polygon hull_2d = object->convex_hull_2d(trafo);
|
||||
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));
|
||||
|
||||
// But we get different hull_2d.points here (and somehow decimal numbers despite being int64_t values, but that's probabaly printing configuration somewhere -- Prusaslicer's prints out with newlines between the X&Y and not one between coordinates, which is about the worse possible output).
|
||||
// I think it's something to do with PrusaSlicer ignoring everything under the Z plane, which makes sense from the results.
|
||||
// See the comments added to ModelObject::convex_hull_2d for more information.
|
||||
Model dst_model;
|
||||
DynamicPrintConfig dst_config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs dst_plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
// LoadConfig is required for slice_info.config (nozzle_volume_type) to be parsed —
|
||||
// matches how the app loads projects.
|
||||
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);
|
||||
THEN("every multi-nozzle key round-trips as expected") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(dst_plates.size() >= 1);
|
||||
PlateData* rt = dst_plates.front();
|
||||
|
||||
// verify result
|
||||
Points result = {
|
||||
{ -91501496, -15914144 },
|
||||
{ 91501496, -15914144 },
|
||||
{ 91501496, 4243 },
|
||||
{ 78229680, 4246883 },
|
||||
{ 56898100, 4246883 },
|
||||
{ -85501496, 4242641 },
|
||||
{ -91501496, 4243 }
|
||||
};
|
||||
// filament_map (model_settings + slice_info; already round-tripped)
|
||||
auto* fmap = rt->config.option<ConfigOptionInts>("filament_map");
|
||||
REQUIRE(fmap != nullptr);
|
||||
REQUIRE(fmap->values == std::vector<int>({ 1, 2, 1 }));
|
||||
|
||||
THEN("2D convex hull should match with reference") {
|
||||
// Allow 1um error due to floating point rounding.
|
||||
bool res = hull_2d.points.size() == result.size();
|
||||
if (res) {
|
||||
for (size_t i = 0; i < result.size(); ++ i) {
|
||||
const Point &p1 = result[i];
|
||||
const Point &p2 = hull_2d.points[i];
|
||||
CHECK((std::abs(p1.x() - p2.x()) > 1 || std::abs(p1.y() - p2.y()) > 1));
|
||||
}
|
||||
}
|
||||
// filament_volume_map (model_settings) with the >1 -> 0 clamp
|
||||
auto* fvmap = rt->config.option<ConfigOptionInts>("filament_volume_map");
|
||||
REQUIRE(fvmap != nullptr);
|
||||
REQUIRE(fvmap->values == std::vector<int>({ 0, 0, 1, 0 }));
|
||||
|
||||
CAPTURE(hull_2d.points);
|
||||
REQUIRE(res);
|
||||
// nozzle_volume_type read-back into PlateData::nozzle_volume_types
|
||||
REQUIRE(rt->nozzle_volume_types == "1");
|
||||
|
||||
// enable_filament_dynamic_map pinned lossy: model_settings never serializes it and
|
||||
// slice_info hardcodes false, so the `true` we set is dropped. Pinned here
|
||||
// (absent or false, never true) so a future change that persists it must update this.
|
||||
auto* dyn = rt->config.option<ConfigOptionBool>("enable_filament_dynamic_map");
|
||||
const bool persisted_true = (dyn != nullptr && dyn->value);
|
||||
REQUIRE_FALSE(persisted_true);
|
||||
}
|
||||
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <filament>/<nozzle> 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();
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_nd");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
// 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") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
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<Preset*> 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);
|
||||
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();
|
||||
|
||||
// <nozzle> tag: device-facing per-nozzle diameter string, written verbatim.
|
||||
REQUIRE(rt->nozzles_info.size() >= 1);
|
||||
REQUIRE(rt->nozzles_info.front().diameter == "0.5");
|
||||
|
||||
// <filament> 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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]") {
|
||||
GIVEN("a project without any multi-nozzle metadata") {
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||
Model model;
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
|
||||
WHEN("loaded through the BBS importer") {
|
||||
bool loaded = false;
|
||||
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf,
|
||||
&file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
||||
THEN("it does not crash and invents no per-filament volume map") {
|
||||
for (PlateData* p : plates) {
|
||||
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||
}
|
||||
}
|
||||
release_PlateData_list(plates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device-side nozzle-grouping serialization surface.
|
||||
// Direct unit coverage for the pure serialize/deserialize + StaticNozzleGroupResult helpers that the
|
||||
// gcode.3mf writer/reader lean on.
|
||||
SCENARIO("MultiNozzle serialization helpers", "[3mf][MultiNozzle]") {
|
||||
using namespace Slic3r::MultiNozzleUtils;
|
||||
|
||||
GIVEN("NozzleInfo / NozzleGroupInfo") {
|
||||
NozzleInfo n0; n0.group_id = 0; n0.extruder_id = 0; n0.diameter = "0.4"; n0.volume_type = nvtStandard;
|
||||
NozzleInfo n1; n1.group_id = 1; n1.extruder_id = 1; n1.diameter = "0.4"; n1.volume_type = nvtHighFlow;
|
||||
|
||||
THEN("NozzleInfo::serialize matches the <nozzle> tag attributes (extruder_id 1-based)") {
|
||||
REQUIRE(n0.serialize() == "id=\"0\" extruder_id=\"1\" nozzle_diameter=\"0.4\" volume_type=\"Standard\"");
|
||||
REQUIRE(n1.serialize() == "id=\"1\" extruder_id=\"2\" nozzle_diameter=\"0.4\" volume_type=\"High Flow\"");
|
||||
}
|
||||
THEN("NozzleGroupInfo serialize/deserialize round-trips and rejects malformed input") {
|
||||
NozzleGroupInfo g("0.4", nvtHighFlow, 1, 3);
|
||||
REQUIRE(g.serialize() == "1-0.4-High Flow-3");
|
||||
auto rt = NozzleGroupInfo::deserialize(g.serialize());
|
||||
REQUIRE(rt.has_value());
|
||||
REQUIRE(*rt == g);
|
||||
REQUIRE_FALSE(NozzleGroupInfo::deserialize("1-0.4-Standard").has_value()); // too few tokens
|
||||
REQUIRE_FALSE(NozzleGroupInfo::deserialize("x-0.4-Standard-3").has_value()); // non-numeric extruder
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("a StaticNozzleGroupResult built from filament + nozzle infos") {
|
||||
std::vector<NozzleInfo> nozzles;
|
||||
{ NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = nvtStandard; nozzles.push_back(n); }
|
||||
{ NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = nvtHighFlow; nozzles.push_back(n); }
|
||||
|
||||
std::vector<FilamentInfo> filaments(3);
|
||||
filaments[0].id = 0; filaments[0].group_id = { 0 };
|
||||
filaments[1].id = 1; filaments[1].group_id = { 1 };
|
||||
filaments[2].id = 2; filaments[2].group_id = { 0, 1 };
|
||||
|
||||
auto result = StaticNozzleGroupResult::create(filaments, nozzles, { 0, 1, 2 }, { 0, 1, 0 }, false);
|
||||
REQUIRE(result.has_value());
|
||||
|
||||
THEN("filament->nozzle queries resolve to the stored mapping") {
|
||||
REQUIRE(result->get_extruder_count() == 2);
|
||||
REQUIRE(result->get_used_extruders() == std::vector<int>({ 0, 1 }));
|
||||
REQUIRE(result->get_used_filaments() == std::vector<unsigned int>({ 0, 1, 2 }));
|
||||
REQUIRE(result->get_nozzles_for_filament(0).size() == 1);
|
||||
REQUIRE(result->get_nozzles_for_filament(2).size() == 2);
|
||||
// first-use resolves through the (filament,nozzle) change sequences.
|
||||
auto first = result->get_first_nozzle_for_filament(1);
|
||||
REQUIRE(first.has_value());
|
||||
REQUIRE(first->group_id == 1);
|
||||
}
|
||||
THEN("empty inputs yield nullopt") {
|
||||
REQUIRE_FALSE(StaticNozzleGroupResult::create({}, nozzles, {}, {}, false).has_value());
|
||||
REQUIRE_FALSE(StaticNozzleGroupResult::create(filaments, {}, {}, {}, false).has_value());
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("load_nozzle_infos_with_compatibility fallbacks") {
|
||||
std::vector<NozzleInfo> new_format;
|
||||
{ NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = nvtHighFlow; new_format.push_back(n); }
|
||||
{ NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = nvtStandard; new_format.push_back(n); }
|
||||
|
||||
THEN("new-format <nozzle> tags are returned sorted by logical id") {
|
||||
auto out = load_nozzle_infos_with_compatibility(new_format, {}, {}, {}, {});
|
||||
REQUIRE(out.size() == 2);
|
||||
REQUIRE(out[0].group_id == 0);
|
||||
REQUIRE(out[1].group_id == 1);
|
||||
}
|
||||
THEN("oldest single-nozzle 3mf (no tags, no filament group_id) rebuilds from diameters/volume types") {
|
||||
std::vector<NozzleVolumeType> vt = { nvtStandard, nvtHighFlow };
|
||||
std::vector<double> dia = { 0.4, 0.4 };
|
||||
auto out = load_nozzle_infos_with_compatibility({}, {}, {}, vt, dia);
|
||||
REQUIRE(out.size() == 2);
|
||||
REQUIRE(out[0].extruder_id == 0);
|
||||
REQUIRE(out[0].volume_type == nvtStandard);
|
||||
REQUIRE(out[1].volume_type == nvtHighFlow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The layer-aware grouping result must survive the gcode.3mf write/read as
|
||||
// <nozzle> tags and the enable_filament_dynamic_map flag. Proves the parse_filament_info stamping,
|
||||
// the NOZZLE_TAG writer, the _handle_config_nozzle reader, and the nozzles_info plate copy.
|
||||
SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a plate carrying a two-nozzle LayeredNozzleGroupResult") {
|
||||
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();
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_ng");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
std::vector<MultiNozzleUtils::NozzleInfo> nozzles;
|
||||
{ MultiNozzleUtils::NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = NozzleVolumeType::nvtStandard; nozzles.push_back(n); }
|
||||
{ MultiNozzleUtils::NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = NozzleVolumeType::nvtHighFlow; nozzles.push_back(n); }
|
||||
auto group = MultiNozzleUtils::LayeredNozzleGroupResult::create(
|
||||
std::vector<int>{ 0, 1, 0 }, nozzles, std::vector<unsigned int>{ 0, 1, 2 });
|
||||
REQUIRE(group.has_value());
|
||||
|
||||
PlateData* plate = new PlateData();
|
||||
plate->plate_index = 0;
|
||||
plate->is_sliced_valid = true;
|
||||
plate->filament_maps = { 1, 2, 1 };
|
||||
plate->nozzle_group_result = group;
|
||||
plate->config.set_key_value("filament_map_mode", new ConfigOptionEnum<FilamentMapMode>(fmmManual));
|
||||
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
|
||||
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
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<Preset*> 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);
|
||||
THEN("the <nozzle> tags round-trip into the loaded plate's nozzles_info") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(dst_plates.size() >= 1);
|
||||
PlateData* rt = dst_plates.front();
|
||||
|
||||
REQUIRE(rt->nozzles_info.size() == 2);
|
||||
// reader stores extruder_id 0-based (tag is 1-based), diameter/volume_type preserved.
|
||||
std::sort(rt->nozzles_info.begin(), rt->nozzles_info.end());
|
||||
REQUIRE(rt->nozzles_info[0].group_id == 0);
|
||||
REQUIRE(rt->nozzles_info[0].extruder_id == 0);
|
||||
REQUIRE(rt->nozzles_info[0].diameter == "0.4");
|
||||
REQUIRE(rt->nozzles_info[0].volume_type == NozzleVolumeType::nvtStandard);
|
||||
REQUIRE(rt->nozzles_info[1].group_id == 1);
|
||||
REQUIRE(rt->nozzles_info[1].extruder_id == 1);
|
||||
REQUIRE(rt->nozzles_info[1].volume_type == NozzleVolumeType::nvtHighFlow);
|
||||
|
||||
// A static (non-selector) result must persist enable_filament_dynamic_map = false.
|
||||
auto* dyn = rt->config.option<ConfigOptionBool>("enable_filament_dynamic_map");
|
||||
const bool persisted_true = (dyn != nullptr && dyn->value);
|
||||
REQUIRE_FALSE(persisted_true);
|
||||
}
|
||||
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Arachne/WallToolPaths.hpp"
|
||||
#include "libslic3r/Arachne/SkeletalTrapezoidation.hpp"
|
||||
#include "libslic3r/Arachne/utils/ExtrusionLine.hpp"
|
||||
#include "libslic3r/Arachne/BeadingStrategy/BeadingStrategyFactory.hpp"
|
||||
#include "libslic3r/Arachne/BeadingStrategy/BeadingStrategy.hpp"
|
||||
@@ -265,3 +266,46 @@ TEST_CASE("Arachne widening keeps two beads in transition band (#14376)", "[Arac
|
||||
for (const coord_t w : beading.bead_widths)
|
||||
CHECK(w <= inner_width);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Exposes the protected static interpolate() for a focused unit test.
|
||||
struct InterpolateProbe : SkeletalTrapezoidation {
|
||||
using SkeletalTrapezoidation::interpolate;
|
||||
};
|
||||
} // anonymous namespace
|
||||
|
||||
// interpolate() indexes the merged beading with an index derived from `left`. The merged beading
|
||||
// follows the thicker of left/right, so when the thicker side has fewer insets the index runs past
|
||||
// its end.
|
||||
TEST_CASE("Beading interpolation tolerates a thicker side with fewer insets", "[Arachne][Regression]") {
|
||||
using Beading = BeadingStrategy::Beading;
|
||||
|
||||
// Thicker side (right) has fewer insets, so the merged beading holds only 2 toolpath locations.
|
||||
const coord_t w = scaled<coord_t>(0.42);
|
||||
Beading left;
|
||||
left.total_thickness = scaled<coord_t>(1.0);
|
||||
left.bead_widths = { w, w, w, w };
|
||||
left.toolpath_locations = { scaled<coord_t>(0.1), scaled<coord_t>(0.3), scaled<coord_t>(0.5), scaled<coord_t>(0.7) };
|
||||
left.left_over = 0;
|
||||
|
||||
Beading right;
|
||||
right.total_thickness = scaled<coord_t>(2.0);
|
||||
right.bead_widths = { w, w };
|
||||
right.toolpath_locations = { scaled<coord_t>(0.1), scaled<coord_t>(0.3) };
|
||||
right.left_over = 0;
|
||||
|
||||
// Just past left's location [2] (0.5), so the derived index is 2, past the end of the 2-inset merged beading.
|
||||
const coord_t switching_radius = scaled<coord_t>(0.6);
|
||||
|
||||
Beading result;
|
||||
REQUIRE_NOTHROW(result = InterpolateProbe::interpolate(left, 0.5, right, switching_radius));
|
||||
|
||||
// With the guard the adjustment is skipped, so the result is the plain interpolation.
|
||||
const Beading expected = InterpolateProbe::interpolate(left, 0.5, right);
|
||||
REQUIRE(result.toolpath_locations.size() == expected.toolpath_locations.size());
|
||||
REQUIRE(result.bead_widths.size() == expected.bead_widths.size());
|
||||
for (size_t i = 0; i < expected.toolpath_locations.size(); ++i) {
|
||||
CHECK(result.toolpath_locations[i] == expected.toolpath_locations[i]);
|
||||
CHECK(result.bead_widths[i] == expected.bead_widths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,9 @@ TEST_CASE("NetworkLibraryVersionInfo::from_static", "[BambuNetworking]") {
|
||||
REQUIRE(info.suffix == "");
|
||||
REQUIRE(info.display_name == "02.03.00.62");
|
||||
REQUIRE(info.url_override == "");
|
||||
REQUIRE(info.is_latest == true);
|
||||
// from_static no longer propagates the static is_latest flag; it is a placeholder
|
||||
// that get_all_available_versions() assigns once the list is sorted newest-first.
|
||||
REQUIRE(info.is_latest == false);
|
||||
REQUIRE(info.warning == "");
|
||||
REQUIRE(info.is_discovered == false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "libslic3r/calib.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
@@ -38,3 +42,69 @@ TEST_CASE("Zero calibration line width resolves to a positive default", "[Calib]
|
||||
REQUIRE(pattern.line_width() > 0.);
|
||||
REQUIRE(pattern.line_width_first_layer() > 0.);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct EndState { double final_e; double max_e; };
|
||||
|
||||
EndState simulate_absolute_e(const std::string &gcode)
|
||||
{
|
||||
double final_e = 0.;
|
||||
double max_e = 0.;
|
||||
|
||||
std::istringstream lines(gcode);
|
||||
std::string line;
|
||||
while (std::getline(lines, line)) {
|
||||
std::istringstream words(line);
|
||||
std::string op;
|
||||
if (!(words >> op))
|
||||
continue;
|
||||
if (op != "G1" && op != "G0" && op != "G92")
|
||||
continue;
|
||||
|
||||
std::string word;
|
||||
while (words >> word) {
|
||||
if (word.size() >= 2 && word[0] == 'E') {
|
||||
final_e = std::stod(word.substr(1));
|
||||
max_e = std::max(max_e, final_e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {final_e, max_e};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("PA pattern resets the extruder after the final layer in absolute E mode", "[Calib][Regression]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{"use_relative_e_distances", "0"},
|
||||
{"line_width", "0.45"},
|
||||
{"initial_layer_line_width", "0.45"},
|
||||
});
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Calib_Params params;
|
||||
params.mode = CalibMode::Calib_PA_Pattern;
|
||||
params.start = 0.;
|
||||
params.end = 0.08;
|
||||
params.step = 0.002;
|
||||
|
||||
CalibPressureAdvancePattern pattern(params, config, /* is_bbl_machine */ false, *model.objects.front(), Vec3d(0, 0, 0));
|
||||
const CustomGCode::Info info = pattern.generate_custom_gcodes(config, /* is_bbl_machine */ false, *model.objects.front(),
|
||||
Vec3d(0, 0, 0));
|
||||
|
||||
std::string gcode;
|
||||
for (const CustomGCode::Item &item : info.gcodes)
|
||||
gcode += item.extra;
|
||||
|
||||
const EndState state = simulate_absolute_e(gcode);
|
||||
|
||||
REQUIRE(state.max_e > 1.);
|
||||
REQUIRE_THAT(state.final_e, Catch::Matchers::WithinAbs(0., 1e-9));
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
#include "libslic3r/PrintConfigConstants.hpp"
|
||||
#include "libslic3r/LocalesUtils.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <cereal/types/polymorphic.hpp>
|
||||
#include <cereal/types/string.hpp>
|
||||
#include <cereal/types/vector.hpp>
|
||||
#include <cereal/types/string.hpp>
|
||||
#include <cereal/types/vector.hpp>
|
||||
#include <cereal/archives/binary.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
SCENARIO("Generic config validation performs as expected.", "[Config]") {
|
||||
@@ -401,3 +407,374 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
|
||||
ScopedTemporaryFile tmp(".json");
|
||||
const std::vector<std::string> refs = {
|
||||
"local_plugin;;inset",
|
||||
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
|
||||
};
|
||||
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(
|
||||
DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
|
||||
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
|
||||
|
||||
nlohmann::json j;
|
||||
{
|
||||
boost::nowide::ifstream ifs(tmp.string());
|
||||
ifs >> j;
|
||||
}
|
||||
REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs));
|
||||
CHECK_FALSE(j.contains("plugins"));
|
||||
|
||||
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
|
||||
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
|
||||
CHECK(reason.empty());
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
|
||||
const std::vector<std::string> refs = {
|
||||
"master_plugin;;header-stamp",
|
||||
"Sample Plugin;1f998ea9-0183-4cc5-957f-4eef659ba4e6;G-code Benchmark (.py)"
|
||||
};
|
||||
|
||||
DynamicPrintConfig original = DynamicPrintConfig::full_print_config();
|
||||
original.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
|
||||
|
||||
std::map<std::string, std::string> serialized{
|
||||
{"slicing_pipeline_plugin", original.option<ConfigOptionStrings>("slicing_pipeline_plugin")->serialize()}
|
||||
};
|
||||
CHECK(serialized["slicing_pipeline_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
|
||||
|
||||
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
|
||||
reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") {
|
||||
const auto local = Slic3r::parse_capability_ref("local_plugin;;post_process");
|
||||
REQUIRE(local.has_value());
|
||||
CHECK(local->name == "local_plugin");
|
||||
CHECK(local->capability_name == "post_process");
|
||||
CHECK(local->uuid.empty());
|
||||
|
||||
const auto cloud = Slic3r::parse_capability_ref(
|
||||
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process");
|
||||
REQUIRE(cloud.has_value());
|
||||
CHECK(cloud->name == "cloud_plugin");
|
||||
CHECK(cloud->capability_name == "post_process");
|
||||
CHECK(cloud->uuid == "550e8400-e29b-41d4-a716-446655440000");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref(";;capability").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref(";uuid;capability").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Installs a stub capability resolver that echoes the capability type into the reference, so tests
|
||||
// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets
|
||||
// the global resolver on teardown -- tests run in random order and other cases assert the
|
||||
// no-resolver behavior (an absent "plugins" manifest).
|
||||
struct PluginResolverFixture {
|
||||
PluginResolverFixture() {
|
||||
ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) {
|
||||
return name.empty() ? std::string() : name + ";;" + type;
|
||||
});
|
||||
}
|
||||
~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE_METHOD(PluginResolverFixture,
|
||||
"update_plugin_manifest derives references generically from plugin-backed options",
|
||||
"[Config][plugins]") {
|
||||
// Both scalar (printer_agent) and vector (slicing_pipeline_plugin) options opt in via a non-empty
|
||||
// ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it -- there is no hardcoded
|
||||
// per-option switch. printer_agent in particular relies on its plugin_type metadata being wired up
|
||||
// (it is edited via a dedicated widget, not the plugin_picker).
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
|
||||
{"slicing_pipeline_plugin", "printer_agent"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"sp"};
|
||||
config.option<ConfigOptionString>("printer_agent", true)->value = "agent";
|
||||
|
||||
config.update_plugin_manifest();
|
||||
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
|
||||
|
||||
using Catch::Matchers::VectorContains;
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline")));
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection")));
|
||||
CHECK(manifest.size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(PluginResolverFixture,
|
||||
"update_plugin_manifest de-duplicates references and skips unset options",
|
||||
"[Config][plugins]") {
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
|
||||
{"slicing_pipeline_plugin", "printer_agent"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate
|
||||
// printer_agent stays at its default empty value -> contributes nothing to the manifest.
|
||||
|
||||
config.update_plugin_manifest();
|
||||
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
|
||||
|
||||
CHECK(manifest == std::vector<std::string>{"x;;slicing-pipeline"});
|
||||
}
|
||||
|
||||
TEST_CASE("H2C/A2L-era multi-nozzle and pre-heat config keys exist", "[config]") {
|
||||
// Foundation keys backing H2C 6-nozzle cluster grouping, the pre-heat/pre-cool time
|
||||
// model, and wipe-tower nozzle-change handling. Defaults must keep existing
|
||||
// single-nozzle printers behaving identically.
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
|
||||
// Printer / per-extruder options
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count")->values == std::vector<int>{1});
|
||||
REQUIRE(config.option<ConfigOptionBool>("enable_pre_heating") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionBool>("enable_pre_heating")->value == false);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("hotend_cooling_rate") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("hotend_heating_rate") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloat>("machine_hotend_change_time") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloat>("machine_prepare_compensation_time") != nullptr);
|
||||
|
||||
// Filament pre-cooling / ramming / nozzle-change (nc) options
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("filament_pre_cooling_temperature") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("filament_pre_cooling_temperature_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_preheat_temperature_delta") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_retract_length_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_change_length_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_prime_volume_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_travel_time") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_travel_time_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed_nc") != nullptr);
|
||||
|
||||
// Spot-check defaults that must not alter existing behavior.
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_retract_length_nc")->values == std::vector<double>{10.});
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_prime_volume_nc")->values == std::vector<double>{60.});
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("filament_pre_cooling_temperature_nc")->values == std::vector<int>{0});
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed")->values == std::vector<double>{-1});
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index with stride=1 copies values correctly", "[Config][set_to_index]") {
|
||||
GIVEN("A destination vector and a source vector with 3 values") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 20.0, 30.0});
|
||||
std::vector<int> variant_index = {0, 1, 2};
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index is called with stride=1") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination contains the source values") {
|
||||
REQUIRE(dest.values.size() == 3);
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 20.0);
|
||||
REQUIRE(dest.values[2] == 30.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("A destination vector and a source vector with subset mapping") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({100.0, 200.0, 300.0});
|
||||
std::vector<int> variant_index = {1, 2};
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index maps only indices 1 and 2") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Only the mapped values are copied, default fills the others") {
|
||||
REQUIRE(dest.values.size() == 2);
|
||||
REQUIRE(dest.values[0] == 200.0);
|
||||
REQUIRE(dest.values[1] == 300.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index with stride=2 copies grouped values correctly", "[Config][set_to_index]") {
|
||||
GIVEN("A destination vector and a source vector with stride=2 (e.g., nozzle groups)") {
|
||||
// Source has 4 groups of 2 values each: (10,11), (20,21), (30,31), (40,41)
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 11.0, 20.0, 21.0, 30.0, 31.0, 40.0, 41.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps groups 0, 1, 3") {
|
||||
std::vector<int> variant_index = {0, 1, 3};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination has 3 groups (6 values) mapped correctly") {
|
||||
REQUIRE(dest.values.size() == 6);
|
||||
// Group 0: (10, 11)
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 11.0);
|
||||
// Group 1: (20, 21)
|
||||
REQUIRE(dest.values[2] == 20.0);
|
||||
REQUIRE(dest.values[3] == 21.0);
|
||||
// Group 3: (40, 41)
|
||||
REQUIRE(dest.values[4] == 40.0);
|
||||
REQUIRE(dest.values[5] == 41.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("A destination and a single-group source") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
// Source has 1 group of 2 values
|
||||
Slic3r::ConfigOptionFloats src({50.0, 60.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps group 0 from a single-group source") {
|
||||
std::vector<int> variant_index = {0};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination contains the single group correctly") {
|
||||
REQUIRE(dest.values.size() == 2);
|
||||
REQUIRE(dest.values[0] == 50.0);
|
||||
REQUIRE(dest.values[1] == 60.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles empty dest_index", "[Config][set_to_index]") {
|
||||
GIVEN("A destination and source with stride=2") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 11.0, 20.0, 21.0});
|
||||
std::vector<int> variant_index = {};
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index is called with an empty index vector") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination is resized to 0") {
|
||||
REQUIRE(dest.values.size() == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles nil values in source", "[Config][set_to_index]") {
|
||||
GIVEN("A source with a nil group (stride=2)") {
|
||||
Slic3r::ConfigOptionFloatsNullable dest({0.0});
|
||||
Slic3r::ConfigOptionFloatsNullable src({10.0, 11.0,
|
||||
Slic3r::ConfigOptionFloatsNullable::nil_value(), Slic3r::ConfigOptionFloatsNullable::nil_value(),
|
||||
30.0, 31.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps all groups including the nil one") {
|
||||
std::vector<int> variant_index = {0, 1, 2};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Non-nil groups are copied and the nil group keeps the default") {
|
||||
REQUIRE(dest.values.size() == 6);
|
||||
// Group 0: (10, 11) — copied
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 11.0);
|
||||
// Group 1: nil — keeps default (the front value = 10.0)
|
||||
REQUIRE(dest.values[2] == 10.0);
|
||||
REQUIRE(dest.values[3] == 10.0);
|
||||
// Group 2: (30, 31) — copied
|
||||
REQUIRE(dest.values[4] == 30.0);
|
||||
REQUIRE(dest.values[5] == 31.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles out-of-bounds dest_index", "[Config][set_to_index]") {
|
||||
GIVEN("A source with only 2 groups (4 values) but dest_index references group 3") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 11.0, 20.0, 21.0}); // 2 groups of stride 2
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps group 3 which is out of bounds") {
|
||||
std::vector<int> variant_index = {0, 3}; // group 3 is out of range
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Group 0 is copied, group 3 falls back to default without crashing") {
|
||||
REQUIRE(dest.values.size() == 4);
|
||||
// Group 0: (10, 11) — copied
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 11.0);
|
||||
// Group 3: out of bounds — keeps default (10.0 = src.values.front())
|
||||
REQUIRE(dest.values[2] == 10.0);
|
||||
REQUIRE(dest.values[3] == 10.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles negative dest_index values", "[Config][set_to_index]") {
|
||||
GIVEN("A destination and source with a negative entry in dest_index") {
|
||||
// The dest is initially empty, so resize fills all slots with src.values.front().
|
||||
Slic3r::ConfigOptionFloats dest;
|
||||
Slic3r::ConfigOptionFloats src({100.0, 101.0, 200.0, 201.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps group 0 and a negative index") {
|
||||
std::vector<int> variant_index = {-1, 0};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The negative index is skipped, the valid group is copied") {
|
||||
REQUIRE(dest.values.size() == 4);
|
||||
// Position 0 (variant_index[0] = -1): skipped, keeps default fill
|
||||
// from resize (src.values.front() = 100.0, applied to all new elements)
|
||||
REQUIRE(dest.values[0] == 100.0);
|
||||
REQUIRE(dest.values[1] == 100.0);
|
||||
// Position 1 (variant_index[1] = 0): copied from group 0 of src
|
||||
REQUIRE(dest.values[2] == 100.0);
|
||||
REQUIRE(dest.values[3] == 101.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles single-element groups with stride=1", "[Config][set_to_index]") {
|
||||
GIVEN("A destination re-mapping one variant index with a stride=1 source") {
|
||||
// Simulates the PrintObject.cpp code path: stride=1, variant_index={1}
|
||||
Slic3r::ConfigOptionFloats dest({99.0, 99.0, 99.0, 99.0}); // pre-sized for 4 extruders
|
||||
Slic3r::ConfigOptionFloats src({0.5, 0.6, 0.7, 0.8}); // 4 extruder values
|
||||
std::vector<int> variant_index = {1}; // only extruder 1 is active
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index is called") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Only the mapped value is copied, rest are defaulted") {
|
||||
REQUIRE(dest.values.size() == 1);
|
||||
REQUIRE(dest.values[0] == 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index throws on incompatible type", "[Config][set_to_index]") {
|
||||
GIVEN("A Floats destination and an Ints source") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionInts src({1, 2, 3});
|
||||
std::vector<int> variant_index = {0};
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index is called with mismatched types") {
|
||||
THEN("A ConfigurationError is thrown") {
|
||||
REQUIRE_THROWS_AS(dest.set_to_index(&src, variant_index, stride), Slic3r::ConfigurationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
481
tests/libslic3r/test_config_variant_expansion.cpp
Normal file
481
tests/libslic3r/test_config_variant_expansion.cpp
Normal file
@@ -0,0 +1,481 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// A 2-extruder printer whose second extruder holds both a Standard and a High Flow nozzle
|
||||
// (nozzle_volume_type Hybrid), described by extruder_nozzle_stats. The variant lists carry one
|
||||
// column per (extruder x volume type) as composed from the presets.
|
||||
DynamicPrintConfig make_hybrid_printer_config()
|
||||
{
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#3|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
return config;
|
||||
}
|
||||
|
||||
void add_print_variant_columns(DynamicPrintConfig &config)
|
||||
{
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30., 200., 50., 500.};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("apply_override fills nil entries from the 0-based default index", "[Config]")
|
||||
{
|
||||
ConfigOptionFloats machine({10., 20., 30.});
|
||||
ConfigOptionFloatsNullable filament;
|
||||
filament.values = {ConfigOptionFloatsNullable::nil_value(), 42.};
|
||||
|
||||
SECTION("a nil entry picks the slot addressed by its 0-based index") {
|
||||
std::vector<int> slot_index{2, 0};
|
||||
ConfigOptionFloats resolved(machine);
|
||||
REQUIRE(resolved.apply_override(&filament, slot_index));
|
||||
REQUIRE(resolved.values == std::vector<double>({30., 42.}));
|
||||
}
|
||||
|
||||
SECTION("an index past the machine slots keeps the slot's own value") {
|
||||
std::vector<int> slot_index{5, 0};
|
||||
ConfigOptionFloats resolved(machine);
|
||||
REQUIRE(resolved.apply_override(&filament, slot_index));
|
||||
REQUIRE(resolved.values == std::vector<double>({10., 42.}));
|
||||
}
|
||||
|
||||
SECTION("a negative index (unresolved slot) keeps the slot's own value") {
|
||||
ConfigOptionFloatsNullable all_nil;
|
||||
all_nil.values = {ConfigOptionFloatsNullable::nil_value(), ConfigOptionFloatsNullable::nil_value(),
|
||||
ConfigOptionFloatsNullable::nil_value()};
|
||||
std::vector<int> slot_index{2, -1, 0};
|
||||
ConfigOptionFloats resolved(machine);
|
||||
REQUIRE(!resolved.apply_override(&all_nil, slot_index));
|
||||
REQUIRE(resolved.values == std::vector<double>({30., 20., 10.}));
|
||||
}
|
||||
|
||||
SECTION("all-nil overrides keyed by unresolved slots leave the machine values intact") {
|
||||
// The failed-lookup map a degenerate print_extruder_id used to produce; the negative
|
||||
// slots must not collapse the machine array to its first value.
|
||||
ConfigOptionFloats per_extruder({100., 70., 70., 70., 100.});
|
||||
ConfigOptionFloatsNullable all_nil;
|
||||
all_nil.values.assign(5, ConfigOptionFloatsNullable::nil_value());
|
||||
std::vector<int> slot_index{0, -1, -1, -1, 0};
|
||||
ConfigOptionFloats resolved(per_extruder);
|
||||
REQUIRE(!resolved.apply_override(&all_nil, slot_index));
|
||||
REQUIRE(resolved.values == std::vector<double>({100., 70., 70., 70., 100.}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("support_different_extruders is true only when the printer defines more than one variant column", "[Config]")
|
||||
{
|
||||
int extruder_count = 0;
|
||||
|
||||
SECTION("a non-Bambu dual-nozzle printer with one variant column reports false") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
// Both extruders resolve to the same default variant, so there is only one column.
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard",
|
||||
"Direct Drive Standard"};
|
||||
REQUIRE(config.support_different_extruders(extruder_count) == false);
|
||||
REQUIRE(extruder_count == 2);
|
||||
}
|
||||
|
||||
SECTION("a Bambu H2D-style printer with distinct variants reports true") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {
|
||||
"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow,Direct Drive TPU High Flow"};
|
||||
REQUIRE(config.support_different_extruders(extruder_count) == true);
|
||||
REQUIRE(extruder_count == 2);
|
||||
}
|
||||
|
||||
SECTION("a many-toolhead printer that never opts into variants reports false") {
|
||||
// A Snapmaker U1 has four identical toolheads and never defines extruder_variant_list,
|
||||
// so the config falls back to a single default variant token.
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4};
|
||||
REQUIRE(config.support_different_extruders(extruder_count) == false);
|
||||
REQUIRE(extruder_count == 4);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("get_config_index_base resolves (volume type, extruder type, id) to a slot", "[Config]")
|
||||
{
|
||||
const std::vector<std::string> variant_list = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
const std::vector<int> variant_ids = {1, 1, 2, 2};
|
||||
|
||||
SECTION("a matching (variant, id) pair yields its slot") {
|
||||
REQUIRE(get_config_index_base(nvtStandard, etDirectDrive, 1, variant_list, variant_ids) == 0);
|
||||
REQUIRE(get_config_index_base(nvtHighFlow, etDirectDrive, 1, variant_list, variant_ids) == 1);
|
||||
REQUIRE(get_config_index_base(nvtStandard, etDirectDrive, 2, variant_list, variant_ids) == 2);
|
||||
REQUIRE(get_config_index_base(nvtHighFlow, etDirectDrive, 2, variant_list, variant_ids) == 3);
|
||||
}
|
||||
|
||||
SECTION("no matching column falls back to slot 0") {
|
||||
REQUIRE(get_config_index_base(nvtStandard, etDirectDrive, 3, variant_list, variant_ids) == 0);
|
||||
REQUIRE(get_config_index_base(nvtStandard, etBowden, 1, variant_list, variant_ids) == 0);
|
||||
}
|
||||
|
||||
SECTION("Hybrid is not a preset variant string and falls back to slot 0") {
|
||||
REQUIRE(get_config_index_base(nvtHybrid, etDirectDrive, 2, variant_list, variant_ids) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("get_extruder_nozzle_volume_count reads the per-extruder volume-type layout", "[Config]")
|
||||
{
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
|
||||
SECTION("absent stats fall back to one slot per extruder") {
|
||||
DynamicPrintConfig config;
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 2);
|
||||
REQUIRE(nozzle_volume_types.size() == 2);
|
||||
REQUIRE(nozzle_volume_types[0].empty());
|
||||
REQUIRE(nozzle_volume_types[1].empty());
|
||||
}
|
||||
|
||||
SECTION("stats sized differently from the extruder count are ignored") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1"};
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 2);
|
||||
REQUIRE(nozzle_volume_types[0].empty());
|
||||
REQUIRE(nozzle_volume_types[1].empty());
|
||||
}
|
||||
|
||||
SECTION("single volume type per extruder counts one slot each") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "High Flow#1"};
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 2);
|
||||
REQUIRE(nozzle_volume_types[0] == std::vector<NozzleVolumeType>{nvtStandard});
|
||||
REQUIRE(nozzle_volume_types[1] == std::vector<NozzleVolumeType>{nvtHighFlow});
|
||||
}
|
||||
|
||||
SECTION("a mixed-nozzle extruder contributes one slot per volume type, ascending enum order") {
|
||||
DynamicPrintConfig config;
|
||||
// list High Flow first in the token string: parsing must still order Standard before High Flow
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#3", "High Flow#3|Standard#3"};
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 3);
|
||||
REQUIRE(nozzle_volume_types[0] == std::vector<NozzleVolumeType>{nvtStandard});
|
||||
REQUIRE(nozzle_volume_types[1] == std::vector<NozzleVolumeType>({nvtStandard, nvtHighFlow}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("update_values_to_printer_extruders expands one slot per (extruder x volume type)", "[Config]")
|
||||
{
|
||||
SECTION("Hybrid extruder yields three slots, extruder-ascending then volume-ascending") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
add_print_variant_columns(config);
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
REQUIRE(count == 3);
|
||||
|
||||
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 50., 500.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2, 2}));
|
||||
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"}));
|
||||
}
|
||||
|
||||
SECTION("stride-2 options keep (normal, silent) pairs together per slot") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
config.option<ConfigOptionInts>("printer_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("printer_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("machine_max_speed_x", true)->values = {100., 50., 110., 55., 120., 60., 130., 65.};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
printer_options_with_variant_2, "printer_extruder_id", "printer_extruder_variant", 2);
|
||||
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("machine_max_speed_x")->values ==
|
||||
std::vector<double>({100., 50., 120., 60., 130., 65.}));
|
||||
}
|
||||
|
||||
SECTION("single-slot expansion on a Hybrid extruder resolves via the filament volume type") {
|
||||
DynamicPrintConfig printer_config = make_hybrid_printer_config();
|
||||
|
||||
DynamicPrintConfig filament_config;
|
||||
filament_config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
filament_config.option<ConfigOptionFloats>("filament_max_volumetric_speed", true)->values = {12., 20.};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = printer_config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
SECTION("default filament volume type selects the Standard column") {
|
||||
std::vector<int> variant_index = filament_config.update_values_to_printer_extruders(printer_config, extruder_count, count,
|
||||
nozzle_volume_types, filament_options_with_variant, "", "filament_extruder_variant", 1, 2);
|
||||
REQUIRE(variant_index == std::vector<int>({0}));
|
||||
REQUIRE(filament_config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12.}));
|
||||
}
|
||||
|
||||
SECTION("a High Flow filament volume type selects the High Flow column") {
|
||||
std::vector<int> variant_index = filament_config.update_values_to_printer_extruders(printer_config, extruder_count, count,
|
||||
nozzle_volume_types, filament_options_with_variant, "", "filament_extruder_variant", 1, 2, nvtHighFlow);
|
||||
REQUIRE(variant_index == std::vector<int>({1}));
|
||||
REQUIRE(filament_config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({20.}));
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("an extruder without per-type stats does not overrun the slot table when another is Hybrid") {
|
||||
DynamicPrintConfig config;
|
||||
// e0 carries no per-type stats (empty entry), so the summed volume-type count (2) does
|
||||
// not exceed the extruder count even though the Hybrid e1 emits one slot per volume type.
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"", "Standard#3|High Flow#3"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
add_print_variant_columns(config);
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
REQUIRE(count == 2);
|
||||
REQUIRE(nozzle_volume_types[0].empty());
|
||||
|
||||
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
// e0 resolves by its configured type; the Hybrid e1 emits one slot per stats volume type
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 50., 500.}));
|
||||
}
|
||||
|
||||
SECTION("without Hybrid or extra slots the expansion matches the per-extruder resolution") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHighFlow};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
add_print_variant_columns(config);
|
||||
|
||||
// compute what the per-extruder loop resolves directly, before the arrays are rewritten
|
||||
std::vector<int> expected_index;
|
||||
for (int e_index = 0; e_index < 2; e_index++)
|
||||
expected_index.push_back(config.get_index_for_extruder(e_index + 1, "print_extruder_id", etDirectDrive,
|
||||
e_index == 0 ? nvtStandard : nvtHighFlow, "print_extruder_variant"));
|
||||
REQUIRE(expected_index == std::vector<int>({0, 3}));
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
REQUIRE(count == 2);
|
||||
|
||||
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
REQUIRE(variant_index == expected_index);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 500.}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("update_values_to_printer_extruders synthesizes degenerate process variant columns", "[Config]")
|
||||
{
|
||||
// Non-BBL process presets and 3mf project configs keep the length-1 defaults for
|
||||
// print_extruder_id/print_extruder_variant; only BBL system presets ship full-width columns.
|
||||
auto add_degenerate_print_columns = [](DynamicPrintConfig &config) {
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard"};
|
||||
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30.};
|
||||
};
|
||||
|
||||
SECTION("a single-column pair on a multi-extruder machine expands to one column per extruder") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard", "Direct Drive Standard"};
|
||||
add_degenerate_print_columns(config);
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
REQUIRE(variant_index == std::vector<int>({0, 1}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2}));
|
||||
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard"}));
|
||||
// width-1 data arrays replicate their only column into every slot
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 30.}));
|
||||
}
|
||||
|
||||
SECTION("a multi-variant list synthesizes one column per (extruder x variant)") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
add_degenerate_print_columns(config);
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
REQUIRE(count == 3);
|
||||
|
||||
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
// same slot resolution as the explicit BBL-style 4-column layout
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2, 2}));
|
||||
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 30., 30.}));
|
||||
}
|
||||
|
||||
SECTION("a single-extruder single-column layout is not treated as degenerate") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard"};
|
||||
add_degenerate_print_columns(config);
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 1;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30.}));
|
||||
}
|
||||
|
||||
SECTION("a second expansion leaves the synthesized layout unchanged") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard", "Direct Drive Standard"};
|
||||
add_degenerate_print_columns(config);
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
DynamicPrintConfig once = config;
|
||||
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
|
||||
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
|
||||
|
||||
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values ==
|
||||
once.option<ConfigOptionInts>("print_extruder_id")->values);
|
||||
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
|
||||
once.option<ConfigOptionStrings>("print_extruder_variant")->values);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values ==
|
||||
once.option<ConfigOptionFloats>("outer_wall_speed")->values);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves per-filament slots", "[Config]")
|
||||
{
|
||||
auto make_filament_arrays = [](DynamicPrintConfig &config) {
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_max_volumetric_speed", true)->values = {12., 20., 13., 21.};
|
||||
};
|
||||
|
||||
std::set<std::string> filament_keys = filament_options_with_variant;
|
||||
filament_keys.insert("filament_self_index");
|
||||
|
||||
SECTION("filament_volume_map picks the concrete volume type on a Hybrid extruder") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
make_filament_arrays(config);
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtStandard, nvtHighFlow};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 21.}));
|
||||
REQUIRE(config.option<ConfigOptionStrings>("filament_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive High Flow"}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
|
||||
}
|
||||
|
||||
SECTION("a volume map not sized to the filament count is ignored") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
make_filament_arrays(config);
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {2, 2};
|
||||
// the registered default is a single-element vector; it must not override slot resolution
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtStandard};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
// Hybrid resolves as Standard when no usable per-filament map exists
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 13.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
|
||||
}
|
||||
|
||||
SECTION("a single-filament explicit assignment on a Hybrid extruder is honored") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_max_volumetric_speed", true)->values = {12., 20.};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {2};
|
||||
// sized to the (single) filament count: the producers guarantee sizing, so a
|
||||
// single-filament map is as trustworthy as any other and the explicit High Flow
|
||||
// request must win over the Hybrid->Standard fallback
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtHighFlow};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({20.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1}));
|
||||
}
|
||||
|
||||
SECTION("without Hybrid or extra slots the volume map is not consulted") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHighFlow};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
make_filament_arrays(config);
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
|
||||
// sized to the filament count, but inert because no extruder exposes multiple volume types
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtHighFlow, nvtStandard};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
REQUIRE(count == 2);
|
||||
|
||||
config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
// filament 1 keeps its extruder's Standard column, filament 2 its extruder's High Flow column
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 21.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
|
||||
}
|
||||
}
|
||||
164
tests/libslic3r/test_fill_plane_path.cpp
Normal file
164
tests/libslic3r/test_fill_plane_path.cpp
Normal file
@@ -0,0 +1,164 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include "libslic3r/Fill/FillPlanePath.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double output_scale = 1'000'000.;
|
||||
|
||||
class TestableHilbertCurve : public FillHilbertCurve
|
||||
{
|
||||
public:
|
||||
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
|
||||
{
|
||||
InfillPolylineOutput output(output_scale);
|
||||
FillParams params;
|
||||
params.smooth_factor = smooth_factor;
|
||||
FillHilbertCurve::generate(0, 0, max_coordinate, max_coordinate, resolution, params, output);
|
||||
return std::move(output.result());
|
||||
}
|
||||
};
|
||||
|
||||
double path_length(const Points &points)
|
||||
{
|
||||
double length = 0.;
|
||||
for (size_t i = 1; i < points.size(); ++i)
|
||||
length += (points[i] - points[i - 1]).cast<double>().norm();
|
||||
return length;
|
||||
}
|
||||
|
||||
double discrete_curvature_at(const Points &points, const Point &point)
|
||||
{
|
||||
const auto point_it = std::find(points.begin(), points.end(), point);
|
||||
REQUIRE(point_it != points.end());
|
||||
const size_t point_idx = size_t(std::distance(points.begin(), point_it));
|
||||
REQUIRE(point_idx > 0);
|
||||
REQUIRE(point_idx + 1 < points.size());
|
||||
|
||||
const Vec2d incoming = (points[point_idx] - points[point_idx - 1]).cast<double>() / output_scale;
|
||||
const Vec2d outgoing = (points[point_idx + 1] - points[point_idx]).cast<double>() / output_scale;
|
||||
const Vec2d chord = incoming + outgoing;
|
||||
const double cross = std::abs(incoming.x() * outgoing.y() - incoming.y() * outgoing.x());
|
||||
return 2. * cross / (incoming.norm() * outgoing.norm() * chord.norm());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Hilbert curve exposes a smoothing factor", "[FillPlanePath]")
|
||||
{
|
||||
const ConfigOptionDef *factor_def = print_config_def.get("sparse_infill_smooth_factor");
|
||||
REQUIRE(factor_def != nullptr);
|
||||
REQUIRE(factor_def->type == coPercent);
|
||||
REQUIRE_THAT(factor_def->min, Catch::Matchers::WithinAbs(0., 1e-12));
|
||||
REQUIRE_THAT(factor_def->max, Catch::Matchers::WithinAbs(100., 1e-12));
|
||||
REQUIRE_THAT(factor_def->get_default_value<ConfigOptionPercent>()->value,
|
||||
Catch::Matchers::WithinAbs(0., 1e-12));
|
||||
}
|
||||
|
||||
TEST_CASE("Hilbert curve smoothing rounds right angle turns", "[FillPlanePath]")
|
||||
{
|
||||
const Points sharp = TestableHilbertCurve().generate_points(0.005);
|
||||
const Points smooth = TestableHilbertCurve().generate_points(0.005, 1.);
|
||||
|
||||
REQUIRE(smooth.front() == sharp.front());
|
||||
REQUIRE(smooth.back() == sharp.back());
|
||||
REQUIRE(smooth.size() > sharp.size());
|
||||
|
||||
bool has_turn = false;
|
||||
for (size_t i = 1; i < smooth.size(); ++i) {
|
||||
const Vec2d segment = (smooth[i] - smooth[i - 1]).cast<double>();
|
||||
REQUIRE(segment.squaredNorm() > 0.);
|
||||
}
|
||||
for (size_t i = 1; i + 1 < smooth.size(); ++i) {
|
||||
const Vec2d incoming = (smooth[i] - smooth[i - 1]).cast<double>();
|
||||
const Vec2d outgoing = (smooth[i + 1] - smooth[i]).cast<double>();
|
||||
const double cross = incoming.x() * outgoing.y() - incoming.y() * outgoing.x();
|
||||
const double cosine = incoming.dot(outgoing) / (incoming.norm() * outgoing.norm());
|
||||
has_turn |= std::abs(cross) > 0.;
|
||||
REQUIRE(cosine > 0.);
|
||||
}
|
||||
REQUIRE(has_turn);
|
||||
|
||||
const coord_t upper_bound = coord_t(7 * output_scale);
|
||||
for (const Point &point : smooth) {
|
||||
REQUIRE(point.x() >= 0);
|
||||
REQUIRE(point.y() >= 0);
|
||||
REQUIRE(point.x() <= upper_bound);
|
||||
REQUIRE(point.y() <= upper_bound);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Smoothed Hilbert curve honors path resolution", "[FillPlanePath]")
|
||||
{
|
||||
const Points coarse = TestableHilbertCurve().generate_points(0.1, 1.);
|
||||
const Points fine = TestableHilbertCurve().generate_points(0.001, 1.);
|
||||
|
||||
REQUIRE(fine.size() > coarse.size());
|
||||
REQUIRE(fine.front() == coarse.front());
|
||||
REQUIRE(fine.back() == coarse.back());
|
||||
}
|
||||
|
||||
TEST_CASE("Smoothed Hilbert corners use a uniform subdivision depth", "[FillPlanePath]")
|
||||
{
|
||||
const Points smooth = TestableHilbertCurve().generate_points(0.0035, 1., 1);
|
||||
const Point curve_entry(0, coord_t(0.5 * output_scale));
|
||||
const Point curve_exit(coord_t(0.5 * output_scale), coord_t(output_scale));
|
||||
|
||||
const auto entry_it = std::find(smooth.begin(), smooth.end(), curve_entry);
|
||||
REQUIRE(entry_it != smooth.end());
|
||||
const auto exit_it = std::find(entry_it, smooth.end(), curve_exit);
|
||||
REQUIRE(exit_it != smooth.end());
|
||||
|
||||
const size_t segment_count = size_t(std::distance(entry_it, exit_it));
|
||||
REQUIRE(segment_count > 1);
|
||||
REQUIRE((segment_count & (segment_count - 1)) == 0);
|
||||
|
||||
double previous_length = (entry_it[1] - entry_it[0]).cast<double>().norm();
|
||||
REQUIRE(previous_length > 0.);
|
||||
double max_length_ratio = 1.;
|
||||
for (size_t segment = 1; segment < segment_count; ++segment) {
|
||||
const double current_length = (entry_it[segment + 1] - entry_it[segment]).cast<double>().norm();
|
||||
REQUIRE(current_length > 0.);
|
||||
max_length_ratio = std::max(max_length_ratio,
|
||||
std::max(current_length / previous_length, previous_length / current_length));
|
||||
previous_length = current_length;
|
||||
}
|
||||
REQUIRE(max_length_ratio < 1.5);
|
||||
}
|
||||
|
||||
TEST_CASE("Hilbert smoothing joins straight segments with continuous curvature", "[FillPlanePath]")
|
||||
{
|
||||
const Points coarse = TestableHilbertCurve().generate_points(0.005, 0.5, 1);
|
||||
const Points fine = TestableHilbertCurve().generate_points(0.0001, 0.5, 1);
|
||||
const Point first_curve_entry(0, coord_t(0.75 * output_scale));
|
||||
|
||||
const double coarse_entry_curvature = discrete_curvature_at(coarse, first_curve_entry);
|
||||
const double fine_entry_curvature = discrete_curvature_at(fine, first_curve_entry);
|
||||
REQUIRE(coarse_entry_curvature > 0.);
|
||||
REQUIRE(fine_entry_curvature < 0.25 * coarse_entry_curvature);
|
||||
}
|
||||
|
||||
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
|
||||
{
|
||||
const Points sharp = TestableHilbertCurve().generate_points(0.005);
|
||||
const Points half_smooth = TestableHilbertCurve().generate_points(0.005, 0.5);
|
||||
const Points full_smooth = TestableHilbertCurve().generate_points(0.005, 1.);
|
||||
const Points invalid_factor = TestableHilbertCurve().generate_points(
|
||||
0.005, std::numeric_limits<double>::quiet_NaN());
|
||||
|
||||
REQUIRE(full_smooth.front() == half_smooth.front());
|
||||
REQUIRE(full_smooth.back() == half_smooth.back());
|
||||
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
|
||||
REQUIRE(invalid_factor == sharp);
|
||||
|
||||
for (size_t i = 1; i < full_smooth.size(); ++i)
|
||||
REQUIRE((full_smooth[i] - full_smooth[i - 1]).squaredNorm() > 0);
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include "libslic3r/SLA/Hollowing.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
TEST_CASE("Hollow two overlapping spheres") {
|
||||
using namespace Slic3r;
|
||||
|
||||
@@ -16,6 +18,6 @@ TEST_CASE("Hollow two overlapping spheres") {
|
||||
|
||||
sla::hollow_mesh(sphere1, sla::HollowingConfig{}, sla::HollowingFlags::hfRemoveInsideTriangles);
|
||||
|
||||
sphere1.WriteOBJFile("twospheres.obj");
|
||||
write_debug_obj("hollowing/twospheres.obj", sphere1);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("Split empty mesh", "[its_split][its]") {
|
||||
@@ -29,13 +31,15 @@ TEST_CASE("Split simple mesh consisting of one part", "[its_split][its]") {
|
||||
REQUIRE(res.front().vertices.size() == cube.vertices.size());
|
||||
}
|
||||
|
||||
// Dump each split part as its own OBJ for eyeballing; no-op in release.
|
||||
void debug_write_obj(const std::vector<indexed_triangle_set> &res, const std::string &name)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
size_t part_idx = 0;
|
||||
for (auto &part_its : res) {
|
||||
its_write_obj(part_its, (name + std::to_string(part_idx++) + ".obj").c_str());
|
||||
}
|
||||
for (const auto &part_its : res)
|
||||
write_debug_obj("indexed_triangle_set/" + name + std::to_string(part_idx++) + ".obj", part_its);
|
||||
#else
|
||||
(void) res; (void) name;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -260,7 +264,6 @@ TEST_CASE("Reduce one edge by Quadric Edge Collapse", "[its]")
|
||||
CHECK(is_similar(its_, its, cfg));
|
||||
}
|
||||
|
||||
#include "test_utils.hpp"
|
||||
TEST_CASE("Simplify mesh by Quadric edge collapse to 5%", "[its]")
|
||||
{
|
||||
TriangleMesh mesh = load_model("frog_legs.obj");
|
||||
|
||||
@@ -191,22 +191,21 @@ static void test_expolys(Rst&& rst, const ExPolygons& ref, Vec2i32 window, const
|
||||
for (const ExPolygon& expoly : ref)
|
||||
rst.draw(expoly);
|
||||
|
||||
std::fstream out(name + ".png", std::ios::out);
|
||||
out << rst.encode(sla::PNGRasterEncoder{});
|
||||
out.close();
|
||||
write_debug_stream("marchingsquares/" + name + ".png",
|
||||
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
|
||||
|
||||
const ExPolygons bmp = rstGetPolys(rst);
|
||||
const ExPolygons ext = sla::raster_to_polygons(rst, window);
|
||||
|
||||
SVG svg(name + ".svg", raster_bb);
|
||||
svg.draw(bmp, "green");
|
||||
if (pixel_size.x() >= scale_(0.5))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
|
||||
if (window_size.x() >= scale_(1.0))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
|
||||
svg.draw_outline(ref, "red", "red", scale_(0.3));
|
||||
svg.draw_outline(ext, "blue", "blue");
|
||||
svg.Close();
|
||||
write_debug_svg("marchingsquares/" + name + ".svg", raster_bb, [&](SVG &svg) {
|
||||
svg.draw(bmp, "green");
|
||||
if (pixel_size.x() >= scale_(0.5))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
|
||||
if (window_size.x() >= scale_(1.0))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
|
||||
svg.draw_outline(ref, "red", "red", scale_(0.3));
|
||||
svg.draw_outline(ext, "blue", "blue");
|
||||
});
|
||||
|
||||
// Note all these areas are unscaled back to mm^2.
|
||||
double raster_area = unscaled(unscaled(area(bmp)));
|
||||
@@ -432,9 +431,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
double disp_w = 120.96;
|
||||
double disp_h = 68.04;
|
||||
|
||||
#ifndef NDEBUG
|
||||
size_t cntr = 0;
|
||||
#endif
|
||||
for (ExPolygons& layer : layers) {
|
||||
auto rst = create_raster(res, disp_w, disp_h);
|
||||
|
||||
@@ -442,11 +439,8 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
rst.draw(island);
|
||||
}
|
||||
|
||||
#ifndef NDEBUG
|
||||
std::fstream out(objname + std::to_string(cntr) + ".png", std::ios::out);
|
||||
out << rst.encode(sla::PNGRasterEncoder{});
|
||||
out.close();
|
||||
#endif
|
||||
write_debug_stream("marchingsquares/" + objname + std::to_string(cntr) + ".png",
|
||||
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
|
||||
|
||||
ExPolygons layer_ = sla::raster_to_polygons(rst);
|
||||
// float delta = scaled(std::min(rst.pixel_dimensions().h_mm,
|
||||
@@ -454,21 +448,19 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
|
||||
// layer_ = expolygons_simplify(layer_, delta);
|
||||
|
||||
#ifndef NDEBUG
|
||||
SVG svg(objname + std::to_string(cntr) + ".svg", rstBBox(rst));
|
||||
svg.draw(layer_);
|
||||
svg.draw(layer, "green");
|
||||
svg.Close();
|
||||
#endif
|
||||
write_debug_svg("marchingsquares/" + objname + std::to_string(cntr) + ".svg", rstBBox(rst),
|
||||
[&](SVG &svg) {
|
||||
svg.draw(layer_);
|
||||
svg.draw(layer, "green");
|
||||
});
|
||||
|
||||
double layera = 0., layera_ = 0.;
|
||||
for (auto& p : layer)
|
||||
layera += p.area();
|
||||
for (auto& p : layer_)
|
||||
layera_ += p.area();
|
||||
#ifndef NDEBUG
|
||||
std::cout << cntr++ << std::endl;
|
||||
#endif
|
||||
++cntr;
|
||||
|
||||
double diff = std::abs(layera_ - layera);
|
||||
REQUIRE((diff <= 0.1 * layera || diff < scaled<double>(1.) * scaled<double>(1.)));
|
||||
|
||||
@@ -477,7 +469,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
|
||||
indexed_triangle_set out = slices_to_mesh(layers, bb.min.z(), double(lh), double(lh));
|
||||
|
||||
its_write_obj(out, "out_from_rasters.obj");
|
||||
write_debug_obj("marchingsquares/out_from_rasters.obj", out);
|
||||
}
|
||||
|
||||
TEST_CASE("Recreate object from rasters", "[SL1Import]") { recreate_object_from_rasters("frog_legs.obj", 0.05f); }
|
||||
|
||||
40
tests/libslic3r/test_model.cpp
Normal file
40
tests/libslic3r/test_model.cpp
Normal file
@@ -0,0 +1,40 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
// convex_hull_2d does not clip geometry below the bed, so these cases avoid
|
||||
// sinking transforms.
|
||||
TEST_CASE("A part's 2D convex hull is its footprint projected onto the bed", "[Model]")
|
||||
{
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
// Keep the cube's raw coordinates ([0,20] on every axis): the default
|
||||
// add_volume re-centers the geometry, which would move the footprint.
|
||||
object->add_volume(make_cube(20, 20, 20), ModelVolumeType::MODEL_PART, false);
|
||||
|
||||
SECTION("identity transform yields the 20 mm square") {
|
||||
const Polygon hull = object->convex_hull_2d(Geometry::Transformation{}.get_matrix());
|
||||
const BoundingBox bb = hull.bounding_box();
|
||||
CHECK(hull.size() == 4);
|
||||
CHECK(bb.min.x() == scaled(0.));
|
||||
CHECK(bb.min.y() == scaled(0.));
|
||||
CHECK(bb.max.x() == scaled(20.));
|
||||
CHECK(bb.max.y() == scaled(20.));
|
||||
}
|
||||
|
||||
SECTION("scaling and offset move and grow the footprint") {
|
||||
Geometry::Transformation t;
|
||||
t.set_scaling_factor({2, 2, 2}); // cube now spans [0,40]
|
||||
t.set_offset({10, 5, 0}); // then shift +10 in X, +5 in Y
|
||||
|
||||
const Polygon hull = object->convex_hull_2d(t.get_matrix());
|
||||
const BoundingBox bb = hull.bounding_box();
|
||||
CHECK(hull.size() == 4);
|
||||
CHECK(bb.min.x() == scaled(10.));
|
||||
CHECK(bb.min.y() == scaled(5.));
|
||||
CHECK(bb.max.x() == scaled(50.));
|
||||
CHECK(bb.max.y() == scaled(45.));
|
||||
}
|
||||
}
|
||||
57
tests/libslic3r/test_multimaterial_segmentation.cpp
Normal file
57
tests/libslic3r/test_multimaterial_segmentation.cpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
// MultiMaterialSegmentation.hpp declares boost::polygon traits for ColoredLine, so its
|
||||
// geometry/boost dependencies must be included first.
|
||||
#include <boost/polygon/polygon.hpp>
|
||||
#include "libslic3r/Line.hpp"
|
||||
#include "libslic3r/Flow.hpp"
|
||||
#include "libslic3r/MultiMaterialSegmentation.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("Multi-material segmentation resolves the outer-wall line width", "[MultiMaterialSegmentation][Regression]")
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
std::string description;
|
||||
double outer_value;
|
||||
bool outer_percent;
|
||||
double line_value;
|
||||
bool line_percent;
|
||||
std::vector<double> nozzle_diameters;
|
||||
int outer_wall_filament_id;
|
||||
double expected;
|
||||
};
|
||||
|
||||
auto c = GENERATE(values<Case>({
|
||||
{"absolute outer-wall width is used as-is", 0.6, false, 0.42, false, {0.4}, 1, 0.6},
|
||||
{"percent outer-wall width uses the nozzle", 120, true, 0.42, false, {0.5}, 1, 0.6},
|
||||
{"zero outer-wall width uses the line width", 0, false, 0.5, false, {0.4}, 1, 0.5},
|
||||
{"zero outer-wall width uses a percent line", 0, false, 100, true, {0.5}, 1, 0.5},
|
||||
{"zero width falls back to auto", 0, false, 0, false, {0.4}, 1, Flow::auto_extrusion_width(frExternalPerimeter, 0.4)},
|
||||
{"the auto fallback scales with the nozzle", 0, false, 0, false, {0.6}, 1, Flow::auto_extrusion_width(frExternalPerimeter, 0.6)},
|
||||
{"a percent width uses the outer wall's nozzle", 120, true, 0.42, false, {0.4, 0.8}, 2, 0.96},
|
||||
{"the auto width uses the outer wall's nozzle", 0, false, 0, false, {0.4, 0.8}, 2, Flow::auto_extrusion_width(frExternalPerimeter, 0.8)},
|
||||
{"an absolute width ignores the nozzle", 0.6, false, 0.42, false, {0.4, 0.8}, 2, 0.6},
|
||||
{"a zero percent width uses the line width", 0, true, 0.5, false, {0.4}, 1, 0.5},
|
||||
{"an unset filament id uses the first nozzle", 0, false, 0, false, {0.4, 0.8}, 0, Flow::auto_extrusion_width(frExternalPerimeter, 0.4)},
|
||||
{"an out-of-range filament id uses nozzle 1", 0, false, 0, false, {0.4, 0.8}, 5, Flow::auto_extrusion_width(frExternalPerimeter, 0.4)},
|
||||
}));
|
||||
|
||||
DYNAMIC_SECTION(c.description)
|
||||
{
|
||||
PrintConfig print_config;
|
||||
print_config.nozzle_diameter.values = c.nozzle_diameters;
|
||||
|
||||
PrintObjectConfig object_config;
|
||||
object_config.line_width = ConfigOptionFloatOrPercent(c.line_value, c.line_percent);
|
||||
|
||||
PrintRegionConfig region_config;
|
||||
region_config.outer_wall_line_width = ConfigOptionFloatOrPercent(c.outer_value, c.outer_percent);
|
||||
region_config.outer_wall_filament_id.value = c.outer_wall_filament_id;
|
||||
|
||||
REQUIRE_THAT(resolve_outer_wall_line_width(region_config, object_config, print_config),
|
||||
Catch::Matchers::WithinAbs(c.expected, 1e-9));
|
||||
}
|
||||
}
|
||||
31
tests/libslic3r/test_nozzle_volume_type.cpp
Normal file
31
tests/libslic3r/test_nozzle_volume_type.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("convert_to_nvt_type maps extruder variant strings to nozzle volume types", "[Config]")
|
||||
{
|
||||
SECTION("Direct Drive variants") {
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive Standard") == nvtStandard);
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive High Flow") == nvtHighFlow);
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive TPU High Flow") == nvtTPUHighFlow);
|
||||
}
|
||||
|
||||
SECTION("Bowden variants") {
|
||||
REQUIRE(convert_to_nvt_type("Bowden Standard") == nvtStandard);
|
||||
REQUIRE(convert_to_nvt_type("Bowden High Flow") == nvtHighFlow);
|
||||
}
|
||||
|
||||
SECTION("Unparsable strings fall back to hybrid") {
|
||||
REQUIRE(convert_to_nvt_type("Unknown Extruder") == nvtHybrid);
|
||||
REQUIRE(convert_to_nvt_type("") == nvtHybrid);
|
||||
REQUIRE(convert_to_nvt_type("High Flow") == nvtHybrid);
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive") == nvtHybrid);
|
||||
}
|
||||
|
||||
SECTION("Whitespace around the volume-type remainder is trimmed") {
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive High Flow ") == nvtHighFlow);
|
||||
REQUIRE(convert_to_nvt_type(" Bowden Standard") == nvtStandard);
|
||||
}
|
||||
}
|
||||
297
tests/libslic3r/test_ordering_strategies.cpp
Normal file
297
tests/libslic3r/test_ordering_strategies.cpp
Normal file
@@ -0,0 +1,297 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#define SLIC3R_TEST_HARNESS
|
||||
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/GCode/OrderingStrategies.hpp"
|
||||
#include "libslic3r/Geometry.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static double euclidean_path_length(const std::vector<size_t>& path, const Points& centers)
|
||||
{
|
||||
return tsp_cycle_path_length(path, centers);
|
||||
}
|
||||
|
||||
static bool has_crossings(const std::vector<size_t>& path, const Points& centers)
|
||||
{
|
||||
size_t pn = path.size();
|
||||
if (pn < 4) return false;
|
||||
for (size_t i = 0; i < pn; ++i) {
|
||||
size_t i_next = (i + 1) % pn;
|
||||
for (size_t j = i + 2; j < pn; ++j) {
|
||||
if (j == i_next) continue;
|
||||
if (j == (pn - 1) && i == 0) continue;
|
||||
size_t j_next = (j + 1) % pn;
|
||||
if (Geometry::segments_intersect(
|
||||
centers[path[i]], centers[path[i_next]],
|
||||
centers[path[j]], centers[path[j_next]])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool is_permutation(const std::vector<size_t>& path, size_t n)
|
||||
{
|
||||
if (path.size() != n) return false;
|
||||
std::unordered_set<size_t> seen(path.begin(), path.end());
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
if (seen.count(i) != 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Test fixtures ---
|
||||
|
||||
static Points make_grid_4x4()
|
||||
{
|
||||
Points pts;
|
||||
for (int row = 0; row < 4; ++row)
|
||||
for (int col = 0; col < 4; ++col)
|
||||
pts.emplace_back(100000 * col, 100000 * row);
|
||||
return pts;
|
||||
}
|
||||
|
||||
static Points make_linear_5()
|
||||
{
|
||||
Points pts;
|
||||
for (int i = 0; i < 5; ++i)
|
||||
pts.emplace_back(100000 * i, 0);
|
||||
return pts;
|
||||
}
|
||||
|
||||
static Points make_ring_8()
|
||||
{
|
||||
Points pts;
|
||||
constexpr double R = 100000.0;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
double angle = 2.0 * M_PI * i / 8.0;
|
||||
pts.emplace_back(static_cast<coord_t>(R * std::cos(angle)),
|
||||
static_cast<coord_t>(R * std::sin(angle)));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
static Points make_random_16()
|
||||
{
|
||||
// Deterministic "random" points via simple hash.
|
||||
Points pts;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
uint32_t h = static_cast<uint32_t>(i * 2654435761u);
|
||||
coord_t x = static_cast<coord_t>((h >> 16) & 0xFFFF) * 10;
|
||||
coord_t y = static_cast<coord_t>(h & 0xFFFF) * 10;
|
||||
pts.emplace_back(x, y);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// --- TSP Post-Processing Tests ---
|
||||
|
||||
TEST_CASE("tsp_2opt_improve reduces path length", "[TSPPostProcessing]") {
|
||||
Points centers = make_random_16();
|
||||
std::vector<size_t> path(centers.size());
|
||||
// Reverse half the path to create a deliberately bad ordering.
|
||||
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
|
||||
std::reverse(path.begin(), path.end() - path.size() / 2);
|
||||
|
||||
double before = euclidean_path_length(path, centers);
|
||||
tsp_2opt_improve(path, centers);
|
||||
double after = euclidean_path_length(path, centers);
|
||||
|
||||
REQUIRE(is_permutation(path, centers.size()));
|
||||
CHECK(after <= before);
|
||||
}
|
||||
|
||||
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
|
||||
Points centers = make_random_16();
|
||||
std::vector<size_t> path(centers.size());
|
||||
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
|
||||
// Create a crossing by reversing a middle segment.
|
||||
if (path.size() >= 4) {
|
||||
std::reverse(path.begin() + 1, path.end() - 1);
|
||||
}
|
||||
|
||||
tsp_remove_crossings(path, centers);
|
||||
CHECK(!has_crossings(path, centers));
|
||||
REQUIRE(is_permutation(path, centers.size()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
|
||||
Points centers = make_random_16();
|
||||
std::vector<size_t> path(centers.size());
|
||||
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
|
||||
|
||||
// Compute all possible closing edge lengths.
|
||||
size_t pn = path.size();
|
||||
double min_closing2 = std::numeric_limits<double>::max();
|
||||
for (size_t start = 0; start < pn; ++start) {
|
||||
size_t last = (start + pn - 1) % pn;
|
||||
double d2 = (centers[path[start]].cast<double>() - centers[path[last]].cast<double>()).squaredNorm();
|
||||
if (d2 < min_closing2) min_closing2 = d2;
|
||||
}
|
||||
|
||||
tsp_rotate_minimize_closing(path, centers);
|
||||
|
||||
// Closing edge should be the minimum possible.
|
||||
double actual_closing2 = (centers[path.front()].cast<double>() - centers[path.back()].cast<double>()).squaredNorm();
|
||||
CHECK(actual_closing2 == min_closing2);
|
||||
REQUIRE(is_permutation(path, centers.size()));
|
||||
}
|
||||
|
||||
TEST_CASE("tsp_cycle_path_length is correct for triangle", "[TSPPostProcessing]") {
|
||||
Points pts;
|
||||
pts.emplace_back(0, 0);
|
||||
pts.emplace_back(100000, 0);
|
||||
pts.emplace_back(50000, 86602); // equilateral ~100mm sides
|
||||
|
||||
std::vector<size_t> path = {0, 1, 2};
|
||||
double len = tsp_cycle_path_length(path, pts);
|
||||
// Perimeter of equilateral triangle with side ~100000.
|
||||
REQUIRE(len > 290000);
|
||||
REQUIRE(len < 310000);
|
||||
}
|
||||
|
||||
TEST_CASE("tsp_max_edge_length finds longest edge", "[TSPPostProcessing]") {
|
||||
Points pts;
|
||||
pts.emplace_back(0, 0);
|
||||
pts.emplace_back(100000, 0);
|
||||
pts.emplace_back(50000, 0);
|
||||
|
||||
std::vector<size_t> path = {0, 1, 2};
|
||||
double mx = tsp_max_edge_length(path, pts);
|
||||
// Longest edge is 0->1 = 100000.
|
||||
CHECK(mx == Catch::Approx(100000).margin(1));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Empty / Small Inputs ---
|
||||
|
||||
TEST_CASE("snake_core handles empty input", "[Snake]") {
|
||||
Points centers;
|
||||
auto path = snake_core(centers);
|
||||
REQUIRE(path.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("snake_core handles single point", "[Snake]") {
|
||||
Points pts{{100, 200}};
|
||||
CHECK(snake_core(pts) == std::vector<size_t>{0});
|
||||
}
|
||||
|
||||
TEST_CASE("snake_core handles two points", "[Snake]") {
|
||||
Points pts{{100, 200}, {300, 400}};
|
||||
auto p2 = snake_core(pts);
|
||||
|
||||
REQUIRE(is_permutation(p2, 2));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Grid Layout ---
|
||||
|
||||
TEST_CASE("snake produces good path on grid", "[Snake]") {
|
||||
Points centers = make_grid_4x4();
|
||||
auto path = snake_core(centers);
|
||||
|
||||
REQUIRE(is_permutation(path, centers.size()));
|
||||
CHECK(!has_crossings(path, centers));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Variable Row Spacing ---
|
||||
|
||||
TEST_CASE("snake handles variable Y spacing", "[Snake]") {
|
||||
// Rows at Y = 0, 50, 100, 1000 (large gap between last two rows).
|
||||
// The adaptive row detection should identify the tight cluster (0, 50, 100)
|
||||
// and the isolated row (1000) without splitting them incorrectly.
|
||||
Points pts;
|
||||
pts.emplace_back(0, 0); pts.emplace_back(100000, 0);
|
||||
pts.emplace_back(0, 50000); pts.emplace_back(100000, 50000);
|
||||
pts.emplace_back(0, 100000); pts.emplace_back(100000, 100000);
|
||||
pts.emplace_back(0, 1000000); pts.emplace_back(100000, 1000000);
|
||||
|
||||
auto path = snake_core(pts);
|
||||
REQUIRE(is_permutation(path, pts.size()));
|
||||
CHECK(!has_crossings(path, pts));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: All Points Same Y ---
|
||||
|
||||
TEST_CASE("snake handles all points on same Y", "[Snake]") {
|
||||
// All points share the same Y coordinate. This exercises the
|
||||
// division-by-zero guard (ys.size() == 1).
|
||||
Points pts;
|
||||
for (int i = 0; i < 6; ++i)
|
||||
pts.emplace_back(100000 * i, 50000);
|
||||
|
||||
auto path = snake_core(pts);
|
||||
REQUIRE(is_permutation(path, pts.size()));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Collinear Points ---
|
||||
|
||||
TEST_CASE("snake_core handles collinear points", "[Snake]") {
|
||||
Points centers = make_linear_5();
|
||||
|
||||
auto p2 = snake_core(centers);
|
||||
|
||||
REQUIRE(is_permutation(p2, centers.size()));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Ring Layout ---
|
||||
|
||||
TEST_CASE("snake_core produces valid paths on ring", "[Snake]") {
|
||||
Points centers = make_ring_8();
|
||||
|
||||
auto p2 = snake_core(centers);
|
||||
|
||||
REQUIRE(is_permutation(p2, centers.size()));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Random Layout ---
|
||||
|
||||
TEST_CASE("snake_core produces valid paths on random input", "[Snake]") {
|
||||
Points centers = make_random_16();
|
||||
|
||||
auto p2 = snake_core(centers);
|
||||
|
||||
REQUIRE(is_permutation(p2, centers.size()));
|
||||
}
|
||||
|
||||
// --- Quality Comparison Tests ---
|
||||
|
||||
TEST_CASE("snake has no crossings on random input", "[Snake]") {
|
||||
Points centers = make_random_16();
|
||||
auto path = snake_core(centers);
|
||||
|
||||
REQUIRE(is_permutation(path, centers.size()));
|
||||
CHECK(!has_crossings(path, centers));
|
||||
}
|
||||
|
||||
// --- Edge Cases ---
|
||||
|
||||
TEST_CASE("snake_core handles duplicate points", "[Snake]") {
|
||||
Points pts;
|
||||
pts.emplace_back(100, 200);
|
||||
pts.emplace_back(100, 200); // duplicate
|
||||
pts.emplace_back(300, 400);
|
||||
|
||||
auto p2 = snake_core(pts);
|
||||
|
||||
REQUIRE(p2.size() == pts.size());
|
||||
}
|
||||
|
||||
TEST_CASE("snake_core handles three points", "[Snake]") {
|
||||
Points pts;
|
||||
pts.emplace_back(0, 0);
|
||||
pts.emplace_back(100000, 0);
|
||||
pts.emplace_back(50000, 86602);
|
||||
|
||||
auto p2 = snake_core(pts);
|
||||
|
||||
REQUIRE(is_permutation(p2, 3));
|
||||
}
|
||||
@@ -52,6 +52,11 @@ SCENARIO("Placeholder parser scripting", "[PlaceholderParser]") {
|
||||
SECTION("math: round(-13.4)") { REQUIRE(parser.process("{round(-13.4)}") == "-13"); }
|
||||
SECTION("math: round(13.6)") { REQUIRE(parser.process("{round(13.6)}") == "14"); }
|
||||
SECTION("math: round(-13.6)") { REQUIRE(parser.process("{round(-13.6)}") == "-14"); }
|
||||
SECTION("math: round(13.5)") { REQUIRE(parser.process("{round(13.5)}") == "14"); }
|
||||
SECTION("math: floor(13.9)") { REQUIRE(parser.process("{floor(13.9)}") == "13"); }
|
||||
SECTION("math: floor(-13.1)") { REQUIRE(parser.process("{floor(-13.1)}") == "-14"); }
|
||||
SECTION("math: ceil(13.1)") { REQUIRE(parser.process("{ceil(13.1)}") == "14"); }
|
||||
SECTION("math: ceil(-13.9)") { REQUIRE(parser.process("{ceil(-13.9)}") == "-13"); }
|
||||
SECTION("math: digits(5, 15)") { REQUIRE(parser.process("{digits(5, 15)}") == " 5"); }
|
||||
SECTION("math: digits(5., 15)") { REQUIRE(parser.process("{digits(5., 15)}") == " 5"); }
|
||||
SECTION("math: zdigits(5, 15)") { REQUIRE(parser.process("{zdigits(5, 15)}") == "000000000000005"); }
|
||||
@@ -65,6 +70,21 @@ SCENARIO("Placeholder parser scripting", "[PlaceholderParser]") {
|
||||
SECTION("math: interpolate_table(13.84375892476, (0, 0), (20, 20))") { REQUIRE(std::stod(parser.process("{interpolate_table(13.84375892476, (0, 0), (20, 20))}")) == Catch::Approx(13.84375892476)); }
|
||||
SECTION("math: interpolate_table(13, (0, 0), (20, 20), (30, 20))") { REQUIRE(std::stod(parser.process("{interpolate_table(13, (0, 0), (20, 20), (30, 20))}")) == Catch::Approx(13.)); }
|
||||
SECTION("math: interpolate_table(25, (0, 0), (20, 20), (30, 20))") { REQUIRE(std::stod(parser.process("{interpolate_table(25, (0, 0), (20, 20), (30, 20))}")) == Catch::Approx(20.)); }
|
||||
// Only the grammar's built-in functions are callable; any other name is an undefined variable and throws.
|
||||
SECTION("math: a non-built-in function name throws") { REQUIRE_THROWS(parser.process("{sqrt(16)}")); }
|
||||
|
||||
// regex_replace(subject, /pattern/, replacement): the string-transform primitive.
|
||||
SECTION("regex_replace: strips a file extension") { REQUIRE(parser.process("{regex_replace(\"part.stl\", /\\.[^.]*$/, \"\")}") == "part"); }
|
||||
SECTION("regex_replace: leaves a non-matching dot untouched") { REQUIRE(parser.process("{regex_replace(\"Bracket v2.1\", /\\.stl$/, \"\")}") == "Bracket v2.1"); }
|
||||
SECTION("regex_replace: replaces every match") { REQUIRE(parser.process("{regex_replace(\"a-b-c\", /-/, \"_\")}") == "a_b_c"); }
|
||||
SECTION("regex_replace: replacement may reference a capture group") { REQUIRE(parser.process("{regex_replace(\"v12\", /v(\\d+)/, \"$1\")}") == "12"); }
|
||||
// The result is an ordinary string, usable in further expressions (the real filename-template shape).
|
||||
SECTION("regex_replace: result composes with concatenation") { REQUIRE(parser.process("{regex_replace(\"part.stl\", /\\.[^.]*$/, \"\") + \".gcode\"}") == "part.gcode"); }
|
||||
// A malformed pattern and a non-string subject are both hard errors.
|
||||
SECTION("regex_replace: an invalid pattern throws") { REQUIRE_THROWS(parser.process("{regex_replace(\"x\", /[/, \"\")}")); }
|
||||
SECTION("regex_replace: a non-string subject throws") { REQUIRE_THROWS(parser.process("{regex_replace(123, /2/, \"\")}")); }
|
||||
// Inside a skipped branch the subject is TYPE_EMPTY and the call must no-op (exercises the guard).
|
||||
SECTION("regex_replace: is skipped inside a false if-branch") { REQUIRE(parser.process("{if false}{regex_replace(\"x\", /x/, \"y\")}{endif}done") == "done"); }
|
||||
|
||||
// Test the "coFloatOrPercent" and "xxx_line_width" substitutions.
|
||||
// min_width_top_surface ratio_over inner_wall_line_width.
|
||||
@@ -130,6 +150,7 @@ SCENARIO("Placeholder parser variables", "[PlaceholderParser]") {
|
||||
|
||||
SECTION("create an int local variable") { REQUIRE(parser.process("{local myint = 33+2}{myint}", 0, nullptr, nullptr, nullptr) == "35"); }
|
||||
SECTION("create a string local variable") { REQUIRE(parser.process("{local mystr = \"mine\" + \"only\" + \"mine\"}{mystr}", 0, nullptr, nullptr, nullptr) == "mineonlymine"); }
|
||||
SECTION("regex_replace transforms a string variable") { REQUIRE(parser.process("{local n = \"part.stl\"}{regex_replace(n, /\\.[^.]*$/, \"\")}", 0, nullptr, nullptr, nullptr) == "part"); }
|
||||
SECTION("create a bool local variable") { REQUIRE(parser.process("{local mybool = 1 + 1 == 2}{mybool}", 0, nullptr, nullptr, nullptr) == "true"); }
|
||||
SECTION("create an int global variable") { REQUIRE(parser.process("{global myint = 33+2}{myint}", 0, nullptr, nullptr, &context_with_global_dict) == "35"); }
|
||||
SECTION("create a string global variable") { REQUIRE(parser.process("{global mystr = \"mine\" + \"only\" + \"mine\"}{mystr}", 0, nullptr, nullptr, &context_with_global_dict) == "mineonlymine"); }
|
||||
|
||||
@@ -5,28 +5,14 @@
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/AppConfig.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
struct TempPresetDir {
|
||||
fs::path path;
|
||||
|
||||
TempPresetDir()
|
||||
{
|
||||
path = fs::temp_directory_path() / fs::unique_path("orcaslicer-preset-%%%%-%%%%-%%%%");
|
||||
fs::create_directories(path);
|
||||
}
|
||||
|
||||
~TempPresetDir()
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(path, ec);
|
||||
}
|
||||
};
|
||||
|
||||
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
|
||||
{
|
||||
DynamicPrintConfig config(default_config);
|
||||
@@ -82,17 +68,17 @@ struct RenameTestCollection : public PresetCollection
|
||||
|
||||
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_PRINT_NAME / "User.json", "User");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
|
||||
|
||||
bundle.prints.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
const Preset *root_user = bundle.prints.find_preset("User");
|
||||
REQUIRE(root_user != nullptr);
|
||||
@@ -112,14 +98,14 @@ TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]
|
||||
|
||||
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
std::vector<std::string> result;
|
||||
int overwrite = 0;
|
||||
std::string file = (temp_dir.path / "legacy-bundle" / "Imported.json").string();
|
||||
const fs::path user_root = temp_dir.path / "user";
|
||||
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
|
||||
const fs::path user_root = temp_dir.path() / "user";
|
||||
|
||||
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
|
||||
fs::create_directories(user_root);
|
||||
@@ -252,7 +238,7 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
|
||||
|
||||
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
RenameTestCollection coll;
|
||||
|
||||
// Current parent, renamed from "Old Process".
|
||||
@@ -262,10 +248,10 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
|
||||
|
||||
// A user preset on disk that still inherits the OLD name.
|
||||
write_preset_with_inherits(coll.default_preset().config,
|
||||
temp_dir.path / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
|
||||
temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
coll.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions,
|
||||
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
const Preset *child = coll.find_preset("Child");
|
||||
@@ -279,17 +265,17 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
|
||||
|
||||
TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
|
||||
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
|
||||
|
||||
// A user filament that still inherits a removed "<vendor> Generic PLA" profile.
|
||||
write_preset_with_inherits(bundle.filaments.default_preset().config,
|
||||
temp_dir.path / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
|
||||
temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
bundle.filaments.load_presets(temp_dir.path.string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
const Preset *child = bundle.filaments.find_preset("MyPLA");
|
||||
@@ -464,3 +450,93 @@ TEST_CASE("Profile validator flags dangling and renamed preset references", "[Pr
|
||||
}
|
||||
}
|
||||
|
||||
// Under a shared override key, the last preset merged into the full config overwrote the others', so an
|
||||
// edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never
|
||||
// re-sliced. Per-type keys make that collision impossible; guard the scoping here.
|
||||
TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]")
|
||||
{
|
||||
// Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change.
|
||||
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides"));
|
||||
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides"));
|
||||
CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides"));
|
||||
|
||||
// ...and each key lives on exactly its own preset type's option list, so no two ever share a slot.
|
||||
const std::pair<Preset::Type, const std::vector<std::string>*> scopes[] = {
|
||||
{Preset::TYPE_PRINT, &Preset::print_options()},
|
||||
{Preset::TYPE_PRINTER, &Preset::printer_options()},
|
||||
{Preset::TYPE_FILAMENT, &Preset::filament_options()},
|
||||
};
|
||||
for (const auto &owner : scopes)
|
||||
for (const auto &scoped : scopes) {
|
||||
const std::string key = Preset::plugin_overrides_key(scoped.first);
|
||||
CAPTURE(owner.first, key);
|
||||
CHECK(contains(*owner.second, key) == (owner.first == scoped.first));
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// A standalone filament collection that exposes the protected library masking builder, so the Orca
|
||||
// Filament Library scenario can be set up without the full system-profile load pipeline.
|
||||
struct LibraryFilamentTestCollection : public PresetCollection
|
||||
{
|
||||
LibraryFilamentTestCollection()
|
||||
: PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
|
||||
{}
|
||||
using PresetCollection::update_library_profile_excluded_from;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
|
||||
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
|
||||
// with that printer and the plater combo box lists the shared alias twice.
|
||||
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
|
||||
{
|
||||
LibraryFilamentTestCollection filaments;
|
||||
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
|
||||
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
||||
VendorProfile vendor("Vendor");
|
||||
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
|
||||
vendor.name = "Vendor";
|
||||
|
||||
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
|
||||
Preset &preset = add_inmemory_preset(filaments, name);
|
||||
preset.alias = "Generic ABS";
|
||||
preset.vendor = &owner;
|
||||
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
|
||||
};
|
||||
|
||||
add_filament(library, "Generic ABS @System", {});
|
||||
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
|
||||
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
|
||||
|
||||
filaments.update_library_profile_excluded_from();
|
||||
|
||||
const Preset *generic = filaments.find_preset("Generic ABS @System");
|
||||
REQUIRE(generic != nullptr);
|
||||
CHECK(generic->m_excluded_from.count("Printer A") == 1);
|
||||
CHECK(generic->m_excluded_from.count("Printer B") == 1);
|
||||
CHECK(generic->m_excluded_from.size() == 2);
|
||||
|
||||
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
|
||||
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
|
||||
REQUIRE(specific != nullptr);
|
||||
CHECK(specific->m_excluded_from.empty());
|
||||
|
||||
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
|
||||
add_inmemory_preset(printers, "Printer A");
|
||||
add_inmemory_preset(printers, "Printer C");
|
||||
const Preset *printer_a = printers.find_preset("Printer A");
|
||||
const Preset *printer_c = printers.find_preset("Printer C");
|
||||
REQUIRE(printer_a != nullptr);
|
||||
REQUIRE(printer_c != nullptr);
|
||||
|
||||
const PresetWithVendorProfile generic_lib(*generic, &library);
|
||||
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
|
||||
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
|
||||
}
|
||||
|
||||
|
||||
35
tests/libslic3r/test_preset_diff.cpp
Normal file
35
tests/libslic3r/test_preset_diff.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
// Regression test for the python-plugin branch's intentional divergence from
|
||||
// upstream in add_correct_opts_to_diff() (src/libslic3r/Preset.cpp): a vector
|
||||
// option entry whose index is beyond the reference vector's length is reported
|
||||
// dirty even when it duplicates an existing value. On main these duplicates
|
||||
// were NOT flagged. See the comment on add_correct_opts_to_diff() in src/libslic3r/Preset.cpp.
|
||||
TEST_CASE("deep_diff flags new vector entries that duplicate values[0]", "[PresetDiff][Config]")
|
||||
{
|
||||
// reference: single-extruder vector (one entry)
|
||||
Preset reference(Preset::TYPE_PRINTER, "ref");
|
||||
reference.config.set_key_value("nozzle_diameter", new ConfigOptionFloats{0.4});
|
||||
|
||||
// edited: a second extruder entry was added whose value duplicates the first
|
||||
Preset edited(Preset::TYPE_PRINTER, "edited");
|
||||
edited.config.set_key_value("nozzle_diameter", new ConfigOptionFloats{0.4, 0.4});
|
||||
|
||||
// deep_compare = true routes through deep_diff() -> add_correct_opts_to_diff()
|
||||
std::vector<std::string> diff =
|
||||
PresetCollection::dirty_options(&edited, &reference, /*deep_compare=*/true);
|
||||
|
||||
// The new index #1 is reported dirty even though 0.4 == values[0] (0.4).
|
||||
REQUIRE(std::find(diff.begin(), diff.end(), "nozzle_diameter#1") != diff.end());
|
||||
|
||||
// Sanity: the unchanged existing index #0 is NOT reported, so the rule is
|
||||
// specific to new indices rather than flagging the whole vector.
|
||||
REQUIRE(std::find(diff.begin(), diff.end(), "nozzle_diameter#0") == diff.end());
|
||||
}
|
||||
983
tests/libslic3r/test_toolordering_nozzle_group.cpp
Normal file
983
tests/libslic3r/test_toolordering_nozzle_group.cpp
Normal file
@@ -0,0 +1,983 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/FilamentGroupUtils.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/GCode/ToolOrdering.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
// H2C/A2L multi-nozzle filament grouping core.
|
||||
//
|
||||
// These tests pin the behaviour of the grouping result type
|
||||
// (Slic3r::MultiNozzleUtils::LayeredNozzleGroupResult) that GCode consumes via
|
||||
// group_result->get_nozzle_id(filament, layer) and
|
||||
// group_result->get_first_nozzle_for_filament(filament)->group_id.
|
||||
//
|
||||
// The central requirement is ZERO behaviour change for existing (single-nozzle)
|
||||
// printers: with extruder_max_nozzle_count == 1 per extruder the result collapses
|
||||
// to the classic filament->extruder grouping (nozzle id == extruder id).
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::MultiNozzleUtils;
|
||||
|
||||
namespace {
|
||||
// Build a trivial "one logical nozzle per extruder" list, the single-nozzle case
|
||||
// that every current printer profile produces.
|
||||
std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
|
||||
{
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
for (int e = 0; e < extruder_count; ++e) {
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard;
|
||||
n.extruder_id = e;
|
||||
n.group_id = e; // one nozzle per extruder => nozzle id == extruder id
|
||||
nozzle_list.push_back(n);
|
||||
}
|
||||
return nozzle_list;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
|
||||
REQUIRE(opt != nullptr); // extruder_max_nozzle_count must be a real config option
|
||||
|
||||
// extruder_nozzle_stats must be a real config option so printer profiles and
|
||||
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
|
||||
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
|
||||
|
||||
auto has_multiple_nozzle = [](const std::vector<int> &values) {
|
||||
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
|
||||
};
|
||||
|
||||
// Default for every existing printer: 1 nozzle per extruder => gate is closed.
|
||||
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
|
||||
|
||||
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
|
||||
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
|
||||
}
|
||||
|
||||
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
|
||||
{
|
||||
SECTION("single extruder => all filaments map to nozzle 0")
|
||||
{
|
||||
auto nozzle_list = single_nozzle_per_extruder(1);
|
||||
// 3 filaments, all assigned to the single extruder 0.
|
||||
std::vector<int> filament_nozzle_map = {0, 0, 0};
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(filament_nozzle_map, nozzle_list, used_filaments);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
REQUIRE(group.get_nozzle_id(f) == 0);
|
||||
REQUIRE(group.get_extruder_id(f) == 0);
|
||||
auto first = group.get_first_nozzle_for_filament(f);
|
||||
REQUIRE(first.has_value());
|
||||
REQUIRE(first->group_id == 0);
|
||||
}
|
||||
REQUIRE_FALSE(group.is_support_dynamic_nozzle_map());
|
||||
}
|
||||
|
||||
SECTION("dual extruder => nozzle id equals the classic extruder grouping")
|
||||
{
|
||||
auto nozzle_list = single_nozzle_per_extruder(2);
|
||||
// filament -> extruder map (the map Orca's reorder already computes).
|
||||
std::vector<int> filament_map = {0, 1, 0, 1};
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2, 3};
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(filament_map, nozzle_list, used_filaments);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
REQUIRE(group.get_nozzle_id(0) == 0);
|
||||
REQUIRE(group.get_nozzle_id(1) == 1);
|
||||
REQUIRE(group.get_nozzle_id(2) == 0);
|
||||
REQUIRE(group.get_nozzle_id(3) == 1);
|
||||
// With one nozzle per extruder, nozzle id and extruder id agree.
|
||||
for (int f = 0; f < 4; ++f)
|
||||
REQUIRE(group.get_nozzle_id(f) == group.get_extruder_id(f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("H2C multi-nozzle: filaments get distinct nozzles on the 6-nozzle extruder", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// Synthetic H2C-like config: 2 extruders, extruder_max_nozzle_count = {1, 6},
|
||||
// 4 filaments all assigned to extruder 1 (0-based). Each filament requests a
|
||||
// distinct logical nozzle cluster (as the grouping algorithm would emit), so the
|
||||
// create() overload must resolve them to 4 distinct physical nozzles.
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2, 3};
|
||||
std::vector<int> filament_map = {1, 1, 1, 1}; // extruder 1
|
||||
std::vector<int> filament_volume_map = {0, 0, 0, 0}; // nvtStandard
|
||||
std::vector<int> filament_nozzle_map = {0, 1, 2, 3}; // distinct clusters
|
||||
|
||||
std::vector<std::map<NozzleVolumeType, int>> nozzle_count(2);
|
||||
nozzle_count[0] = {}; // extruder 0: 1-nozzle (unused here)
|
||||
nozzle_count[1] = {{nvtStandard, 6}}; // extruder 1: 6-nozzle cluster
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(
|
||||
used_filaments, filament_map, filament_volume_map, filament_nozzle_map, nozzle_count, 0.4f);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
// All four filaments live on extruder 1, on four distinct physical nozzles.
|
||||
std::set<int> distinct_nozzles;
|
||||
for (int f = 0; f < 4; ++f) {
|
||||
REQUIRE(group.get_extruder_id(f) == 1);
|
||||
int nid = group.get_nozzle_id(f);
|
||||
REQUIRE(nid >= 0);
|
||||
distinct_nozzles.insert(nid);
|
||||
}
|
||||
REQUIRE(distinct_nozzles.size() == 4);
|
||||
|
||||
// get_nozzle_id must be stable across layers (no per-layer / selector map here).
|
||||
for (int f = 0; f < 4; ++f) {
|
||||
int base = group.get_nozzle_id(f, -1);
|
||||
REQUIRE(group.get_nozzle_id(f, 0) == base);
|
||||
REQUIRE(group.get_nozzle_id(f, 5) == base);
|
||||
}
|
||||
|
||||
// first-nozzle lookup agrees with the per-layer lookup for a static map.
|
||||
for (int f = 0; f < 4; ++f) {
|
||||
auto first = group.get_first_nozzle_for_filament(f);
|
||||
REQUIRE(first.has_value());
|
||||
REQUIRE(first->extruder_id == 1);
|
||||
REQUIRE(first->group_id == group.get_nozzle_id(f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("H2C dynamic selector: per-layer nozzle ids reach the g-code surface", "[ToolOrdering][H2C][Dynamic]")
|
||||
{
|
||||
// The per-layer regroup engine
|
||||
// (plan_filament_mapping_and_order_by_combo_ranges -> 4-arg LayeredNozzleGroupResult::create)
|
||||
// produces a *selector* result whose filament->nozzle map varies across layers. This is exactly
|
||||
// what GCode reads for H2C dynamic mode: hotend_id_for_gcode_placeholder /
|
||||
// nozzle_id_for_gcode_placeholder call group->is_support_dynamic_nozzle_map() and, when true,
|
||||
// group->get_nozzle_id(filament, layer) / get_first_nozzle_for_filament(filament). Here we build
|
||||
// the selector result directly (the engine's output shape) and assert those accessors return
|
||||
// per-layer values -- the surface that "goes live" only in dynamic mode. The static path (every
|
||||
// other test above) keeps is_support_dynamic_nozzle_map() == false and a stable nozzle id, so its
|
||||
// g-code is unchanged.
|
||||
|
||||
// H2C-like fleet: extruder 0 = 1 nozzle (group 0), extruder 1 = a 3-nozzle rack (groups 1..3).
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
for (int g = 0; g < 4; ++g) {
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard;
|
||||
n.extruder_id = (g == 0) ? 0 : 1;
|
||||
n.group_id = g;
|
||||
nozzle_list.push_back(n);
|
||||
}
|
||||
|
||||
// Three filaments; filament 2 is reassigned from physical nozzle 2 (layers 0-1) to nozzle 3
|
||||
// (layers 2-3) by the per-layer selector -- the case that sets support_dynamic_nozzle_map.
|
||||
std::vector<std::vector<int>> layer_filament_nozzle_maps = {
|
||||
{0, 1, 2}, // layer 0
|
||||
{0, 1, 2}, // layer 1
|
||||
{0, 1, 3}, // layer 2: filament 2 moved to nozzle 3
|
||||
{0, 1, 3}, // layer 3
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> layer_filament_sequences = {
|
||||
{0, 1, 2}, {0, 1, 2}, {0, 1, 2}, {0, 1, 2},
|
||||
};
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(layer_filament_nozzle_maps, nozzle_list, used_filaments, layer_filament_sequences);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
// The selector is active: a filament maps to more than one physical nozzle across layers.
|
||||
REQUIRE(group.is_support_dynamic_nozzle_map());
|
||||
|
||||
// Per-layer hotend/nozzle ids -- the values the dynamic g-code placeholders emit.
|
||||
REQUIRE(group.get_nozzle_id(2, 0) == 2);
|
||||
REQUIRE(group.get_nozzle_id(2, 1) == 2);
|
||||
REQUIRE(group.get_nozzle_id(2, 2) == 3); // reassigned on layer 2
|
||||
REQUIRE(group.get_nozzle_id(2, 3) == 3);
|
||||
REQUIRE(group.get_extruder_id(2, 0) == 1);
|
||||
REQUIRE(group.get_extruder_id(2, 2) == 1);
|
||||
|
||||
// Unmoved filaments keep a stable id across layers.
|
||||
REQUIRE(group.get_nozzle_id(0, 0) == 0);
|
||||
REQUIRE(group.get_nozzle_id(0, 3) == 0);
|
||||
REQUIRE(group.get_nozzle_id(1, 0) == 1);
|
||||
REQUIRE(group.get_nozzle_id(1, 3) == 1);
|
||||
|
||||
// first-nozzle lookup (used by the *_first_* placeholders / start g-code) is the first layer's id.
|
||||
auto first2 = group.get_first_nozzle_for_filament(2);
|
||||
REQUIRE(first2.has_value());
|
||||
REQUIRE(first2->group_id == 2);
|
||||
|
||||
// every physical nozzle a filament visits is reported (3mf metadata / nozzle_diameters_by_nozzle_id).
|
||||
std::set<int> fil2_nozzles;
|
||||
for (const auto &n : group.get_nozzles_for_filament(2))
|
||||
fil2_nozzles.insert(n.group_id);
|
||||
REQUIRE(fil2_nozzles == std::set<int>({2, 3}));
|
||||
}
|
||||
|
||||
TEST_CASE("Multi-nozzle reorder tolerates a filament with no nozzle (RL-48)", "[ToolOrdering][H2C][Dynamic]")
|
||||
{
|
||||
// The per-layer engine can hand reorder_filaments_for_multi_nozzle_extruder a group result that
|
||||
// resolves no nozzle for a layer's filament (a degenerate/malformed input where a layer references
|
||||
// a filament index outside the grouping map). Unguarded, that dereferences std::max_element() on an
|
||||
// empty extruder set (SIGSEGV). The guard must instead emit each layer's filaments in order and
|
||||
// return, so a bad input degrades gracefully rather than crashing.
|
||||
auto nozzle_list = single_nozzle_per_extruder(2);
|
||||
std::vector<int> filament_nozzle_map = {0}; // map only covers filament 0
|
||||
auto group_opt = LayeredNozzleGroupResult::create(filament_nozzle_map, nozzle_list, std::vector<unsigned int>{0});
|
||||
REQUIRE(group_opt.has_value());
|
||||
|
||||
std::vector<unsigned int> filament_lists = {3}; // filament 3 resolves to no nozzle
|
||||
std::vector<std::vector<unsigned int>> layer_filaments = {{3}, {3}};
|
||||
std::vector<std::vector<std::vector<float>>> flush_matrix(2, {{0.f}}); // unused on the guard path
|
||||
std::vector<std::vector<unsigned int>> sequences;
|
||||
|
||||
REQUIRE_NOTHROW(reorder_filaments_for_multi_nozzle_extruder(filament_lists, *group_opt, layer_filaments, flush_matrix, nullptr, &sequences));
|
||||
// Each layer still gets a valid sequence (its own filaments) — no reorder, no crash.
|
||||
REQUIRE(sequences.size() == layer_filaments.size());
|
||||
REQUIRE(sequences[0] == std::vector<unsigned int>{3});
|
||||
REQUIRE(sequences[1] == std::vector<unsigned int>{3});
|
||||
}
|
||||
|
||||
// The round-robin build_multi_nozzle_group_result adapter was superseded by the
|
||||
// nozzle-centric FilamentGroup engine (get_recommended_filament_maps now decides nozzle co-location
|
||||
// by flush cost, not round-robin). The two former pipeline tests are dropped:
|
||||
// * H2C multi-nozzle physical-nozzle resolution (6-arg create) is covered above by the
|
||||
// "H2C multi-nozzle: filaments get distinct nozzles" case;
|
||||
// * the single-nozzle "nozzle id == extruder id" degradation is covered above by the
|
||||
// "Single-nozzle grouping" case (build_default_nozzle_list + 3-arg create is the exact path the
|
||||
// gate-closed branch and by-object fallback use);
|
||||
// * end-to-end H2C/H2D grouping co-location is now pinned by the filament_group golden suite
|
||||
// (tests/filament_group, config_b/config_c).
|
||||
|
||||
TEST_CASE("extruder_nozzle_stats round-trips through save/parse", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The per-extruder nozzle inventory must survive save_extruder_nozzle_stats_to_string ->
|
||||
// get_extruder_nozzle_stats unchanged, so printer presets and 3mf projects persist it.
|
||||
std::vector<std::map<NozzleVolumeType, int>> stats = {
|
||||
{{nvtStandard, 1}}, // extruder 0: single standard nozzle
|
||||
{{nvtStandard, 5}, {nvtHighFlow, 1}}, // extruder 1: 6-nozzle mixed cluster
|
||||
};
|
||||
REQUIRE(get_extruder_nozzle_stats(save_extruder_nozzle_stats_to_string(stats)) == stats);
|
||||
}
|
||||
|
||||
// The filament-change-time model (MultiNozzleUtils::simulate_filament_change_time) is self-contained
|
||||
// analytic code with no slicing-pipeline caller yet; these fixtures pin its numeric output so future
|
||||
// changes and its first consumer (the filament_group golden harness) build on a locked model. Expected
|
||||
// values are hand-traced through the AMS -> selector -> extruder transport model.
|
||||
TEST_CASE("Filament-change-time model matches the BBS analytic simulation", "[MultiNozzle][H2C][ChangeTime]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
// Load/unload constants mirror the golden config_c change_time_params
|
||||
// (selector 1/1, standard 3/2): a selector move costs 1, a full AMS load 3 / unload 2.
|
||||
FilamentChangeTimeParams params;
|
||||
params.selector_load_time = 1.0f;
|
||||
params.selector_unload_time = 1.0f;
|
||||
params.standard_load_time = 3.0f;
|
||||
params.standard_unload_time = 2.0f;
|
||||
|
||||
// One extruder carrying one physical nozzle (nozzle id == extruder id == 0).
|
||||
std::vector<NozzleInfo> nozzle_list(1);
|
||||
nozzle_list[0].diameter = "0.4";
|
||||
nozzle_list[0].volume_type = nvtStandard;
|
||||
nozzle_list[0].extruder_id = 0;
|
||||
nozzle_list[0].group_id = 0;
|
||||
|
||||
// Two filaments in distinct AMS groups, printed in the order A, B, A on nozzle 0.
|
||||
std::vector<int> logical_filaments = {0, 1};
|
||||
std::vector<int> group_of_filament = {0, 1};
|
||||
std::vector<int> filament_change_seq = {0, 1, 0};
|
||||
std::vector<int> nozzle_change_seq = {0, 0, 0};
|
||||
|
||||
SECTION("no AMS pre-load: each change is a full AMS<->extruder transport")
|
||||
{
|
||||
auto r = simulate_filament_change_time(
|
||||
logical_filaments, nozzle_list, filament_change_seq, nozzle_change_seq,
|
||||
group_of_filament, params, /*ams_preload_enabled=*/{}, /*calc_sliced_time=*/true);
|
||||
// load0(3) + [unload0(2)+load1(3)] + [unload1(2)+load0(3)] = 13
|
||||
REQUIRE_THAT(r.actual_time, WithinAbs(13.0, 1e-6));
|
||||
// Single nozzle, no selector overlap => slicer estimate equals the actual time.
|
||||
REQUIRE_THAT(r.sliced_time, WithinAbs(13.0, 1e-6));
|
||||
}
|
||||
|
||||
SECTION("AMS pre-load overlaps transport, shrinking the actual time")
|
||||
{
|
||||
std::vector<bool> preload = {true, true};
|
||||
auto r = simulate_filament_change_time(
|
||||
logical_filaments, nozzle_list, filament_change_seq, nozzle_change_seq,
|
||||
group_of_filament, params, preload, /*calc_sliced_time=*/false);
|
||||
// Pre-loading the next filament into the selector runs in parallel with the current
|
||||
// extruder move, so the selector<->extruder legs dominate: 3 + (1+1) + (1+1) = 7.
|
||||
REQUIRE_THAT(r.actual_time, WithinAbs(7.0, 1e-6));
|
||||
}
|
||||
|
||||
SECTION("degenerate inputs return zero")
|
||||
{
|
||||
auto r = simulate_filament_change_time({}, nozzle_list, filament_change_seq,
|
||||
nozzle_change_seq, {}, params);
|
||||
REQUIRE_THAT(r.actual_time, WithinAbs(0.0, 1e-6));
|
||||
REQUIRE_THAT(r.sliced_time, WithinAbs(0.0, 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("NozzleStatusRecorder tracks nozzle/extruder occupancy", "[MultiNozzle][H2C][ChangeTime]")
|
||||
{
|
||||
NozzleStatusRecorder rec;
|
||||
REQUIRE(rec.is_nozzle_empty(0));
|
||||
REQUIRE(rec.get_filament_in_nozzle(0) == -1);
|
||||
REQUIRE(rec.get_nozzle_in_extruder(0) == -1);
|
||||
|
||||
rec.set_nozzle_status(2, 5, 1); // nozzle 2 holds filament 5, mounted on extruder 1
|
||||
REQUIRE_FALSE(rec.is_nozzle_empty(2));
|
||||
REQUIRE(rec.get_filament_in_nozzle(2) == 5);
|
||||
REQUIRE(rec.get_nozzle_in_extruder(1) == 2);
|
||||
|
||||
rec.clear_nozzle_status(2);
|
||||
REQUIRE(rec.is_nozzle_empty(2));
|
||||
REQUIRE(rec.get_filament_in_nozzle(2) == -1);
|
||||
// Clearing a nozzle leaves the extruder->nozzle association intact.
|
||||
REQUIRE(rec.get_nozzle_in_extruder(1) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Hybrid nozzle stats resolve to concrete volume types", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// Extruder 0 is Standard-only; extruder 1 carries a mixed Standard + High Flow inventory
|
||||
// (the "Hybrid" flow selection). The write-back pipeline persists get_volume_map(), so the
|
||||
// result must always carry concrete per-filament volume types, never the Hybrid seed.
|
||||
auto stats = get_extruder_nozzle_stats({"Standard#1", "Standard#1|High Flow#1"});
|
||||
REQUIRE(stats.size() == 2);
|
||||
REQUIRE(stats[1].size() == 2);
|
||||
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
std::vector<int> filament_map = {0, 1, 1}; // 0-based extruder ids
|
||||
std::vector<int> volume_requests = {(int) nvtStandard, (int) nvtHighFlow, (int) nvtStandard};
|
||||
std::vector<int> nozzle_requests = {0, 1, 2}; // distinct logical nozzles
|
||||
|
||||
auto group = LayeredNozzleGroupResult::create(used_filaments, filament_map, volume_requests, nozzle_requests, stats, 0.4f);
|
||||
REQUIRE(group.has_value());
|
||||
|
||||
auto volume_map = group->get_volume_map();
|
||||
REQUIRE(volume_map == volume_requests);
|
||||
for (auto fid : used_filaments)
|
||||
REQUIRE(volume_map[fid] != (int) nvtHybrid);
|
||||
|
||||
// The Hybrid seed itself matches no physical nozzle: such a request is unsatisfiable.
|
||||
std::vector<int> hybrid_requests = {(int) nvtStandard, (int) nvtHybrid, (int) nvtStandard};
|
||||
REQUIRE_FALSE(LayeredNozzleGroupResult::create(used_filaments, filament_map, hybrid_requests, nozzle_requests, stats, 0.4f).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("update_used_filament_values merges only used filaments", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The config write-back merges the engine's per-filament values over the config baseline:
|
||||
// used filaments adopt the engine value, unused filaments keep their config assignment.
|
||||
std::vector<int> old_values = {1, 1, 2, 1};
|
||||
std::vector<int> new_values = {2, 2, 1, 2};
|
||||
std::vector<unsigned int> used = {0, 2};
|
||||
|
||||
auto merged = FilamentGroupUtils::update_used_filament_values(old_values, new_values, used);
|
||||
REQUIRE(merged == std::vector<int>{2, 1, 1, 1});
|
||||
|
||||
// No used filaments => the config baseline is returned untouched.
|
||||
REQUIRE(FilamentGroupUtils::update_used_filament_values(old_values, new_values, {}) == old_values);
|
||||
}
|
||||
|
||||
TEST_CASE("Print config-index resolvers pick per-filament Hybrid slots", "[Print][H2C]")
|
||||
{
|
||||
// A 2-extruder printer whose second extruder is Hybrid (Standard + High Flow nozzles).
|
||||
// The preset-style variant columns carry one column per (extruder x volume type); apply()
|
||||
// expands them to the 3-slot layout [e1-Std, e2-Std, e2-HF].
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30., 200., 50., 500.};
|
||||
|
||||
// Three filaments: 0 -> extruder 1 (Std), 1 -> extruder 2 (Std), 2 -> extruder 2 (High Flow).
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
|
||||
// Stub grouping result mirroring the maps above: one nozzle per (extruder, volume type).
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
{
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtStandard; n.extruder_id = 1; n.group_id = 1; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 1; n.group_id = 2; nozzle_list.push_back(n);
|
||||
}
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
auto group = LayeredNozzleGroupResult::create(std::vector<int>{0, 1, 2}, nozzle_list, used_filaments);
|
||||
REQUIRE(group.has_value());
|
||||
print.set_nozzle_group_result(std::make_shared<LayeredNozzleGroupResult>(*group));
|
||||
|
||||
// The write-back re-expands the config and refreshes the resolver caches.
|
||||
print.update_filament_maps_to_config({1, 2, 2}, {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow}, {0, 1, 2});
|
||||
|
||||
// The expansion must have produced the 3-slot layout the resolvers index into.
|
||||
const auto ®ion_config = print.default_region_config();
|
||||
REQUIRE(region_config.print_extruder_variant.values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"}));
|
||||
REQUIRE(region_config.print_extruder_id.values == std::vector<int>({1, 2, 2}));
|
||||
|
||||
SECTION("each filament resolves to its own (extruder x volume type) slot") {
|
||||
REQUIRE(print.get_nozzle_config_index(0, 0) == 0); // extruder 1, Standard
|
||||
REQUIRE(print.get_nozzle_config_index(1, 0) == 1); // extruder 2, Standard
|
||||
REQUIRE(print.get_nozzle_config_index(2, 0) == 2); // extruder 2, High Flow
|
||||
}
|
||||
|
||||
SECTION("without a group result the resolver falls back to the filament's extruder slot") {
|
||||
print.set_nozzle_group_result(nullptr);
|
||||
REQUIRE(print.get_nozzle_config_index(0, 0) == 0);
|
||||
REQUIRE(print.get_nozzle_config_index(1, 0) == 1);
|
||||
REQUIRE(print.get_nozzle_config_index(2, 0) == 1); // extruder slot, not the High Flow slot
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Re-applying an unchanged config after slicing keeps the result valid", "[Print][H2C]")
|
||||
{
|
||||
// apply() rebuilds m_config.filament_map_2 to the real per-filament slot map, while the
|
||||
// incoming full config only ever carries the ConfigDef default for it. The engine-derived
|
||||
// key must therefore be kept out of the apply diff: the GUI re-applies right after slicing
|
||||
// completes, and a phantom filament_map_2 diff would invalidate every freshly sliced result
|
||||
// on any multi-extruder printer.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow};
|
||||
|
||||
Model model;
|
||||
ModelObject *object = model.add_object("cube", "", make_cube(20, 20, 20));
|
||||
object->add_instance()->set_offset(Vec3d(100., 100., 0.));
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
}
|
||||
|
||||
TEST_CASE("A degenerate process variant map on a custom multi-extruder printer slices to a stable result", "[Print][Regression]")
|
||||
{
|
||||
// Non-BBL multi-extruder printers get machine-scope variant columns synthesized on preset
|
||||
// load (extend_extruder_variant), but nothing ships process-scope print_extruder_id /
|
||||
// print_extruder_variant: presets and 3mf project configs carry the length-1 defaults. The
|
||||
// apply-time expansion must synthesize the process columns from extruder_variant_list;
|
||||
// otherwise the failed per-extruder lookups collapse the per-extruder retract overrides
|
||||
// during slicing and the post-slice re-apply invalidates every fresh result, forever.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_num_extruders(5);
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4, 0.4};
|
||||
// per-extruder machine values that a first-slot collapse would destroy
|
||||
config.option<ConfigOptionPercents>("retract_before_wipe", true)->values = {100., 70., 70., 70., 100.};
|
||||
config.option<ConfigOptionEnumsGeneric>("z_hop_types", true)->values = {zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope};
|
||||
// filament presets carry the nullable override twins (all-nil = "no override"); they are what
|
||||
// routes the machine values through apply_override in the in-slice override recompute
|
||||
config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values =
|
||||
std::vector<double>(5, ConfigOptionPercentsNullable::nil_value());
|
||||
config.option<ConfigOptionEnumsGenericNullable>("filament_z_hop_types", true)->values =
|
||||
std::vector<int>(5, ConfigOptionEnumsGenericNullable::nil_value());
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = std::vector<double>(5, 1.75);
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 3, 4, 1};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance()->set_offset(Vec3d(100., 100., 0.));
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
// BackgroundSlicingProcess reads the engine-computed maps back into the plate config after
|
||||
// slicing; the next apply overlays that written-back state.
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = print.get_filament_maps();
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = print.get_filament_volume_maps();
|
||||
config.option<ConfigOptionInts>("filament_nozzle_map", true)->values = print.get_filament_nozzle_maps();
|
||||
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status == PrintBase::APPLY_STATUS_UNCHANGED);
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
// the per-extruder machine values must survive the in-slice override recompute
|
||||
REQUIRE(print.config().retract_before_wipe.values == std::vector<double>({100., 70., 70., 70., 100.}));
|
||||
REQUIRE(print.config().z_hop_types.values == std::vector<int>({zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope}));
|
||||
}
|
||||
|
||||
TEST_CASE("normalize_nozzle_map_per_layer makes per-filament assignments gap-free", "[MultiNozzle][H2C][Dynamic]")
|
||||
{
|
||||
SECTION("gaps inherit the last used nozzle, entries on used layers stay untouched") {
|
||||
// Filament 1 extrudes on layers 0 (nozzle 1) and 3 (nozzle 2); the planner leaves stale
|
||||
// entries on the layers in between.
|
||||
std::vector<std::vector<int>> maps = {
|
||||
{0, 1},
|
||||
{0, -1}, // filament 1 idle
|
||||
{0, -1}, // filament 1 idle
|
||||
{0, 2},
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> filaments = {{0, 1}, {0}, {0}, {0, 1}};
|
||||
|
||||
normalize_nozzle_map_per_layer(maps, filaments);
|
||||
|
||||
REQUIRE(maps[0] == std::vector<int>({0, 1}));
|
||||
REQUIRE(maps[1] == std::vector<int>({0, 1})); // carried forward
|
||||
REQUIRE(maps[2] == std::vector<int>({0, 1})); // carried forward
|
||||
REQUIRE(maps[3] == std::vector<int>({0, 2})); // used layer untouched
|
||||
}
|
||||
|
||||
SECTION("layers before a filament's first use inherit its first nozzle") {
|
||||
std::vector<std::vector<int>> maps = {
|
||||
{0, -1},
|
||||
{0, -1},
|
||||
{0, 3}, // filament 1 first extrudes here
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> filaments = {{0}, {0}, {0, 1}};
|
||||
|
||||
normalize_nozzle_map_per_layer(maps, filaments);
|
||||
|
||||
REQUIRE(maps[0] == std::vector<int>({0, 3})); // back-filled
|
||||
REQUIRE(maps[1] == std::vector<int>({0, 3})); // back-filled
|
||||
REQUIRE(maps[2] == std::vector<int>({0, 3}));
|
||||
}
|
||||
|
||||
SECTION("empty and ragged inputs are safe no-ops") {
|
||||
std::vector<std::vector<int>> empty_maps;
|
||||
std::vector<std::vector<unsigned int>> no_filaments;
|
||||
REQUIRE_NOTHROW(normalize_nozzle_map_per_layer(empty_maps, no_filaments));
|
||||
REQUIRE(empty_maps.empty());
|
||||
|
||||
// Rows of different widths and a filament list shorter than the map list.
|
||||
std::vector<std::vector<int>> ragged = {{0}, {0, 1, 2}};
|
||||
std::vector<std::vector<unsigned int>> short_filaments = {{0}};
|
||||
REQUIRE_NOTHROW(normalize_nozzle_map_per_layer(ragged, short_filaments));
|
||||
REQUIRE(ragged[0] == std::vector<int>({0}));
|
||||
}
|
||||
|
||||
SECTION("a single layer is left unchanged") {
|
||||
std::vector<std::vector<int>> maps = {{2, 1, 0}};
|
||||
std::vector<std::vector<unsigned int>> filaments = {{0, 1, 2}};
|
||||
normalize_nozzle_map_per_layer(maps, filaments);
|
||||
REQUIRE(maps[0] == std::vector<int>({2, 1, 0}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Stitched sequential blocks resolve per-layer after normalization", "[MultiNozzle][H2C][Dynamic]")
|
||||
{
|
||||
// Shape of the sequential (by-object) stitch: two per-object plan blocks concatenated on one
|
||||
// global layer axis, where the second object's plan moves filament 1 to another physical
|
||||
// nozzle. After normalization the 4-arg create() must detect the migration (selector result)
|
||||
// and resolve stable ids inside each object's layer range.
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
for (int g = 0; g < 3; ++g) {
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard;
|
||||
n.extruder_id = (g == 0) ? 0 : 1;
|
||||
n.group_id = g;
|
||||
nozzle_list.push_back(n);
|
||||
}
|
||||
|
||||
// Object A (layers 0-1): filament 1 on nozzle 1, filament 0 idle until layer 1.
|
||||
// Object B (layers 2-3): filament 1 moved to nozzle 2.
|
||||
std::vector<std::vector<int>> stitched_maps = {
|
||||
{-1, 1},
|
||||
{0, 1},
|
||||
{0, 2},
|
||||
{0, 2},
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> stitched_filaments = {{1}, {0, 1}, {0, 1}, {0, 1}};
|
||||
std::vector<unsigned int> used_filaments = {0, 1};
|
||||
|
||||
normalize_nozzle_map_per_layer(stitched_maps, stitched_filaments);
|
||||
REQUIRE(stitched_maps[0] == std::vector<int>({0, 1})); // filament 0 back-filled to its first nozzle
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(stitched_maps, nozzle_list, used_filaments, stitched_filaments);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
// A filament on two physical nozzles across the objects => selector result.
|
||||
REQUIRE(group.is_support_dynamic_nozzle_map());
|
||||
REQUIRE(group.get_nozzle_id(1, 0) == 1);
|
||||
REQUIRE(group.get_nozzle_id(1, 1) == 1);
|
||||
REQUIRE(group.get_nozzle_id(1, 2) == 2); // second object's range
|
||||
REQUIRE(group.get_nozzle_id(1, 3) == 2);
|
||||
// The default (out-of-range) map is the first layer's normalized row.
|
||||
REQUIRE(group.get_nozzle_id(0, 999) == 0);
|
||||
REQUIRE(group.get_nozzle_id(1, 999) == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential selector prints publish a stitched result and cache the plans", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// By-object + smart filament assign: the by-object branch of Print::process must plan each
|
||||
// object with nozzle-status threading, cache the plans for the g-code export, stitch them
|
||||
// into the published print-wide result, and write the grouping result back to the config
|
||||
// once (per-object orderings must not churn the config).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard};
|
||||
config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = FilamentMapMode::fmmAutoForFlush;
|
||||
config.option<ConfigOptionEnum<PrintSequence>>("print_sequence", true)->value = PrintSequence::ByObject;
|
||||
// Export validates flush_volumes_matrix as filaments^2 values per head.
|
||||
config.option<ConfigOptionFloats>("flush_volumes_matrix", true)->values = std::vector<double>(8, 140.);
|
||||
config.option<ConfigOptionFloats>("flush_multiplier", true)->values = {1., 1.};
|
||||
|
||||
Model model;
|
||||
ModelObject *object_a = model.add_object("cube_a", "", make_cube(20, 20, 20));
|
||||
ModelInstance *instance_a = object_a->add_instance();
|
||||
instance_a->set_offset(Vec3d(70., 100., 0.));
|
||||
ModelObject *object_b = model.add_object("cube_b", "", make_cube(20, 20, 20));
|
||||
object_b->config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
ModelInstance *instance_b = object_b->add_instance();
|
||||
instance_b->set_offset(Vec3d(150., 100., 0.));
|
||||
// The sequential instance ordering keys on arrange_order, which validate() assigns before
|
||||
// process() in the real pipeline (instances tying at 0 get dropped from the ordering);
|
||||
// initialize it here since the test drives process() directly.
|
||||
instance_a->arrange_order = 1;
|
||||
instance_b->arrange_order = 2;
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
REQUIRE(print.objects().size() == 2);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
auto result = print.get_layered_nozzle_group_result();
|
||||
REQUIRE(result != nullptr);
|
||||
// One cached plan per unique object, and a stitched layer axis spanning both objects.
|
||||
REQUIRE(print.sequential_dynamic_orderings().size() == 2);
|
||||
REQUIRE(result->get_layer_count() > 0);
|
||||
// The write-back mirrors the stitched result's extruder map.
|
||||
REQUIRE(print.config().filament_map.values == result->get_extruder_map(false));
|
||||
// The per-slot filament arrays stay label-consistent whether or not the stitched plan
|
||||
// actually migrated a filament (one slot per filament, plus one per extra variant).
|
||||
REQUIRE(print.config().filament_extruder_variant.values.size() == print.config().filament_self_index.values.size());
|
||||
REQUIRE(print.config().filament_self_index.values.size() >= print.config().filament_map.values.size());
|
||||
|
||||
// Export must consume the cached plans and produce g-code without throwing.
|
||||
ScopedTemporaryFile gcode(".gcode");
|
||||
REQUIRE_NOTHROW(print.export_gcode(gcode.string(), nullptr, nullptr));
|
||||
REQUIRE(boost::filesystem::exists(gcode.path()));
|
||||
}
|
||||
|
||||
TEST_CASE("Per-variant expansion gives migrating filaments one slot per variant", "[PrintConfig][H2C][Dynamic]")
|
||||
{
|
||||
// The selector write-back rebuilds the filament arrays from the grouping result: a filament
|
||||
// that prints through several (extruder x volume type) variants keeps one slot per variant,
|
||||
// and every key grows in lockstep with the self-index / variant labels.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
// Extruder 1 Standard, extruder 2 Hybrid (Standard + High Flow): 3 nozzle slots, 2 extruders.
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Two filaments with superset arrays: one column per (filament x variant).
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtHighFlow};
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("nozzle_temperature", true)->values = {220, 230, 240, 250};
|
||||
|
||||
std::set<std::string> key_set = {"filament_self_index", "filament_extruder_variant", "nozzle_temperature"};
|
||||
|
||||
auto make_use = [](ExtruderType et, NozzleVolumeType nvt, int extruder_id) {
|
||||
FilamentVariantUse use;
|
||||
use.extruder_type = et;
|
||||
use.nozzle_volume_type = nvt;
|
||||
use.extruder_id = extruder_id;
|
||||
return use;
|
||||
};
|
||||
|
||||
SECTION("a migrating filament expands, machine slots track each output slot") {
|
||||
std::unordered_map<int, std::vector<FilamentVariantUse>> uses;
|
||||
uses[0] = {make_use(etDirectDrive, nvtStandard, 0), make_use(etDirectDrive, nvtHighFlow, 1)};
|
||||
uses[1] = {make_use(etDirectDrive, nvtHighFlow, 1)};
|
||||
std::vector<int> slot_machine_indices;
|
||||
config.update_filament_config_values_for_multiple_extruders(config, uses, 2, 3, key_set,
|
||||
"filament_self_index", "filament_extruder_variant",
|
||||
&slot_machine_indices);
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>{1, 1, 2});
|
||||
REQUIRE(config.option<ConfigOptionStrings>("filament_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive High Flow", "Direct Drive High Flow"}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("nozzle_temperature")->values == std::vector<int>{220, 230, 250});
|
||||
// Slot 0 backs onto extruder 1 Standard; slots 1-2 onto extruder 2 High Flow.
|
||||
REQUIRE(slot_machine_indices == std::vector<int>{0, 3, 3});
|
||||
}
|
||||
|
||||
SECTION("filaments absent from the uses fall back to their static assignment") {
|
||||
std::unordered_map<int, std::vector<FilamentVariantUse>> uses;
|
||||
uses[0] = {make_use(etDirectDrive, nvtStandard, 0)};
|
||||
// Filament 1 unrouted: filament_map -> extruder 2 (Hybrid) -> volume map -> High Flow.
|
||||
config.update_filament_config_values_for_multiple_extruders(config, uses, 2, 3, key_set,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>{1, 2});
|
||||
REQUIRE(config.option<ConfigOptionInts>("nozzle_temperature")->values == std::vector<int>{220, 250});
|
||||
}
|
||||
|
||||
SECTION("a mis-sized filament_volume_map is ignored") {
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtHighFlow};
|
||||
std::unordered_map<int, std::vector<FilamentVariantUse>> uses;
|
||||
uses[0] = {make_use(etDirectDrive, nvtStandard, 0)};
|
||||
// Unrouted filament 1 keeps the extruder's own typing (Hybrid folds to Standard).
|
||||
config.update_filament_config_values_for_multiple_extruders(config, uses, 2, 3, key_set,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
REQUIRE(config.option<ConfigOptionInts>("nozzle_temperature")->values == std::vector<int>{220, 240});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Selector write-back expands migrating filaments and survives re-apply", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// A filament the per-layer plan moves between nozzle variants must end up with one config
|
||||
// slot per variant (so per-layer temperatures/retractions resolve correctly), the extruder
|
||||
// retract overrides must key each slot to its own variant's machine value, and an unchanged
|
||||
// re-apply must reproduce the expansion instead of trimming it back to one slot per
|
||||
// filament — a trim-back would diff the freshly written values and invalidate the result.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Three filaments: 0 -> extruder 1 (Std), 1 -> extruder 2 (Std), 2 -> extruder 2, migrating
|
||||
// Standard -> High Flow between layers. Superset arrays: one column per (filament x variant).
|
||||
// filament_type must be sized to the filament count: the variant-use collection (like the
|
||||
// full-config producers) keys the per-filament loop on it.
|
||||
config.option<ConfigOptionStrings>("filament_type", true)->values = {"PLA", "PLA", "PLA"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtStandard};
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2, 3, 3};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("nozzle_temperature", true)->values = {200, 210, 220, 230, 240, 250};
|
||||
// The migrating filament's Standard column is nil, so the override merge must fall back to
|
||||
// the machine value of the Standard slot (not the High Flow one).
|
||||
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values =
|
||||
{0.5, 0.5, 0.6, 0.6, ConfigOptionFloatsNullable::nil_value(), 1.2};
|
||||
config.option<ConfigOptionFloats>("retraction_length", true)->values = {0.8, 0.9, 1.0, 1.1};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
|
||||
// Stub grouping result: nozzles as in the resolver test; filament 2 prints on the Standard
|
||||
// nozzle at layer 0 and on the High Flow nozzle at layer 1.
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
{
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtStandard; n.extruder_id = 1; n.group_id = 1; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 1; n.group_id = 2; nozzle_list.push_back(n);
|
||||
}
|
||||
std::vector<std::vector<int>> layer_maps = {{0, 1, 1}, {0, 1, 2}};
|
||||
std::vector<std::vector<unsigned int>> layer_seqs = {{0, 1, 2}, {0, 1, 2}};
|
||||
auto group = LayeredNozzleGroupResult::create(layer_maps, nozzle_list, {0, 1, 2}, layer_seqs);
|
||||
REQUIRE(group.has_value());
|
||||
REQUIRE(group->is_support_dynamic_nozzle_map());
|
||||
print.set_nozzle_group_result(std::make_shared<LayeredNozzleGroupResult>(*group));
|
||||
|
||||
print.update_to_config_by_nozzle_group_result(*group);
|
||||
|
||||
// Filament 2 holds two slots (Standard + High Flow), everything in lockstep.
|
||||
REQUIRE(print.config().filament_map.values == group->get_extruder_map(false));
|
||||
REQUIRE(print.config().filament_self_index.values == std::vector<int>{1, 2, 3, 3});
|
||||
REQUIRE(print.config().nozzle_temperature.values == std::vector<int>{200, 220, 240, 250});
|
||||
// The layer-aware resolver picks the slot matching each layer's variant.
|
||||
REQUIRE(print.get_filament_config_indx(2, 0) == 2);
|
||||
REQUIRE(print.get_filament_config_indx(2, 1) == 3);
|
||||
// Retract overrides: non-nil slots take the filament value; the nil Standard slot of the
|
||||
// migrating filament falls back to its own variant's machine value.
|
||||
const auto &machine_retract = print.full_print_config().option<ConfigOptionFloats>("retraction_length")->values;
|
||||
int f2_std_machine_slot = print.full_print_config().get_index_for_extruder(2, "print_extruder_id", etDirectDrive, nvtStandard,
|
||||
"print_extruder_variant");
|
||||
REQUIRE(f2_std_machine_slot >= 0);
|
||||
const std::vector<double> merged_retract = print.config().retraction_length.values;
|
||||
REQUIRE(merged_retract.size() == 4);
|
||||
REQUIRE_THAT(merged_retract[0], Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
REQUIRE_THAT(merged_retract[1], Catch::Matchers::WithinAbs(0.6, 1e-9));
|
||||
REQUIRE_THAT(merged_retract[2], Catch::Matchers::WithinAbs(machine_retract[f2_std_machine_slot], 1e-9));
|
||||
REQUIRE_THAT(merged_retract[3], Catch::Matchers::WithinAbs(1.2, 1e-9));
|
||||
|
||||
// Re-apply the unchanged config: the persisted result must reproduce the exact expansion.
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
|
||||
REQUIRE(print.config().filament_self_index.values == std::vector<int>{1, 2, 3, 3});
|
||||
REQUIRE(print.config().nozzle_temperature.values == std::vector<int>{200, 220, 240, 250});
|
||||
REQUIRE(print.config().retraction_length.values == merged_retract);
|
||||
}
|
||||
|
||||
TEST_CASE("Filaments ordered after a migrator shift columns and the resolver tracks them", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// When a mid-list filament expands to two columns, every later filament's values move one
|
||||
// column to the right — a raw get_at(filament_id) lands in the migrator's second column.
|
||||
// The layer-aware resolver must return the shifted column for both the expanded filament
|
||||
// arrays and the merged machine overrides.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Three filaments: 0 -> extruder 1 (Std), 1 -> extruder 2, migrating Standard -> High Flow
|
||||
// between layers, 2 -> extruder 2 (Std) — ordered AFTER the migrator.
|
||||
config.option<ConfigOptionStrings>("filament_type", true)->values = {"PLA", "PLA", "PLA"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtStandard};
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2, 3, 3};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("nozzle_temperature", true)->values = {200, 210, 220, 230, 240, 250};
|
||||
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = {0.5, 0.5, 0.7, 0.9, 1.4, 1.4};
|
||||
config.option<ConfigOptionFloats>("retraction_length", true)->values = {0.8, 0.9, 1.0, 1.1};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
{
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtStandard; n.extruder_id = 1; n.group_id = 1; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 1; n.group_id = 2; nozzle_list.push_back(n);
|
||||
}
|
||||
// Filament 1: Standard nozzle on layer 0, High Flow nozzle on layer 1; filament 2 stays Standard.
|
||||
std::vector<std::vector<int>> layer_maps = {{0, 1, 1}, {0, 2, 1}};
|
||||
std::vector<std::vector<unsigned int>> layer_seqs = {{0, 1, 2}, {0, 1, 2}};
|
||||
auto group = LayeredNozzleGroupResult::create(layer_maps, nozzle_list, {0, 1, 2}, layer_seqs);
|
||||
REQUIRE(group.has_value());
|
||||
REQUIRE(group->is_support_dynamic_nozzle_map());
|
||||
print.set_nozzle_group_result(std::make_shared<LayeredNozzleGroupResult>(*group));
|
||||
|
||||
print.update_to_config_by_nozzle_group_result(*group);
|
||||
|
||||
// Filament 1 holds columns 1-2; filament 2's values shift to column 3.
|
||||
REQUIRE(print.config().filament_self_index.values == std::vector<int>{1, 2, 2, 3});
|
||||
REQUIRE(print.config().nozzle_temperature.values == std::vector<int>{200, 220, 230, 240});
|
||||
// The migrator resolves per layer to its two columns.
|
||||
REQUIRE(print.get_filament_config_indx(1, 0) == 1);
|
||||
REQUIRE(print.get_filament_config_indx(1, 1) == 2);
|
||||
// The filament after it no longer lives at its raw index on any layer.
|
||||
REQUIRE(print.get_filament_config_indx(2, 0) == 3);
|
||||
REQUIRE(print.get_filament_config_indx(2, 1) == 3);
|
||||
// Merged machine override: filament 2's value sits in the shifted column, while a raw
|
||||
// get_at(2) would read the migrator's High Flow column.
|
||||
const std::vector<double> merged = print.config().retraction_length.values;
|
||||
REQUIRE(merged.size() == 4);
|
||||
REQUIRE_THAT(merged[3], Catch::Matchers::WithinAbs(1.4, 1e-9));
|
||||
REQUIRE_THAT(merged[2], Catch::Matchers::WithinAbs(0.9, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("Selector slicing keeps the result valid across re-apply", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// The dynamic counterpart of the static re-apply test above: a full process() run through
|
||||
// the selector branch (whatever grouping it settles on) must leave the config in a state
|
||||
// the next apply reproduces without invalidating the freshly sliced result.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow};
|
||||
config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = FilamentMapMode::fmmAutoForFlush;
|
||||
|
||||
Model model;
|
||||
ModelObject *object = model.add_object("cube", "", make_cube(20, 20, 20));
|
||||
object->add_instance()->set_offset(Vec3d(100., 100., 0.));
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
}
|
||||
54
tests/libslic3r/test_utils.cpp
Normal file
54
tests/libslic3r/test_utils.cpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <unistd.h> // getuid
|
||||
#endif
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("per_user_temp_dir composes a per-user temp root", "[utils]") {
|
||||
const std::string base = "/tmp";
|
||||
|
||||
SECTION("an empty id returns base unchanged") {
|
||||
REQUIRE(per_user_temp_dir(base, "") == base);
|
||||
}
|
||||
SECTION("a non-empty id is appended at the top level") {
|
||||
REQUIRE(per_user_temp_dir(base, "1000") == base + "/orcaslicer_1000");
|
||||
}
|
||||
SECTION("distinct ids produce distinct roots") {
|
||||
REQUIRE(per_user_temp_dir(base, "1000") != per_user_temp_dir(base, "1001"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("per_user_temp_id follows the platform contract", "[utils]") {
|
||||
const std::string id = per_user_temp_id();
|
||||
|
||||
SECTION("stable across calls") {
|
||||
REQUIRE(per_user_temp_id() == id);
|
||||
}
|
||||
#ifdef _WIN32
|
||||
SECTION("empty on Windows (its temp dir is already per-user)") {
|
||||
REQUIRE(id.empty());
|
||||
}
|
||||
#else
|
||||
SECTION("the current uid on Linux/macOS") {
|
||||
REQUIRE_FALSE(id.empty());
|
||||
REQUIRE(id == std::to_string(static_cast<unsigned long>(::getuid())));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// The end-to-end contract callers depend on: the temp root is left alone on
|
||||
// Windows and isolated per user on Linux/macOS.
|
||||
TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[utils]") {
|
||||
const std::string base = "/tmp";
|
||||
const std::string root = per_user_temp_dir(base, per_user_temp_id());
|
||||
#ifdef _WIN32
|
||||
REQUIRE(root == base);
|
||||
#else
|
||||
REQUIRE(root != base);
|
||||
REQUIRE_THAT(root, Catch::Matchers::StartsWith(base + "/orcaslicer_"));
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user