mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-12 18:31:18 +00:00
Read each layer's texture from the archive only. `layer.path` comes from the project
file, so following it let any .3mf pick a local file to read, a UNC path among them,
and have the next save copy what it found into the archive. Reverts the disk fallback
added in 8ecfee6a2.
Store a paint mask only for the slots a triangle names. Eight empty strings per
triangle cost every project hundreds of megabytes on a large mesh, whether or not it
uses the feature. A slot's run is padded to the triangles seen so far on its first
value, so it stays indexed by triangle, and ends at its last painted one.
Report a malformed layer block instead of throwing out of the project load. The typed
accessors throw on a value of the wrong type and nothing above them caught that, so
one bad number cost the whole file rather than one volume's relief.
Cover the store and load round trip in test_3mf.cpp: the layer, its image, the options
and the paint, the two regressions above, paint at either end of the triangle list, two
volumes sharing a slot, and the wrong-typed documents at the unit level.
2396 lines
120 KiB
C++
2396 lines
120 KiB
C++
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/generators/catch_generators.hpp>
|
|
#include <catch2/catch_message.hpp>
|
|
#include <catch2/matchers/catch_matchers.hpp>
|
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
|
#include "libslic3r/Model.hpp"
|
|
#include "libslic3r/Format/3mf.hpp"
|
|
#include "libslic3r/Format/bbs_3mf.hpp"
|
|
#include "libslic3r/Format/STL.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
#include "libslic3r/miniz_extension.hpp"
|
|
#include "libslic3r/Zipper.hpp"
|
|
#include "libslic3r/PrintConfig.hpp"
|
|
#include "libslic3r/Semver.hpp"
|
|
#include "libslic3r/Preset.hpp"
|
|
#include "libslic3r/MultiNozzleUtils.hpp"
|
|
#include "libslic3r/ProjectTask.hpp"
|
|
#include "libslic3r/PublishSettings.hpp"
|
|
|
|
#include "test_utils.hpp"
|
|
|
|
#include "libslic3r/Config.hpp"
|
|
#include "libslic3r/Geometry.hpp"
|
|
#include <cstddef>
|
|
#include "libslic3r/Point.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
#include <miniz.h>
|
|
#include "libslic3r/TriangleMesh.hpp"
|
|
#include "libslic3r/TriangleSelector.hpp"
|
|
#include "libslic3r/TextureDisplacement.hpp"
|
|
#include "libslic3r/PNGReadWrite.hpp"
|
|
#include <memory>
|
|
#include <cstdint>
|
|
#include <ios>
|
|
#include <iterator>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <boost/filesystem/operations.hpp>
|
|
#include <boost/nowide/fstream.hpp>
|
|
#include <boost/algorithm/string/predicate.hpp>
|
|
#include <algorithm>
|
|
#include <functional>
|
|
|
|
#include <catch2/catch_tostring.hpp>
|
|
#include <Eigen/Core>
|
|
#include <Eigen/Geometry>
|
|
#include <string>
|
|
#include <sstream>
|
|
#include <type_traits> // for std::enable_if_t
|
|
#include <typeinfo> // for typeid
|
|
#include <regex>
|
|
#include <vector>
|
|
#include <utility>
|
|
|
|
namespace Catch {
|
|
template <typename T>
|
|
struct is_eigen_matrix : std::is_base_of<Eigen::MatrixBase<T>, T> {};
|
|
|
|
template <typename T>
|
|
struct StringMaker<T, std::enable_if_t<is_eigen_matrix<T>::value>> {
|
|
static std::string convert(const T& eigen_obj) {
|
|
// Newline at end of rows
|
|
Eigen::IOFormat fmt(4, 0, ", ", "\n", "[", "]");
|
|
std::stringstream ss;
|
|
ss << "Matrix<" << typeid(eigen_obj).name() << "> = \n";
|
|
ss << eigen_obj.format(fmt);
|
|
return ss.str();
|
|
}
|
|
};
|
|
|
|
// We must manually specialize for Eigen::Transform as it doesn't derive from MatrixBase.
|
|
// It's defined as: Eigen::Transform<Scalar, Dim, Mode, Options>
|
|
template <typename Scalar, int Dim, int Mode, int Options>
|
|
struct StringMaker<Eigen::Transform<Scalar, Dim, Mode, Options>> {
|
|
static std::string convert(const Eigen::Transform<Scalar, Dim, Mode, Options>& trafo) {
|
|
// We print the underlying matrix
|
|
const auto& matrix = trafo.matrix();
|
|
|
|
// Newline at end of rows
|
|
Eigen::IOFormat fmt(4, 0, ", ", "\n", "[", "]");
|
|
std::stringstream ss;
|
|
|
|
ss << "Transform<Mode=" << Mode << ", Dim=" << Dim << "> = \n";
|
|
ss << matrix.format(fmt);
|
|
return ss.str();
|
|
}
|
|
};
|
|
|
|
// Quaternions also need an explicit specialization
|
|
template <typename Scalar, int Options>
|
|
struct StringMaker<Eigen::Quaternion<Scalar, Options>> {
|
|
static std::string convert(const Eigen::Quaternion<Scalar, Options>& quat) {
|
|
std::stringstream ss;
|
|
ss << "Quaternion(w=" << quat.w() << ", x=" << quat.x() << ", y=" << quat.y() << ", z=" << quat.z() << ")";
|
|
return ss.str();
|
|
}
|
|
};
|
|
} // end namespace Catch
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
using namespace Slic3r;
|
|
|
|
|
|
SCENARIO("Reading 3mf file", "[3mf]") {
|
|
GIVEN("umlauts in the path of the file") {
|
|
Model model;
|
|
WHEN("3mf model is read") {
|
|
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
|
DynamicPrintConfig config;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
|
|
bool ret = load_3mf(path.c_str(), config, ctxt, &model, false);
|
|
THEN("load should succeed") {
|
|
REQUIRE(ret);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
|
|
GIVEN("world vertices coordinates before save") {
|
|
// load a model from stl file
|
|
Model src_model;
|
|
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
|
load_stl(src_file.c_str(), &src_model);
|
|
src_model.add_default_instances();
|
|
|
|
ModelObject* src_object = src_model.objects.front();
|
|
|
|
// apply generic transformation to the 1st volume
|
|
Geometry::Transformation src_volume_transform;
|
|
src_volume_transform.set_offset({ 10.0, 20.0, 0.0 });
|
|
src_volume_transform.set_rotation({ Geometry::deg2rad(25.0), Geometry::deg2rad(35.0), Geometry::deg2rad(45.0) });
|
|
src_volume_transform.set_scaling_factor({ 1.1, 1.2, 1.3 });
|
|
src_volume_transform.set_mirror({ -1.0, 1.0, -1.0 });
|
|
src_object->volumes.front()->set_transformation(src_volume_transform);
|
|
|
|
// apply generic transformation to the 1st instance
|
|
Geometry::Transformation src_instance_transform;
|
|
src_instance_transform.set_offset({ 5.0, 10.0, 0.0 });
|
|
src_instance_transform.set_rotation({ Geometry::deg2rad(12.0), Geometry::deg2rad(13.0), Geometry::deg2rad(14.0) });
|
|
src_instance_transform.set_scaling_factor({ 0.9, 0.8, 0.7 });
|
|
src_instance_transform.set_mirror({ 1.0, -1.0, -1.0 });
|
|
src_object->instances.front()->set_transformation(src_instance_transform);
|
|
|
|
WHEN("model is saved+loaded to/from 3mf file") {
|
|
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
|
|
Model dst_model;
|
|
DynamicPrintConfig dst_config;
|
|
{
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
|
|
load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false);
|
|
}
|
|
|
|
// compare meshes
|
|
TriangleMesh src_mesh = src_model.mesh();
|
|
TriangleMesh dst_mesh = dst_model.mesh();
|
|
|
|
bool res = src_mesh.its.vertices.size() == dst_mesh.its.vertices.size();
|
|
if (res) {
|
|
for (size_t i = 0; i < dst_mesh.its.vertices.size(); ++i) {
|
|
res &= dst_mesh.its.vertices[i].isApprox(src_mesh.its.vertices[i]);
|
|
}
|
|
}
|
|
THEN("world vertices coordinates after load match") {
|
|
REQUIRE(res);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SCENARIO("Mirrored instance transforms survive 3mf round trips", "[3mf][Regression]") {
|
|
const bool bbs_format = GENERATE(false, true);
|
|
|
|
GIVEN("an instance with a valid mirrored transform") {
|
|
Model model;
|
|
const 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();
|
|
|
|
Transform3d mirrored = Transform3d::Identity();
|
|
mirrored.linear() <<
|
|
4.4408921e-16, 0.819152044, 0.573576436,
|
|
1.0, -4.4408921e-16, -1.11022302e-16,
|
|
-5.55111512e-17, -0.573576436, 0.819152044;
|
|
mirrored.translation() = Vec3d(700.41477, -169.930939, 63.392571);
|
|
REQUIRE_THAT(mirrored.linear().determinant(), Catch::Matchers::WithinAbs(-1.0, 1e-8));
|
|
model.objects.front()->instances.front()->set_transformation(Geometry::Transformation(mirrored));
|
|
const TriangleMesh expected_mesh = model.mesh();
|
|
|
|
WHEN("the model is stored and loaded") {
|
|
ScopedTemporaryDir backup_dir("orca_mirrored_transform");
|
|
model.set_backup_path(backup_dir.string());
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
if (bbs_format) {
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
} else {
|
|
REQUIRE(store_3mf(test_file.c_str(), &model, &config, false));
|
|
}
|
|
|
|
Model dst_model;
|
|
ScopedTemporaryDir loaded_backup_dir("orca_mirrored_transform_loaded");
|
|
dst_model.set_backup_path(loaded_backup_dir.string());
|
|
DynamicPrintConfig dst_config;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
|
PlateDataPtrs dst_plates;
|
|
std::vector<Preset*> project_presets;
|
|
ScopeGuard cleanup([&dst_plates, &project_presets]() {
|
|
release_PlateData_list(dst_plates);
|
|
for (Preset *preset : project_presets)
|
|
delete preset;
|
|
});
|
|
if (bbs_format) {
|
|
bool is_bbl_3mf = false, is_orca_3mf = false;
|
|
Semver file_version;
|
|
REQUIRE(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));
|
|
} else {
|
|
REQUIRE(load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false));
|
|
}
|
|
|
|
THEN("the mirrored transform and world geometry are preserved") {
|
|
REQUIRE(dst_model.objects.size() == 1);
|
|
REQUIRE(dst_model.objects.front()->instances.size() == 1);
|
|
const Transform3d &loaded = dst_model.objects.front()->instances.front()->get_matrix();
|
|
REQUIRE(loaded.linear().determinant() < 0.0);
|
|
|
|
const TriangleMesh loaded_mesh = dst_model.mesh();
|
|
REQUIRE(loaded_mesh.its.vertices.size() == expected_mesh.its.vertices.size());
|
|
for (size_t i = 0; i < loaded_mesh.its.vertices.size(); ++i)
|
|
REQUIRE(loaded_mesh.its.vertices[i].isApprox(expected_mesh.its.vertices[i], 1e-5f));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The recipe is an opaque binary blob (CadDocument::serialize_recipe()), so the 3mf backend has
|
|
// to carry it byte-for-byte — no XML/text mangling, embedded NULs intact.
|
|
static std::string make_cad_recipe()
|
|
{
|
|
// Built from an explicit length, not append(const char*), which would stop at the first
|
|
// embedded NUL — the one thing this blob exists to prove survives the archive.
|
|
static const char blob[] = "\x01" "RECIPE" "\0" "\xff\xfe\x00\x10" "cad-features-blob";
|
|
return std::string(blob, sizeof(blob) - 1);
|
|
}
|
|
|
|
static const std::string CAD_RECIPE_ENTRY = "Metadata/orca_cad.bin";
|
|
static const std::string LEGACY_CAD_RECIPE_ENTRY = "Metadata/SnapOrca_cad.bin";
|
|
|
|
// Pulls one named entry out of a 3mf archive; false when it is absent.
|
|
static bool read_cad_recipe_entry(const std::string& path, std::string& out,
|
|
const std::string& entry = CAD_RECIPE_ENTRY)
|
|
{
|
|
mz_zip_archive zip;
|
|
mz_zip_zero_struct(&zip);
|
|
REQUIRE(open_zip_reader(&zip, path));
|
|
bool found = false;
|
|
mz_uint n = mz_zip_reader_get_num_files(&zip);
|
|
for (mz_uint i = 0; i < n; ++i) {
|
|
mz_zip_archive_file_stat st;
|
|
if (!mz_zip_reader_file_stat(&zip, i, &st)) continue;
|
|
std::string name(st.m_filename);
|
|
std::replace(name.begin(), name.end(), '\\', '/');
|
|
if (boost::algorithm::iequals(name, entry)) {
|
|
out.resize(st.m_uncomp_size);
|
|
found = mz_zip_reader_extract_to_mem(&zip, i, out.data(), out.size(), 0) != 0;
|
|
break;
|
|
}
|
|
}
|
|
close_zip_reader(&zip);
|
|
return found;
|
|
}
|
|
|
|
// Rewrites the archive at `path`, letting `edit` change the name or data of each entry; returns
|
|
// whether `edit` reported a change for any of them. miniz cannot edit in place and
|
|
// open_zip_writer truncates, so the entries are held across the switch.
|
|
static bool rewrite_3mf_entries(const std::string& path, const std::function<bool(std::string& name, std::string& data)>& edit)
|
|
{
|
|
std::vector<std::pair<std::string, std::string>> entries;
|
|
bool changed = false;
|
|
{
|
|
mz_zip_archive zip;
|
|
mz_zip_zero_struct(&zip);
|
|
REQUIRE(open_zip_reader(&zip, path));
|
|
mz_uint n = mz_zip_reader_get_num_files(&zip);
|
|
for (mz_uint i = 0; i < n; ++i) {
|
|
mz_zip_archive_file_stat st;
|
|
REQUIRE(mz_zip_reader_file_stat(&zip, i, &st));
|
|
if (st.m_is_directory) continue;
|
|
std::string name(st.m_filename);
|
|
std::replace(name.begin(), name.end(), '\\', '/');
|
|
std::string data((size_t) st.m_uncomp_size, '\0');
|
|
if (st.m_uncomp_size > 0)
|
|
REQUIRE(mz_zip_reader_extract_to_mem(&zip, i, data.data(), data.size(), 0));
|
|
changed |= edit(name, data);
|
|
entries.emplace_back(std::move(name), std::move(data));
|
|
}
|
|
close_zip_reader(&zip);
|
|
}
|
|
|
|
Zipper out(path);
|
|
for (const auto& e : entries)
|
|
out.add_entry(e.first, e.second.data(), e.second.size());
|
|
out.finalize();
|
|
return changed;
|
|
}
|
|
|
|
// Rewrites the archive at `path` with the recipe entry back under the name it had before the
|
|
// rename, which is what every project saved by an earlier build looks like on disk. Generated
|
|
// rather than checked in because a whole project archive is not frozen evidence the way a bare
|
|
// recipe blob is -- it has to be whatever today's exporter writes, with only the name aged.
|
|
static void rename_cad_recipe_entry_to_legacy(const std::string& path)
|
|
{
|
|
const bool renamed = rewrite_3mf_entries(path, [](std::string& name, std::string&) {
|
|
if (!boost::algorithm::iequals(name, CAD_RECIPE_ENTRY))
|
|
return false;
|
|
name = LEGACY_CAD_RECIPE_ENTRY;
|
|
return true;
|
|
});
|
|
// Without this the scenario would degrade silently into re-testing the new name if the
|
|
// exporter's constant ever moved again: every load below would still pass.
|
|
REQUIRE(renamed);
|
|
}
|
|
|
|
// Replaces the first occurrence of `from` in any entry whose name ends with `suffix`.
|
|
static bool replace_in_3mf_entry(const std::string& path, const std::string& suffix, const std::string& from, const std::string& to)
|
|
{
|
|
bool replaced = false;
|
|
rewrite_3mf_entries(path, [&](std::string& name, std::string& data) {
|
|
if (replaced || !boost::algorithm::ends_with(name, suffix))
|
|
return false;
|
|
if (const size_t pos = data.find(from); pos != std::string::npos) {
|
|
data.replace(pos, from.size(), to);
|
|
replaced = true;
|
|
}
|
|
return replaced;
|
|
});
|
|
return replaced;
|
|
}
|
|
|
|
// Writes `model` out as a one-plate project, which is what the application's Save does with it.
|
|
static void store_project(const std::string& path, Model& model)
|
|
{
|
|
ScopedTemporaryDir backup_dir("orca_project_src");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
DynamicPrintConfig cfg;
|
|
PlateData plate;
|
|
plate.plate_index = 0;
|
|
StoreParams sp;
|
|
sp.path = path.c_str();
|
|
sp.model = &model;
|
|
sp.config = &cfg;
|
|
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
sp.plate_data_list.push_back(&plate);
|
|
REQUIRE(store_bbs_3mf(sp));
|
|
}
|
|
|
|
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
|
|
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
|
|
static void store_painted_cube(const std::string& path)
|
|
{
|
|
Model model;
|
|
ModelObject* object = model.add_object();
|
|
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
|
object->add_instance();
|
|
{
|
|
TriangleSelector selector(volume->mesh());
|
|
selector.set_facet(0, EnforcerBlockerType::Extruder2);
|
|
selector.set_facet(1, EnforcerBlockerType::Extruder3);
|
|
REQUIRE(volume->mmu_segmentation_facets.set(selector));
|
|
}
|
|
store_project(path, model);
|
|
}
|
|
|
|
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
|
|
// importer stages metadata through `backup_dir`, which has to outlive the model.
|
|
static bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
|
|
{
|
|
model.set_backup_path(backup_dir.string());
|
|
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;
|
|
const bool 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);
|
|
release_PlateData_list(plates);
|
|
return loaded;
|
|
}
|
|
|
|
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
|
|
{
|
|
const int plate_id = GENERATE(0, -1);
|
|
INFO("plater_id " << plate_id);
|
|
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_painted_cube(temp.string());
|
|
{
|
|
ScopedTemporaryDir backup_dir("orca_plate_dst");
|
|
Model model;
|
|
REQUIRE(load_project(temp.string(), model, backup_dir));
|
|
}
|
|
|
|
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", "key=\"plater_id\" value=\"1\"",
|
|
"key=\"plater_id\" value=\"" + std::to_string(plate_id) + "\""));
|
|
ScopedTemporaryDir backup_dir("orca_plate_dst");
|
|
Model model;
|
|
bool loaded = true;
|
|
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
|
|
REQUIRE_FALSE(loaded);
|
|
}
|
|
|
|
TEST_CASE("A project whose components reference themselves fails to load", "[3mf][Regression]")
|
|
{
|
|
// One self-reference keeps the expansion going without ever reaching a mesh. A thousand also make
|
|
// each expansion queue a thousand more, so the bound has to hold the work list, not just the loop.
|
|
const int references = GENERATE(1, 1000);
|
|
INFO("self-references " << references);
|
|
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_painted_cube(temp.string());
|
|
|
|
// Point the component back at the object that holds it, repeated `references` times.
|
|
REQUIRE(rewrite_3mf_entries(temp.string(), [references](std::string& name, std::string& data) {
|
|
if (!boost::algorithm::ends_with(name, "3dmodel.model"))
|
|
return false;
|
|
std::smatch match;
|
|
if (!std::regex_search(data, match, std::regex("<object id=\"([0-9]+)\"[^>]*>\\s*<components")))
|
|
return false;
|
|
data = std::regex_replace(data, std::regex("objectid=\"[0-9]+\""), "objectid=\"" + match[1].str() + "\"");
|
|
std::smatch component;
|
|
if (!std::regex_search(data, component, std::regex("<component [^>]*/>")))
|
|
return false;
|
|
std::string repeated;
|
|
for (int i = 0; i < references; ++i)
|
|
repeated += component.str();
|
|
data.replace(component.position(), component.length(), repeated);
|
|
return true;
|
|
}));
|
|
|
|
ScopedTemporaryDir backup_dir("orca_cycle_dst");
|
|
Model model;
|
|
bool loaded = true;
|
|
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
|
|
REQUIRE_FALSE(loaded);
|
|
}
|
|
|
|
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
|
|
{
|
|
// The importer queues at most 100000 component references per object. Every reference besides the
|
|
// cube's own points at an object the file does not define: it counts toward the budget, then expands
|
|
// to nothing, so the object stays a single part whatever the count.
|
|
const auto [references, loads] = GENERATE(table<int, bool>({ { 100000, true }, { 100001, false } }));
|
|
INFO("component references " << references);
|
|
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_painted_cube(temp.string());
|
|
|
|
std::string dangling;
|
|
for (int i = 1; i < references; ++i)
|
|
dangling += "<component objectid=\"999999\"/>";
|
|
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "</components>", dangling + "</components>"));
|
|
|
|
ScopedTemporaryDir backup_dir("orca_budget_dst");
|
|
Model model;
|
|
bool loaded = !loads;
|
|
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
|
|
REQUIRE(loaded == loads);
|
|
if (loads) {
|
|
REQUIRE(model.objects.size() == 1);
|
|
CHECK(model.objects.front()->volumes.size() == 1);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]")
|
|
{
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_painted_cube(temp.string());
|
|
// Split codes with no children behind them: the stream runs out mid-tree.
|
|
REQUIRE(replace_in_3mf_entry(temp.string(), ".model", "paint_color=\"8\"", "paint_color=\"FFFFFFFFFFFFFFFF3\""));
|
|
|
|
ScopedTemporaryDir backup_dir("orca_paint_dst");
|
|
Model model;
|
|
REQUIRE(load_project(temp.string(), model, backup_dir));
|
|
REQUIRE(model.objects.size() == 1);
|
|
const ModelVolume& volume = *model.objects.front()->volumes.front();
|
|
const auto& data = volume.mmu_segmentation_facets.get_data();
|
|
REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder2)]);
|
|
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder3)]);
|
|
|
|
TriangleSelector selector(volume.mesh());
|
|
REQUIRE_NOTHROW(selector.deserialize(data));
|
|
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder2) == 0);
|
|
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1);
|
|
}
|
|
|
|
// The recipe lives only in the BBS-native backend, because that is the only one that runs:
|
|
// store_bbs_3mf is the sole exporter the app calls, and 3mf.cpp's load_3mf is reached only for
|
|
// files fingerprinted as PrusaSlicer's, which never carry a recipe. This locks in both halves:
|
|
// the archive entry is at the exact path the importer looks for, and the recipe comes back
|
|
// through the real importer.
|
|
SCENARIO("CAD recipe is embedded in the BBS 3mf archive", "[3mf][CAD]") {
|
|
GIVEN("a model carrying a binary cad_recipe") {
|
|
Model model;
|
|
std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
|
REQUIRE(load_stl(src.c_str(), &model));
|
|
model.add_default_instances();
|
|
|
|
// store_bbs_3mf stages its metadata through the model's backup path; point it at a
|
|
// writable temp dir, as the sibling BBS scenarios do. The process-global
|
|
// set_temporary_dir() would leak into every test that ran afterwards.
|
|
ScopedTemporaryDir backup_dir("orca_cad");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
const std::string recipe = make_cad_recipe();
|
|
model.cad_recipe = recipe;
|
|
|
|
WHEN("saved through the BBS backend (the format the GUI uses)") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig cfg;
|
|
StoreParams sp;
|
|
sp.path = test_file.c_str();
|
|
sp.model = &model;
|
|
sp.config = &cfg;
|
|
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(sp));
|
|
|
|
THEN("the archive entry is present byte-for-byte") {
|
|
std::string got;
|
|
REQUIRE(read_cad_recipe_entry(test_file, got));
|
|
REQUIRE(got.size() == recipe.size());
|
|
REQUIRE(got == recipe);
|
|
}
|
|
|
|
THEN("the importer restores it onto the loaded model") {
|
|
Model dst_model;
|
|
ScopedTemporaryDir dst_backup_dir("orca_cad_dst");
|
|
dst_model.set_backup_path(dst_backup_dir.string());
|
|
|
|
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;
|
|
REQUIRE(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));
|
|
REQUIRE(dst_model.cad_recipe.size() == recipe.size());
|
|
REQUIRE(dst_model.cad_recipe == recipe);
|
|
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
|
|
WHEN("the same model is saved with no recipe") {
|
|
model.cad_recipe.clear();
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig cfg;
|
|
StoreParams sp;
|
|
sp.path = test_file.c_str();
|
|
sp.model = &model;
|
|
sp.config = &cfg;
|
|
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(sp));
|
|
|
|
THEN("no entry is written at all") {
|
|
std::string got;
|
|
REQUIRE_FALSE(read_cad_recipe_entry(test_file, got));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The recipe entry was renamed from Metadata/SnapOrca_cad.bin to Metadata/orca_cad.bin. Nothing
|
|
// in the blob marks that move, so a reader that knows only the new name loads a project written
|
|
// before it with an empty cad_recipe and no error at all — a feature tree gone with no symptom
|
|
// but an empty Design tab. The importer must still accept the old name; the exporter may never
|
|
// write it.
|
|
SCENARIO("a project saved under the pre-rename recipe name still loads", "[3mf][CAD]") {
|
|
GIVEN("a project whose recipe entry carries the old name") {
|
|
Model model;
|
|
std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
|
REQUIRE(load_stl(src.c_str(), &model));
|
|
model.add_default_instances();
|
|
const std::string recipe = make_cad_recipe();
|
|
model.cad_recipe = recipe;
|
|
|
|
WHEN("it was written by the BBS backend") {
|
|
ScopedTemporaryDir backup_dir("orca_cad_legacy");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
DynamicPrintConfig cfg;
|
|
StoreParams sp;
|
|
sp.path = test_file.c_str();
|
|
sp.model = &model;
|
|
sp.config = &cfg;
|
|
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(sp));
|
|
rename_cad_recipe_entry_to_legacy(test_file);
|
|
|
|
// Catch2 replays the enclosing sections per THEN, so one load here serves both.
|
|
Model dst_model;
|
|
ScopedTemporaryDir dst_backup_dir("orca_cad_legacy_dst");
|
|
dst_model.set_backup_path(dst_backup_dir.string());
|
|
|
|
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;
|
|
REQUIRE(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));
|
|
release_PlateData_list(dst_plates);
|
|
|
|
THEN("the recipe still comes back byte-for-byte") {
|
|
REQUIRE(dst_model.cad_recipe == recipe);
|
|
}
|
|
|
|
THEN("re-saving migrates it to the new name and leaves the old one behind") {
|
|
ScopedTemporaryFile again(".3mf");
|
|
const std::string resaved = again.string();
|
|
DynamicPrintConfig cfg2;
|
|
StoreParams sp2;
|
|
sp2.path = resaved.c_str();
|
|
sp2.model = &dst_model;
|
|
sp2.config = &cfg2;
|
|
sp2.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(sp2));
|
|
|
|
std::string got;
|
|
REQUIRE(read_cad_recipe_entry(resaved, got));
|
|
REQUIRE(got == recipe);
|
|
REQUIRE_FALSE(read_cad_recipe_entry(resaved, got, LEGACY_CAD_RECIPE_ENTRY));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// .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();
|
|
|
|
// 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());
|
|
|
|
// 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 }));
|
|
|
|
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));
|
|
|
|
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;
|
|
// 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();
|
|
|
|
// 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 }));
|
|
|
|
// 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 }));
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
SCENARIO("BBS 3MF round-trips per-plate IMEX state (parallel mode + head filament map)", "[3mf][IMEX]") {
|
|
// Regression guard for the class of bug where per-plate state silently drops through
|
|
// save/load (the variant-truncation bug was the precipitating example; IMEX plate state
|
|
// rides the same XML metadata path and is equally vulnerable).
|
|
//
|
|
// The mode name is user-typed free text, so the values below deliberately carry every
|
|
// character class that is special inside an XML attribute value:
|
|
// & and < must be escaped or the document is not well formed and the project will
|
|
// not load at all,
|
|
// " must be escaped or it terminates the attribute early,
|
|
// tab must be written as a numeric character reference, because XML normalizes
|
|
// literal whitespace in attribute values on read and the mode would be
|
|
// silently renamed,
|
|
// ' and > are legal raw inside a double-quoted value and must survive untouched.
|
|
// The head filament map value is machine generated in practice; it is given hostile
|
|
// content here only to pin the escaping of the sibling attribute, so this asserts the
|
|
// XML transport, not the map grammar (the loader stores the string verbatim).
|
|
//
|
|
// BBS exporter scaffolds a backup dir under temporary_dir() for the project config file;
|
|
// point it at a writable location for the test process.
|
|
set_temporary_dir(boost::filesystem::temp_directory_path().string());
|
|
|
|
const std::string hostile_mode = "PLA & ABS <hot> \"2x\"\tcopy's mode";
|
|
const std::string hostile_hfm = "1:2,2:3 & <\"\tx>";
|
|
|
|
GIVEN("A Model with a single object on plate 0 and IMEX plate state set") {
|
|
Model src_model;
|
|
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
|
REQUIRE(load_stl(src_file.c_str(), &src_model));
|
|
src_model.add_default_instances();
|
|
|
|
DynamicPrintConfig src_config;
|
|
|
|
PlateDataPtrs src_plates;
|
|
auto *plate0 = new PlateData();
|
|
plate0->plate_index = 0;
|
|
plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString(hostile_mode));
|
|
plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString(hostile_hfm));
|
|
src_plates.push_back(plate0);
|
|
|
|
WHEN("the model is saved to BBS 3MF and loaded back") {
|
|
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_roundtrip.3mf";
|
|
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &src_model;
|
|
store_params.plate_data_list = src_plates;
|
|
store_params.config = &src_config;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
Model dst_model;
|
|
DynamicPrintConfig dst_config;
|
|
PlateDataPtrs dst_plates;
|
|
std::vector<Preset*> dst_presets;
|
|
bool is_bbl = false;
|
|
bool is_orca = false;
|
|
Semver file_version;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
|
|
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model,
|
|
&dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version);
|
|
boost::filesystem::remove(test_file);
|
|
|
|
THEN("load succeeds") {
|
|
REQUIRE(loaded);
|
|
}
|
|
THEN("the loaded plate list has the same number of plates") {
|
|
REQUIRE(dst_plates.size() == src_plates.size());
|
|
}
|
|
THEN("imex_parallel_mode round-trips byte for byte, XML metacharacters included") {
|
|
REQUIRE(dst_plates.size() >= 1);
|
|
auto *mode_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_parallel_mode");
|
|
REQUIRE(mode_opt != nullptr);
|
|
REQUIRE(mode_opt->value == hostile_mode);
|
|
}
|
|
THEN("imex_head_filament_map round-trips byte for byte, XML metacharacters included") {
|
|
REQUIRE(dst_plates.size() >= 1);
|
|
auto *hfm_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_head_filament_map");
|
|
REQUIRE(hfm_opt != nullptr);
|
|
REQUIRE(hfm_opt->value == hostile_hfm);
|
|
}
|
|
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
|
|
release_PlateData_list(src_plates);
|
|
}
|
|
}
|
|
|
|
SCENARIO("BBS 3MF round-trips distinct IMEX state across multiple plates", "[3mf][IMEX]") {
|
|
// Guards against a plate-indexing regression where IMEX metadata lands on the wrong
|
|
// plate or bleeds across plates on reload. Each plate carries distinct mode + head-
|
|
// filament-map values; the reload must reproduce them in the same order.
|
|
set_temporary_dir(boost::filesystem::temp_directory_path().string());
|
|
|
|
GIVEN("A Model with two plates each carrying different IMEX state") {
|
|
Model src_model;
|
|
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
|
REQUIRE(load_stl(src_file.c_str(), &src_model));
|
|
src_model.add_default_instances();
|
|
|
|
DynamicPrintConfig src_config;
|
|
|
|
PlateDataPtrs src_plates;
|
|
auto *plate0 = new PlateData();
|
|
plate0->plate_index = 0;
|
|
plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString("copy_mode"));
|
|
plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString("1:2"));
|
|
src_plates.push_back(plate0);
|
|
|
|
auto *plate1 = new PlateData();
|
|
plate1->plate_index = 1;
|
|
plate1->config.set_key_value("imex_parallel_mode", new ConfigOptionString("mirror_mode"));
|
|
plate1->config.set_key_value("imex_head_filament_map", new ConfigOptionString("2:4,3:5"));
|
|
src_plates.push_back(plate1);
|
|
|
|
WHEN("the model is saved to BBS 3MF and loaded back") {
|
|
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_multiplate_roundtrip.3mf";
|
|
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &src_model;
|
|
store_params.plate_data_list = src_plates;
|
|
store_params.config = &src_config;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
Model dst_model;
|
|
DynamicPrintConfig dst_config;
|
|
PlateDataPtrs dst_plates;
|
|
std::vector<Preset*> dst_presets;
|
|
bool is_bbl = false;
|
|
bool is_orca = false;
|
|
Semver file_version;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
|
|
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model,
|
|
&dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version);
|
|
boost::filesystem::remove(test_file);
|
|
|
|
THEN("load succeeds and both plates are returned") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_plates.size() == 2);
|
|
}
|
|
THEN("plate 0 retains its own IMEX state (copy_mode, 1:2) and does not inherit plate 1's") {
|
|
REQUIRE(dst_plates.size() == 2);
|
|
auto *mode = dst_plates[0]->config.option<ConfigOptionString>("imex_parallel_mode");
|
|
auto *hfm = dst_plates[0]->config.option<ConfigOptionString>("imex_head_filament_map");
|
|
REQUIRE(mode != nullptr);
|
|
REQUIRE(hfm != nullptr);
|
|
REQUIRE(mode->value == "copy_mode");
|
|
REQUIRE(hfm->value == "1:2");
|
|
}
|
|
THEN("plate 1 retains its own IMEX state (mirror_mode, 2:4,3:5) and does not inherit plate 0's") {
|
|
REQUIRE(dst_plates.size() == 2);
|
|
auto *mode = dst_plates[1]->config.option<ConfigOptionString>("imex_parallel_mode");
|
|
auto *hfm = dst_plates[1]->config.option<ConfigOptionString>("imex_head_filament_map");
|
|
REQUIRE(mode != nullptr);
|
|
REQUIRE(hfm != nullptr);
|
|
REQUIRE(mode->value == "mirror_mode");
|
|
REQUIRE(hfm->value == "2:4,3:5");
|
|
}
|
|
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
|
|
release_PlateData_list(src_plates);
|
|
}
|
|
}
|
|
|
|
SCENARIO("BBS 3MF does not emit IMEX metadata when plate is in primary mode", "[3mf][IMEX]") {
|
|
// The serialization guard short-circuits when the mode is empty or "primary", so loading
|
|
// a plate that was saved in primary mode must not leave a stale imex_parallel_mode option
|
|
// on the plate's config. If this regressed, we'd see ghost "primary" strings appearing on
|
|
// plates that had no IMEX state at all.
|
|
set_temporary_dir(boost::filesystem::temp_directory_path().string());
|
|
|
|
GIVEN("A Model with plate 0 in primary mode and an empty head-filament map") {
|
|
Model src_model;
|
|
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
|
REQUIRE(load_stl(src_file.c_str(), &src_model));
|
|
src_model.add_default_instances();
|
|
|
|
DynamicPrintConfig src_config;
|
|
PlateDataPtrs src_plates;
|
|
auto *plate0 = new PlateData();
|
|
plate0->plate_index = 0;
|
|
plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString("primary"));
|
|
plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString(""));
|
|
src_plates.push_back(plate0);
|
|
|
|
WHEN("the model is saved to BBS 3MF and loaded back") {
|
|
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_primary_roundtrip.3mf";
|
|
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &src_model;
|
|
store_params.plate_data_list = src_plates;
|
|
store_params.config = &src_config;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
Model dst_model;
|
|
DynamicPrintConfig dst_config;
|
|
PlateDataPtrs dst_plates;
|
|
std::vector<Preset*> dst_presets;
|
|
bool is_bbl = false;
|
|
bool is_orca = false;
|
|
Semver file_version;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
|
|
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model,
|
|
&dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version);
|
|
boost::filesystem::remove(test_file);
|
|
|
|
THEN("load succeeds") {
|
|
REQUIRE(loaded);
|
|
}
|
|
THEN("the loaded plate has no imex_parallel_mode option set (primary is not serialized)") {
|
|
REQUIRE(dst_plates.size() >= 1);
|
|
auto *mode_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_parallel_mode");
|
|
REQUIRE(mode_opt == nullptr);
|
|
}
|
|
THEN("the loaded plate has no imex_head_filament_map option set (empty is not serialized)") {
|
|
REQUIRE(dst_plates.size() >= 1);
|
|
auto *hfm_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_head_filament_map");
|
|
REQUIRE(hfm_opt == nullptr);
|
|
}
|
|
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
|
|
release_PlateData_list(src_plates);
|
|
}
|
|
}
|
|
|
|
// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an
|
|
// ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup
|
|
// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays.
|
|
SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[3mf][MixedFilament]") {
|
|
GIVEN("a painted model whose project config describes a mixed filament in the last slot") {
|
|
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();
|
|
|
|
// Both the exporter and the importer stage Metadata/project_settings.config through the
|
|
// model's backup path; point them at writable temp dirs.
|
|
ScopedTemporaryDir backup_dir("orca_mixed_src");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
ModelVolume* mv = model.objects.front()->volumes.front();
|
|
{
|
|
TriangleSelector selector(mv->mesh());
|
|
selector.set_facet(0, EnforcerBlockerType::Extruder5); // the mixed slot
|
|
selector.set_facet(1, EnforcerBlockerType::Extruder2);
|
|
REQUIRE(mv->mmu_segmentation_facets.set(selector));
|
|
}
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.set_key_value("filament_colour", new ConfigOptionStrings(
|
|
{ "#00AE42", "#FFFF00", "#FF0000", "#0000FF", "#FF6A26" }));
|
|
config.set_key_value("filament_is_mixed", new ConfigOptionBools(
|
|
{ false, false, false, false, true }));
|
|
config.set_key_value("filament_mixed_components", new ConfigOptionStrings(
|
|
{ "", "", "", "", "3,2" }));
|
|
config.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings(
|
|
{ "", "", "", "", "0.4200,0.5800" }));
|
|
|
|
WHEN("stored to and reloaded from a .3mf") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
PlateData* plate = new PlateData();
|
|
plate->plate_index = 0;
|
|
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
store_params.plate_data_list.push_back(plate);
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
Model dst_model;
|
|
ScopedTemporaryDir dst_backup_dir("orca_mixed_dst");
|
|
dst_model.set_backup_path(dst_backup_dir.string());
|
|
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;
|
|
REQUIRE(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 mixed-filament project keys survive") {
|
|
auto* is_mixed = dst_config.option<ConfigOptionBools>("filament_is_mixed");
|
|
REQUIRE(is_mixed != nullptr);
|
|
REQUIRE(is_mixed->values == std::vector<unsigned char>({ 0, 0, 0, 0, 1 }));
|
|
|
|
auto* components = dst_config.option<ConfigOptionStrings>("filament_mixed_components");
|
|
REQUIRE(components != nullptr);
|
|
REQUIRE(components->values.size() == 5);
|
|
REQUIRE(components->values[4] == "3,2");
|
|
|
|
auto* ratios = dst_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
|
|
REQUIRE(ratios != nullptr);
|
|
REQUIRE(ratios->values.size() == 5);
|
|
REQUIRE(ratios->values[4] == "0.4200,0.5800");
|
|
}
|
|
|
|
THEN("the painted facets survive, including the one painted with the mixed slot") {
|
|
REQUIRE(dst_model.objects.size() == 1);
|
|
ModelVolume* dst_mv = dst_model.objects.front()->volumes.front();
|
|
REQUIRE_FALSE(dst_mv->mmu_segmentation_facets.empty());
|
|
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder2));
|
|
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder5));
|
|
}
|
|
|
|
release_PlateData_list(dst_plates);
|
|
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
|
}
|
|
}
|
|
}
|
|
// Locks the serialization contract of the "Publish" metadata: the orca_published flag and the
|
|
// orca_published_keys JSON array in model.model_info->metadata_items must survive a store_bbs_3mf ->
|
|
// load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior is exercised
|
|
// headlessly in test_preset_bundle_loading.cpp.)
|
|
SCENARIO("Published 3MF round-trips the published flag and published_keys metadata", "[3mf]") {
|
|
GIVEN("a model carrying published 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();
|
|
|
|
model.model_info = std::make_shared<ModelInfo>();
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1";
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","wall_thickness"])";
|
|
|
|
// store_bbs_3mf stages 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_pub");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
WHEN("stored to and reloaded from a .3mf") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
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 published metadata round-trips unchanged") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1");
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","wall_thickness"])");
|
|
|
|
// The orca_published_keys value is a JSON array of setting keys; it must parse back to
|
|
// the same keys that were selected.
|
|
nlohmann::json keys = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG]);
|
|
REQUIRE(keys.is_array());
|
|
REQUIRE(keys.size() == 2);
|
|
REQUIRE(keys[0] == "layer_height");
|
|
REQUIRE(keys[1] == "wall_thickness");
|
|
}
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A normal 3MF (no Publish metadata) must load identically: the loader must not fabricate a
|
|
// "orca_published" flag or orca_published_keys for files that never carried them.
|
|
SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") {
|
|
GIVEN("a model without any published 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();
|
|
|
|
ScopedTemporaryDir backup_dir("orca_legacy");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
WHEN("stored to and reloaded from a .3mf") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
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("no published key is fabricated") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_TAG) == 0);
|
|
REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_KEYS_TAG) == 0);
|
|
}
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Locks the serialization contract of the orca_published_material_keys metadata: the per-entry JSON
|
|
// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like orca_published_keys.
|
|
SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") {
|
|
GIVEN("a model carrying published material keys 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();
|
|
|
|
const std::string material_keys_json =
|
|
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])";
|
|
|
|
model.model_info = std::make_shared<ModelInfo>();
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json;
|
|
|
|
ScopedTemporaryDir backup_dir("orca_pub_mat");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
WHEN("stored to and reloaded from a .3mf") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
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 published material keys metadata round-trips unchanged") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
|
|
|
// The value must parse back to one material entry carrying the nested identity
|
|
// object, the author slot ordinal and the key list.
|
|
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
|
|
REQUIRE(entries.is_array());
|
|
REQUIRE(entries.size() == 1);
|
|
REQUIRE(entries[0]["material"]["filament_type"] == "PLA");
|
|
REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic");
|
|
REQUIRE(entries[0]["material"]["filament_id"] == "GFL99");
|
|
REQUIRE(entries[0]["slot"] == 0);
|
|
REQUIRE(entries[0]["keys"].is_array());
|
|
REQUIRE(entries[0]["keys"].size() == 2);
|
|
REQUIRE(entries[0]["keys"][0] == "filament_retraction_length");
|
|
}
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
|
|
SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") {
|
|
GIVEN("a multi-instance model carrying published metadata and a published_config payload") {
|
|
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();
|
|
// A second instance: tag-less third-party files get their multi-instance objects split,
|
|
// published files must not (the loader recognizes them by their metadata).
|
|
model.objects.front()->add_instance();
|
|
|
|
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
|
|
full_cfg.set_key_value("layer_height", new ConfigOptionFloat(0.24));
|
|
full_cfg.set_key_value("retraction_length", new ConfigOptionFloats({ 1.2 }));
|
|
|
|
const std::vector<std::string> published_keys = { "layer_height", "retraction_length" };
|
|
const std::vector<PublishedMaterialEntry> material_keys = {
|
|
{ "PLA", "Generic", "GFL99", "", "Generic PLA", 0, { "filament_retraction_length" } }
|
|
};
|
|
|
|
// The payload builder keeps the published and identity keys and drops everything else.
|
|
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys);
|
|
REQUIRE(filtered_cfg.option("layer_height") != nullptr);
|
|
REQUIRE(filtered_cfg.option("retraction_length") != nullptr);
|
|
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
|
|
REQUIRE(filtered_cfg.option("filament_type") != nullptr);
|
|
REQUIRE(filtered_cfg.option("wipe_tower_x") != nullptr);
|
|
REQUIRE(filtered_cfg.option("sparse_infill_density") == nullptr);
|
|
REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr);
|
|
|
|
// Serialize the payload exactly like export_published_3mf does.
|
|
std::string payload;
|
|
for (const std::string &key : filtered_cfg.keys())
|
|
payload += key + " = " + filtered_cfg.opt_serialize(key) + "\n";
|
|
|
|
model.model_info = std::make_shared<ModelInfo>();
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1";
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","retraction_length"])";
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = payload;
|
|
|
|
ScopedTemporaryDir backup_dir("orca_min_pub");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
WHEN("stored using SaveStrategy::MinimalPublished and reloaded") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
// Create a fake project preset to verify MinimalPublished omits it.
|
|
Preset preset(Preset::TYPE_PRINT, "TestPrintPreset");
|
|
preset.config = full_cfg;
|
|
std::vector<Preset*> project_presets = { &preset };
|
|
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &filtered_cfg;
|
|
store_params.project_presets = project_presets;
|
|
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
Model dst_model;
|
|
ScopedTemporaryDir loaded_backup_dir("orca_min_pub_loaded");
|
|
dst_model.set_backup_path(loaded_backup_dir.string());
|
|
DynamicPrintConfig dst_config;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
|
PlateDataPtrs dst_plates;
|
|
std::vector<Preset*> loaded_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,
|
|
&loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
|
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
|
THEN("the 3MF loads without project config or embedded presets") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_config.empty());
|
|
REQUIRE(loaded_presets.empty());
|
|
}
|
|
THEN("the file carries no slicer tags and classifies as a generic 3MF") {
|
|
REQUIRE_FALSE(is_bbl_3mf);
|
|
REQUIRE_FALSE(is_orca_3mf);
|
|
// No Application / OrcaSlicer tag: old receivers import the geometry silently
|
|
// instead of showing a baked-in, wrong "old version" popup.
|
|
REQUIRE_FALSE(file_version.valid());
|
|
}
|
|
THEN("the geometry keeps BBS-grade handling: instances are not split") {
|
|
REQUIRE(dst_model.objects.size() == 1);
|
|
REQUIRE(dst_model.objects.front()->instances.size() == 2);
|
|
}
|
|
THEN("the published metadata and payload round-trip unchanged") {
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1");
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","retraction_length"])");
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] == payload);
|
|
}
|
|
THEN("the payload parses back to the published values") {
|
|
DynamicPrintConfig parsed_payload;
|
|
parsed_payload.load_from_ini_string(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG], ForwardCompatibilitySubstitutionRule::Enable);
|
|
REQUIRE(parsed_payload.option("layer_height") != nullptr);
|
|
REQUIRE_THAT(parsed_payload.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.24, 1e-6));
|
|
REQUIRE(parsed_payload.option("retraction_length") != nullptr);
|
|
REQUIRE_THAT(parsed_payload.opt<ConfigOptionFloats>("retraction_length")->get_at(0), Catch::Matchers::WithinAbs(1.2, 1e-6));
|
|
}
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A minimal published 3MF must not leak the slicer tags of the source project. The exporter seeds
|
|
// metadata_item_map from the input file's metadata_items, so re-publishing a project opened from a
|
|
// regular Orca/BBS 3MF (the typical remix flow) must strip the Application / OrcaSlicer tags it
|
|
// came with, otherwise old receivers route onto the baked-in "old version" popup.
|
|
SCENARIO("MinimalPublished strips slicer tags carried by the source project", "[3mf]") {
|
|
GIVEN("a model loaded from a regular Orca/BBS 3MF whose metadata carries the slicer tags") {
|
|
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();
|
|
|
|
model.model_info = std::make_shared<ModelInfo>();
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1";
|
|
model.model_info->metadata_items["Application"] = "BambuStudio-2.0.0";
|
|
model.model_info->metadata_items["OrcaSlicer"] = "2.1.0";
|
|
|
|
ScopedTemporaryDir backup_dir("orca_strip_tags");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
WHEN("stored using SaveStrategy::MinimalPublished and reloaded") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
Model dst_model;
|
|
DynamicPrintConfig dst_config;
|
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
|
PlateDataPtrs dst_plates;
|
|
std::vector<Preset*> loaded_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,
|
|
&loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
|
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
|
THEN("the source slicer tags are stripped, not carried through") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
REQUIRE(dst_model.model_info->metadata_items.count("Application") == 0);
|
|
REQUIRE(dst_model.model_info->metadata_items.count("OrcaSlicer") == 0);
|
|
// The published marker itself must survive.
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1");
|
|
}
|
|
THEN("the file classifies as a generic 3MF without a version popup") {
|
|
REQUIRE_FALSE(is_bbl_3mf);
|
|
REQUIRE_FALSE(is_orca_3mf);
|
|
REQUIRE_FALSE(file_version.valid());
|
|
}
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
|
|
// An entry masks the non-published slots to their defaults so publishing slot 1 never leaks slot
|
|
// 0's value into the file. Both a full entry (the whole-slot key list) and a partial entry (a
|
|
// per-slot key) go through the same masking path in filter_published_config (keys and full_keys
|
|
// are filtered identically), so the two forms are exercised together.
|
|
SCENARIO("Published entries mask the other slots to their defaults", "[3mf]") {
|
|
const bool full = GENERATE(true, false);
|
|
GIVEN("a full print configuration with two filament slots") {
|
|
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
|
|
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222" };
|
|
// filament_flow_ratio carries a non-empty option default (1.0) of the same type, so the
|
|
// mask can restore it on the non-published slot.
|
|
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
|
|
|
|
WHEN("filtering with a published entry for slot 1") {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
if (full) {
|
|
entry.full = true;
|
|
entry.full_keys = { "filament_flow_ratio" };
|
|
} else {
|
|
entry.keys = { "filament_flow_ratio" };
|
|
}
|
|
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { entry });
|
|
|
|
THEN("the selected key is present with the author's slot value") {
|
|
REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr);
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6));
|
|
}
|
|
THEN("the non-published slot is masked to its default") {
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6));
|
|
}
|
|
THEN("the identity keys stay present") {
|
|
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A key needing slot masking that cannot be masked (no registered option default of the same
|
|
// type) is dropped from the payload entirely instead of shipping the author's whole vector.
|
|
SCENARIO("Unmaskable keys are dropped from the published payload instead of leaking", "[3mf]") {
|
|
GIVEN("a config carrying a synthetic def-less vector key and a maskable one") {
|
|
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
|
|
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222" };
|
|
// Not a PrintConfig key: print_config_def has no default to mask with.
|
|
full_cfg.set_key_value("orca_synthetic_setting", new ConfigOptionFloats({ 9.9, 8.8 }));
|
|
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
|
|
|
|
PublishedMaterialEntry partial_entry;
|
|
partial_entry.slot = 1;
|
|
partial_entry.keys = { "orca_synthetic_setting", "filament_flow_ratio" };
|
|
|
|
WHEN("filtering with a partial entry for slot 1") {
|
|
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry });
|
|
|
|
THEN("the unmaskable synthetic key is not published") {
|
|
REQUIRE(filtered_cfg.option("orca_synthetic_setting") == nullptr);
|
|
}
|
|
THEN("the maskable key is present, author slot kept, other slot masked") {
|
|
REQUIRE(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio") != nullptr);
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6));
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6));
|
|
}
|
|
THEN("the identity keys stay present") {
|
|
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A per-extruder printer key carrying a "#N" variant (e.g. retraction_length#1) must not serialize
|
|
// every extruder's value: the base is masked to the author's extruder and the other slots are
|
|
// restored to their option default, matching the material-side slot-masking invariant. A bare
|
|
// printer base key (no variant) keeps whole-vector serialization.
|
|
SCENARIO("Published per-extruder printer keys mask the other extruders to their defaults", "[3mf]") {
|
|
GIVEN("a full print configuration with three extruders carrying per-extruder retraction values") {
|
|
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
|
|
// Non-default values on the un-selected slots, so a leak is distinguishable from the mask
|
|
// restoring the option default (retraction_length defaults to {0.8}).
|
|
full_cfg.opt<ConfigOptionFloats>("retraction_length")->values = { 3.0, 1.2, 4.0 };
|
|
|
|
WHEN("filtering with only extruder 1's retraction_length checked") {
|
|
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length#1" }, {});
|
|
|
|
THEN("the author's extruder value survives") {
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6));
|
|
}
|
|
THEN("the other extruders are masked to their default") {
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[0], Catch::Matchers::WithinAbs(0.8, 1e-6));
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[2], Catch::Matchers::WithinAbs(0.8, 1e-6));
|
|
}
|
|
}
|
|
WHEN("filtering the bare base key without a '#N' variant") {
|
|
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length" }, {});
|
|
|
|
THEN("the whole vector is serialized unmasked") {
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[0], Catch::Matchers::WithinAbs(3.0, 1e-6));
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6));
|
|
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[2], Catch::Matchers::WithinAbs(4.0, 1e-6));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The extended per-entry fields (full dump list, published type and colour) travel inside the
|
|
// published_material_keys metadata and round-trip unchanged.
|
|
SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
|
|
GIVEN("a model carrying extended published material keys 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();
|
|
|
|
const std::string material_keys_json =
|
|
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99","setting_id":"RFs9eCKYOMUSmvZf","name":"Generic PLA Matte @System"},"slot":1,"keys":[],"full":true,"full_keys":["filament_retraction_length","filament_colour"],"publish_type":true,"type":"PLA","publish_color":false,"color":""}])";
|
|
|
|
model.model_info = std::make_shared<ModelInfo>();
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json;
|
|
|
|
ScopedTemporaryDir backup_dir("orca_pub_mat2");
|
|
model.set_backup_path(backup_dir.string());
|
|
|
|
WHEN("stored to and reloaded from a .3mf") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string test_file = temp.string();
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
StoreParams store_params;
|
|
store_params.path = test_file.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
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 extended material metadata round-trips unchanged") {
|
|
REQUIRE(loaded);
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
|
|
|
// The value must parse back with every extended field intact.
|
|
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
|
|
REQUIRE(entries.is_array());
|
|
REQUIRE(entries.size() == 1);
|
|
REQUIRE(entries[0]["full"].get<bool>() == true);
|
|
REQUIRE(entries[0]["full_keys"].is_array());
|
|
REQUIRE(entries[0]["full_keys"].size() == 2);
|
|
REQUIRE(entries[0]["publish_type"].get<bool>() == true);
|
|
REQUIRE(entries[0]["type"] == "PLA");
|
|
REQUIRE(entries[0]["publish_color"].get<bool>() == false);
|
|
}
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A published mixed filament serializes its whole definition (components, ratios, gradient)
|
|
// masked to the author's slot: the mix slot's values survive, the non-published slots reset to
|
|
// their defaults, so a partial publish never leaks another slot's mix data.
|
|
SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf]") {
|
|
GIVEN("a full print configuration with three slots, one of them mixed") {
|
|
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
|
|
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222", "#333333" };
|
|
full_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
|
|
full_cfg.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
|
|
full_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
|
|
full_cfg.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 1 };
|
|
|
|
PublishedMaterialEntry mix_entry;
|
|
mix_entry.slot = 2;
|
|
mix_entry.keys = {
|
|
"filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios",
|
|
"filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part"
|
|
};
|
|
|
|
WHEN("filtering with a mixed entry for slot 2") {
|
|
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { mix_entry });
|
|
|
|
THEN("the author's mixed slot keeps its definition") {
|
|
REQUIRE(filtered_cfg.option("filament_is_mixed") != nullptr);
|
|
REQUIRE(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values == std::vector<unsigned char>{ 0, 0, 1 });
|
|
const auto& components = filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2] == "1,2");
|
|
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] == "0.6,0.4");
|
|
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values[2] == "0,0.1|1,0.9");
|
|
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_mixed_gradient")->values[2]);
|
|
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values[2]);
|
|
}
|
|
THEN("the non-published slots are masked to their defaults") {
|
|
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values[0] == "");
|
|
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values[1] == "");
|
|
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values[0] == 0);
|
|
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values[1] == 0);
|
|
}
|
|
THEN("the identity keys stay present") {
|
|
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The published flag is gated on the exact string "1": any other serialized value means "not
|
|
// published", so a receiver never treats a file as published on a loose truthiness check.
|
|
TEST_CASE("is_published_3mf_flag accepts only the literal \"1\"", "[3mf]") {
|
|
CHECK(is_published_3mf_flag("1"));
|
|
CHECK_FALSE(is_published_3mf_flag("0"));
|
|
CHECK_FALSE(is_published_3mf_flag("false"));
|
|
CHECK_FALSE(is_published_3mf_flag("true"));
|
|
CHECK_FALSE(is_published_3mf_flag(""));
|
|
CHECK_FALSE(is_published_3mf_flag("YES"));
|
|
}
|
|
|
|
// bbs_3mf_is_published is the lightweight metadata probe used to decide whether a file was
|
|
// produced by the publish feature (GUI "recently published" tracking). It must return true only
|
|
// for a file whose metadata carries the flag set to "1", and false for legacy files and for a
|
|
// file whose flag is present but not "1" (which loads as a normal, non-published 3MF).
|
|
SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]") {
|
|
auto store_model = [](const std::string &path, const std::string &flag_value, const std::string &keys_value) {
|
|
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();
|
|
model.model_info = std::make_shared<ModelInfo>();
|
|
// An empty flag_value means "don't write the flag at all" (a legacy file).
|
|
if (!flag_value.empty())
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = flag_value;
|
|
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = keys_value;
|
|
ScopedTemporaryDir backup_dir("orca_is_pub");
|
|
model.set_backup_path(backup_dir.string());
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
StoreParams store_params;
|
|
store_params.path = path.c_str();
|
|
store_params.model = &model;
|
|
store_params.config = &config;
|
|
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
};
|
|
|
|
GIVEN("a minimal published 3MF whose flag is \"1\"") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_model(temp.string(), "1", R"(["layer_height"])");
|
|
WHEN("probed by bbs_3mf_is_published") {
|
|
THEN("it is recognized as published") {
|
|
CHECK(bbs_3mf_is_published(temp.string()));
|
|
}
|
|
}
|
|
}
|
|
GIVEN("a legacy 3MF without any published flag") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_model(temp.string(), "", R"(["layer_height"])");
|
|
WHEN("probed by bbs_3mf_is_published") {
|
|
THEN("it is not recognized as published") {
|
|
CHECK_FALSE(bbs_3mf_is_published(temp.string()));
|
|
}
|
|
}
|
|
}
|
|
GIVEN("a 3MF carrying the flag set to \"0\"") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_model(temp.string(), "0", R"(["layer_height"])");
|
|
WHEN("probed and loaded") {
|
|
THEN("it is not recognized as published") {
|
|
CHECK_FALSE(bbs_3mf_is_published(temp.string()));
|
|
}
|
|
THEN("it loads as a normal, non-published 3MF") {
|
|
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;
|
|
REQUIRE(load_bbs_3mf(temp.string().c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
|
|
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
|
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
|
REQUIRE(dst_model.model_info != nullptr);
|
|
// The key is present but not "1", so nothing treats the file as published; the
|
|
// stored keys still round-trip verbatim.
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "0");
|
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height"])");
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Writes a single-entry zip whose central directory carries a zip64 record declaring an
|
|
// uncompressed size beyond what the 32-bit expat buffer API can take, while the deflated
|
|
// payload inflates to only ~64 KiB. Built by hand because miniz never writes a size that
|
|
// disagrees with the data.
|
|
static void write_zip_with_oversized_entry(const std::string& path, const std::string& entry)
|
|
{
|
|
const std::string xml = "<?xml version=\"1.0\"?><!--" + std::string(65536, 'A') + "--><a/>";
|
|
size_t comp_len = 0;
|
|
void* comp = tdefl_compress_mem_to_heap(xml.data(), xml.size(), &comp_len, TDEFL_DEFAULT_MAX_PROBES);
|
|
REQUIRE(comp != nullptr);
|
|
const std::string deflated(static_cast<const char*>(comp), comp_len);
|
|
mz_free(comp);
|
|
const uint32_t crc = static_cast<uint32_t>(mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(xml.data()), xml.size()));
|
|
const uint64_t claimed_size = (uint64_t(1) << 32) + 16;
|
|
|
|
std::string out;
|
|
auto put = [&out](uint64_t v, int bytes) {
|
|
for (int i = 0; i < bytes; ++i)
|
|
out.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
|
|
};
|
|
// local file header, with the true sizes
|
|
put(0x04034b50, 4); put(45, 2); put(0, 2); put(8, 2); put(0, 2); put(0, 2);
|
|
put(crc, 4); put(deflated.size(), 4); put(xml.size(), 4); put(entry.size(), 2); put(0, 2);
|
|
out += entry + deflated;
|
|
// central directory header, sizes deferred to the zip64 extra field
|
|
const size_t cd_offset = out.size();
|
|
put(0x02014b50, 4); put(45, 2); put(45, 2); put(0, 2); put(8, 2); put(0, 2); put(0, 2);
|
|
put(crc, 4); put(0xFFFFFFFF, 4); put(0xFFFFFFFF, 4); put(entry.size(), 2); put(20, 2);
|
|
put(0, 2); put(0, 2); put(0, 2); put(0, 4); put(0, 4);
|
|
out += entry;
|
|
put(0x0001, 2); put(16, 2); put(claimed_size, 8); put(deflated.size(), 8);
|
|
const size_t cd_size = out.size() - cd_offset;
|
|
// end of central directory
|
|
put(0x06054b50, 4); put(0, 2); put(0, 2); put(1, 2); put(1, 2);
|
|
put(cd_size, 4); put(cd_offset, 4); put(0, 2);
|
|
|
|
boost::nowide::ofstream f(path, std::ios::binary);
|
|
REQUIRE(f.good());
|
|
f.write(out.data(), static_cast<std::streamsize>(out.size()));
|
|
REQUIRE(f.good());
|
|
}
|
|
|
|
TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[3mf]") {
|
|
ScopedTemporaryFile temp(".3mf");
|
|
const std::string path = temp.string();
|
|
|
|
SECTION("BBS importer") {
|
|
write_zip_with_oversized_entry(path, "_rels/.rels");
|
|
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;
|
|
bool loaded = true;
|
|
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));
|
|
CHECK_FALSE(loaded);
|
|
release_PlateData_list(plates);
|
|
}
|
|
SECTION("PrusaSlicer importer") {
|
|
write_zip_with_oversized_entry(path, "Metadata/Slic3r_PE_model.config");
|
|
Model model;
|
|
DynamicPrintConfig config;
|
|
ConfigSubstitutionContext ctxt{ForwardCompatibilitySubstitutionRule::Disable};
|
|
bool loaded = true;
|
|
REQUIRE_NOTHROW(loaded = load_3mf(path.c_str(), config, ctxt, &model, false));
|
|
CHECK_FALSE(loaded);
|
|
}
|
|
SECTION("PrusaSlicer fingerprint probe") {
|
|
write_zip_with_oversized_entry(path, "3D/3dmodel.model");
|
|
PrusaFileParser parser;
|
|
CHECK_FALSE(parser.check_3mf_from_prusa(path));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern file paths", "[3mf][Regression]")
|
|
{
|
|
Model model;
|
|
REQUIRE(load_stl((std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl").c_str(), &model));
|
|
model.add_default_instances();
|
|
ScopedTemporaryDir backup_dir("orca_pick");
|
|
model.set_backup_path(backup_dir.string());
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
|
|
auto plate = std::make_unique<PlateData>();
|
|
plate->plate_index = 0;
|
|
plate->pick_file = (backup_dir.path() / "pick.png").string();
|
|
plate->pattern_bbox_file = (backup_dir.path() / "plate.json").string();
|
|
|
|
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.get());
|
|
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
REQUIRE(store_bbs_3mf(store_params));
|
|
|
|
boost::nowide::ifstream in(test_file, std::ios::binary);
|
|
Model dst_model;
|
|
DynamicPrintConfig dst_config;
|
|
PlateDataPtrs dst_plates;
|
|
Semver file_version;
|
|
dst_model.set_backup_path(backup_dir.string());
|
|
REQUIRE(load_gcode_3mf_from_stream(in, &dst_config, &dst_model, &dst_plates, &file_version));
|
|
REQUIRE(dst_plates.size() == 1);
|
|
CHECK(dst_plates.front()->pick_file == "Metadata/pick_1.png");
|
|
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
|
|
release_PlateData_list(dst_plates);
|
|
}
|
|
|
|
// Encodes a flat grayscale PNG the way the texture panel's import does, so what the archive carries
|
|
// is a real texture rather than an opaque blob.
|
|
static std::shared_ptr<std::vector<unsigned char>> make_gray_png(uint8_t value, size_t w = 4, size_t h = 4)
|
|
{
|
|
ScopedTemporaryFile file(".png");
|
|
const std::vector<uint8_t> pixels(w * h, value);
|
|
REQUIRE(png::write_gray_to_file(file.string(), w, h, pixels));
|
|
|
|
boost::nowide::ifstream in(file.string(), std::ios::binary);
|
|
std::vector<unsigned char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
REQUIRE_FALSE(bytes.empty());
|
|
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
|
}
|
|
|
|
// Every entry name in a 3mf archive, with the separators normalised the way the importer sees them.
|
|
static std::vector<std::string> zip_entry_names(const std::string& path)
|
|
{
|
|
std::vector<std::string> names;
|
|
mz_zip_archive zip;
|
|
mz_zip_zero_struct(&zip);
|
|
REQUIRE(open_zip_reader(&zip, path));
|
|
const mz_uint n = mz_zip_reader_get_num_files(&zip);
|
|
for (mz_uint i = 0; i < n; ++i) {
|
|
mz_zip_archive_file_stat st;
|
|
if (!mz_zip_reader_file_stat(&zip, i, &st) || st.m_is_directory)
|
|
continue;
|
|
std::string name(st.m_filename);
|
|
std::replace(name.begin(), name.end(), '\\', '/');
|
|
names.push_back(std::move(name));
|
|
}
|
|
close_zip_reader(&zip);
|
|
return names;
|
|
}
|
|
|
|
static const std::string TEXTURE_DISPLACEMENT_ENTRY_DIR = "Metadata/texture_displacement/";
|
|
|
|
// Stores a one-plate project holding a cube with one texture displacement layer, painted on that
|
|
// layer's own slot. The caller fills the layer in, and gets back the mask string the exporter wrote
|
|
// for `painted_facet`, which is what the load has to reproduce.
|
|
//
|
|
// The painted facet is deliberately not the first one: the importer keeps a slot's masks only as far
|
|
// as its last painted triangle, so a facet in the middle is the case where that run has to be padded
|
|
// in front and left short at the back.
|
|
static void store_texture_displaced_cube(const std::string& path, const TextureDisplacementLayer& layer,
|
|
const TextureDisplacementOptions& options, std::string& painted_mask,
|
|
int painted_facet = 5)
|
|
{
|
|
Model model;
|
|
ModelObject* object = model.add_object();
|
|
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
|
object->add_instance();
|
|
|
|
volume->texture_displacement_layers.push_back(layer);
|
|
volume->texture_displacement_options = options;
|
|
REQUIRE(painted_facet < int(volume->mesh().its.indices.size()));
|
|
{
|
|
TriangleSelector selector(volume->mesh());
|
|
selector.set_facet(painted_facet, EnforcerBlockerType::ENFORCER);
|
|
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
|
|
}
|
|
painted_mask = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet);
|
|
REQUIRE_FALSE(painted_mask.empty());
|
|
|
|
store_project(path, model);
|
|
}
|
|
|
|
// Returns the only volume of the only object in the project at `path`, loaded through the importer.
|
|
static const ModelVolume& load_single_volume(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
|
|
{
|
|
REQUIRE(load_project(path, model, backup_dir));
|
|
REQUIRE(model.objects.size() == 1);
|
|
REQUIRE(model.objects.front()->volumes.size() == 1);
|
|
return *model.objects.front()->volumes.front();
|
|
}
|
|
|
|
// A texture displacement stack is the one paint tool whose settings live outside the volume's config:
|
|
// the layer stack goes into the archive as its own JSON file, each layer's texture as a PNG beside it,
|
|
// and the paint as a per-triangle attribute (see add_texture_displacement). A project only reopens
|
|
// with its relief intact if all three come back together.
|
|
TEST_CASE("A texture displacement layer, its texture and its paint survive a project round trip", "[3mf][TextureDisplacement]")
|
|
{
|
|
TextureDisplacementLayer layer;
|
|
layer.slot = 3; // not the first slot, so the per-slot attribute names have to line up
|
|
layer.name = "Bark";
|
|
layer.path = "/textures/bark.png";
|
|
layer.depth_mm = 1.25f;
|
|
layer.tiling_scale = 7.5f;
|
|
layer.midlevel = 0.5f;
|
|
layer.invert = true;
|
|
layer.tile_method = TextureTileMethod::MirroredRepeat;
|
|
layer.projection_method = TextureProjectionMethod::LSCM;
|
|
layer.blend_mode = TextureBlendMode::Subtract;
|
|
layer.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
|
|
layer.image_data = make_gray_png(128);
|
|
|
|
TextureDisplacementOptions options;
|
|
options.smooth_enabled = true;
|
|
options.smooth_iterations = 4;
|
|
options.active_slot = layer.slot;
|
|
options.panel_mode = 1;
|
|
|
|
const int painted_facet = 5;
|
|
std::string painted;
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_texture_displaced_cube(temp.string(), layer, options, painted, painted_facet);
|
|
|
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
|
Model model;
|
|
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
|
|
|
|
REQUIRE(volume.texture_displacement_layers.size() == 1);
|
|
const TextureDisplacementLayer& read = volume.texture_displacement_layers.front();
|
|
CHECK(read.slot == layer.slot);
|
|
CHECK(read.name == layer.name);
|
|
CHECK(read.path == layer.path);
|
|
CHECK_THAT(read.depth_mm, Catch::Matchers::WithinAbs(layer.depth_mm, 1e-6f));
|
|
CHECK_THAT(read.tiling_scale, Catch::Matchers::WithinAbs(layer.tiling_scale, 1e-6f));
|
|
CHECK_THAT(read.midlevel, Catch::Matchers::WithinAbs(layer.midlevel, 1e-6f));
|
|
CHECK(read.invert == layer.invert);
|
|
CHECK(read.tile_method == layer.tile_method);
|
|
CHECK(read.projection_method == layer.projection_method);
|
|
CHECK(read.blend_mode == layer.blend_mode);
|
|
CHECK(read.lscm_seam_edges == layer.lscm_seam_edges);
|
|
|
|
// The texture rides along in the archive, named after the slot it belongs to, byte for byte.
|
|
CHECK(boost::algorithm::starts_with(read.path_in_3mf, TEXTURE_DISPLACEMENT_ENTRY_DIR));
|
|
CHECK(boost::algorithm::ends_with(read.path_in_3mf, "_" + std::to_string(layer.slot) + ".png"));
|
|
REQUIRE(read.image_data);
|
|
CHECK(*read.image_data == *layer.image_data);
|
|
|
|
CHECK(volume.texture_displacement_options.smooth_enabled);
|
|
CHECK(volume.texture_displacement_options.smooth_iterations == options.smooth_iterations);
|
|
CHECK(volume.texture_displacement_options.active_slot == options.active_slot);
|
|
CHECK(volume.texture_displacement_options.panel_mode == options.panel_mode);
|
|
|
|
// The paint comes back on the facet it was put on, on its own slot and nowhere else.
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(0).empty());
|
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
|
|
if (slot != layer.slot)
|
|
CHECK(volume.texture_displacement_facet(slot).empty());
|
|
}
|
|
|
|
// A layer names the file its texture was imported from, which the library panel uses to point at the
|
|
// entry the layer came from. That name must not be opened: the project may come from anyone, the path
|
|
// may name any file the user can read - on Windows a UNC path names a host as well - and the next save
|
|
// would copy whatever was found there into the archive. A layer the archive has no image for comes
|
|
// back without pixels instead, which the user fixes by picking the texture again.
|
|
TEST_CASE("A texture displacement layer whose image the archive lacks is not read from the path it names", "[3mf][TextureDisplacement][Regression]")
|
|
{
|
|
// A file that really is there, so the test fails if the importer ever reaches for it.
|
|
ScopedTemporaryFile texture(".png");
|
|
REQUIRE(png::write_gray_to_file(texture.string(), 4, 4, std::vector<uint8_t>(16, 200)));
|
|
|
|
TextureDisplacementLayer layer;
|
|
layer.slot = 0;
|
|
layer.name = "Bark";
|
|
layer.path = texture.string(); // and no image_data: nothing for the exporter to put in the archive
|
|
|
|
std::string painted;
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
|
|
|
|
for (const std::string& name : zip_entry_names(temp.string()))
|
|
if (boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR))
|
|
CHECK_FALSE(boost::algorithm::ends_with(name, ".png"));
|
|
|
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
|
Model model;
|
|
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
|
|
|
|
REQUIRE(volume.texture_displacement_layers.size() == 1);
|
|
CHECK(volume.texture_displacement_layers.front().path == texture.string());
|
|
CHECK_FALSE(bool(volume.texture_displacement_layers.front().image_data));
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
|
|
}
|
|
|
|
// The stack's JSON is arbitrary text by the time it is read back, and a value of the wrong type in it
|
|
// throws out of the parse. That has to cost the volume its layers, not the project: the model, and the
|
|
// paint the user can still build on, load as they would from a build without the feature.
|
|
TEST_CASE("A malformed texture displacement block leaves the rest of the project loadable", "[3mf][TextureDisplacement][Regression]")
|
|
{
|
|
TextureDisplacementLayer layer;
|
|
layer.slot = 0;
|
|
layer.name = "Bark";
|
|
layer.image_data = make_gray_png(128);
|
|
|
|
std::string painted;
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
|
|
|
|
REQUIRE(rewrite_3mf_entries(temp.string(), [](std::string& name, std::string& data) {
|
|
if (!boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR) ||
|
|
!boost::algorithm::ends_with(name, ".json"))
|
|
return false;
|
|
// `offset` goes through a typed accessor, so a string in it throws; a plain setting like
|
|
// depth_mm would be shrugged off by the parse instead, leaving the layer loadable.
|
|
data = R"({"version":1,"layers":[{"slot":0,"offset":["deep","deep"]}]})";
|
|
return true;
|
|
}));
|
|
|
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
|
Model model;
|
|
bool loaded = false;
|
|
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
|
|
REQUIRE(loaded);
|
|
REQUIRE(model.objects.size() == 1);
|
|
REQUIRE(model.objects.front()->volumes.size() == 1);
|
|
const ModelVolume& volume = *model.objects.front()->volumes.front();
|
|
|
|
CHECK(volume.texture_displacement_layers.empty());
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
|
|
}
|
|
|
|
// A slot's masks are kept only as far as its last painted triangle, so where the paint falls in the
|
|
// list decides whether that run has to be padded in front of the paint, cut short behind it, or both.
|
|
TEST_CASE("Texture displacement paint survives wherever it falls in the triangle list", "[3mf][TextureDisplacement]")
|
|
{
|
|
const int painted_facet = GENERATE(0, 5, 11); // first, middle and last facet of a cube
|
|
INFO("painted facet " << painted_facet);
|
|
|
|
TextureDisplacementLayer layer;
|
|
layer.slot = 0;
|
|
layer.name = "Bark";
|
|
layer.image_data = make_gray_png(128);
|
|
|
|
std::string painted;
|
|
ScopedTemporaryFile temp(".3mf");
|
|
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted, painted_facet);
|
|
|
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
|
Model model;
|
|
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
|
|
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
|
|
for (int facet = 0; facet < int(volume.mesh().its.indices.size()); ++facet)
|
|
if (facet != painted_facet)
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(facet).empty());
|
|
}
|
|
|
|
// Each volume's stack is written under the volume's own id, and the paint of a whole object arrives as
|
|
// one run of triangles that the volumes take their slices of. Two volumes using the same slot are the
|
|
// case where both of those have to tell the volumes apart.
|
|
TEST_CASE("Two volumes of one object keep their own texture displacement layers", "[3mf][TextureDisplacement]")
|
|
{
|
|
const auto first_image = make_gray_png(64);
|
|
const auto second_image = make_gray_png(192);
|
|
REQUIRE(*first_image != *second_image);
|
|
|
|
// The facet each volume is painted on, volume-local, so a mask landing on the wrong volume shows up.
|
|
const int painted_facets[2] = { 0, 7 };
|
|
std::string painted[2];
|
|
|
|
ScopedTemporaryFile temp(".3mf");
|
|
{
|
|
Model model;
|
|
ModelObject* object = model.add_object();
|
|
object->add_instance();
|
|
for (int i = 0; i < 2; ++i) {
|
|
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
|
TextureDisplacementLayer layer;
|
|
layer.slot = 0; // the same slot in both, which is what makes their names collide
|
|
layer.name = i == 0 ? "First" : "Second";
|
|
layer.depth_mm = i == 0 ? 0.5f : 1.5f;
|
|
layer.image_data = i == 0 ? first_image : second_image;
|
|
volume->texture_displacement_layers.push_back(layer);
|
|
|
|
TriangleSelector selector(volume->mesh());
|
|
selector.set_facet(painted_facets[i], EnforcerBlockerType::ENFORCER);
|
|
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
|
|
painted[i] = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]);
|
|
REQUIRE_FALSE(painted[i].empty());
|
|
}
|
|
store_project(temp.string(), model);
|
|
}
|
|
|
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
|
Model model;
|
|
REQUIRE(load_project(temp.string(), model, backup_dir));
|
|
REQUIRE(model.objects.size() == 1);
|
|
REQUIRE(model.objects.front()->volumes.size() == 2);
|
|
|
|
const std::shared_ptr<std::vector<unsigned char>> images[2] = { first_image, second_image };
|
|
const std::string names[2] = { "First", "Second" };
|
|
const float depths[2] = { 0.5f, 1.5f };
|
|
std::string entries[2];
|
|
for (int i = 0; i < 2; ++i) {
|
|
INFO("volume " << i);
|
|
const ModelVolume& volume = *model.objects.front()->volumes[i];
|
|
REQUIRE(volume.texture_displacement_layers.size() == 1);
|
|
const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front();
|
|
CHECK(layer.name == names[i]);
|
|
CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(depths[i], 1e-6f));
|
|
REQUIRE(layer.image_data);
|
|
CHECK(*layer.image_data == *images[i]);
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]) == painted[i]);
|
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[1 - i]).empty());
|
|
entries[i] = layer.path_in_3mf;
|
|
}
|
|
// Both volumes paint slot 0, so only the volume id keeps their textures apart in the archive.
|
|
CHECK(entries[0] != entries[1]);
|
|
}
|
|
|