diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 16f08e6a56..1a7080d5c7 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -405,6 +405,7 @@ set(lisbslic3r_sources Polyline.hpp PresetBundle.cpp PresetBundle.hpp + PrinterBedAssets.hpp PresetCacheFormat.cpp PresetCacheFormat.hpp Preset.cpp diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 572e7bb1c3..8215cb3aef 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include #include "PresetBundle.hpp" +#include "PrinterBedAssets.hpp" #include "Config.hpp" #include "Exception.hpp" @@ -64,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -1678,6 +1681,258 @@ bool PresetBundle::apply_vendor_config( return true; } +namespace { + +// The two bed files a printer bundle can carry. Zip names are bed//, never the source basename. +struct BedAssetSlot { + const char *option; + const char *stem; +}; + +constexpr BedAssetSlot k_bed_asset_slots[] = { + {"bed_custom_texture", "texture"}, + {"bed_custom_model", "model"}, +}; + +std::string zip_slashes(std::string name) +{ + for (char &c : name) + if (c == '\\') + c = '/'; + return name; +} + +// The bed dialog loads only png/svg textures and stl models. Anything else is not a bed asset. +bool bed_extension_allowed(const char *option, const std::string &ext) +{ + if (std::string(option) == "bed_custom_texture") + return boost::iequals(ext, ".png") || boost::iequals(ext, ".svg"); + if (std::string(option) == "bed_custom_model") + return boost::iequals(ext, ".stl"); + return false; +} + +// Extension including the dot, or empty when this option may not use it. +std::string bed_asset_extension(const std::string &path, const char *option) +{ + const auto slash = path.find_last_of("/\\"); + const auto base = slash == std::string::npos ? 0 : slash + 1; + const auto dot = path.find_last_of('.'); + if (dot == std::string::npos || dot < base || dot + 1 >= path.size()) + return {}; + const std::string ext = path.substr(dot); + if (!bed_extension_allowed(option, ext)) + return {}; + return ext; +} + +bool utf8_is_file(const std::string &path) +{ + if (path.empty()) + return false; + boost::system::error_code ec; +#ifdef _WIN32 + // A narrow path is the ANSI code page unless nowide_filesystem() has run. + const std::wstring wide = boost::nowide::widen(path); + if (wide.empty()) + return false; + return boost::filesystem::is_regular_file(boost::filesystem::path(wide), ec) && !ec; +#else + return boost::filesystem::is_regular_file(path, ec) && !ec; +#endif +} + +std::string bed_asset_dest_utf8(const fs::path &dest) +{ +#ifdef _WIN32 + return boost::nowide::narrow(dest.wstring()); +#else + return dest.string(); +#endif +} + +// True only for bed// with an extension the bed dialog accepts for this field. +bool accepted_bed_zip_name(const std::string &raw, const BedAssetSlot &slot, const fs::path &bundle_dir, + std::string &zip_name, std::string &folder) +{ + const std::string name = zip_slashes(raw); + if (name.find("..") != std::string::npos) + return false; + if (name.size() < 8 || name.compare(0, 4, "bed/") != 0) + return false; + const auto slash = name.find('/', 4); + if (slash == std::string::npos || slash == 4 || name.find('/', slash + 1) != std::string::npos) + return false; + const std::string digits = name.substr(4, slash - 4); + for (unsigned char c : digits) { + if (!std::isdigit(c)) + return false; + } + const std::string filename = name.substr(slash + 1); + const std::string ext = bed_asset_extension(filename, slot.option); + if (ext.empty() || filename != std::string(slot.stem) + ext) + return false; + if (!is_path_within_root(name, bundle_dir)) + return false; + zip_name = name; + folder = digits; + return true; +} + +struct PendingBedAsset { + std::string option; + std::string zip_name; + std::string dest; + std::string printer_key; + fs::path final_path; + fs::path staged; +}; + +// Validated asset names only. A raw manifest value that fails the check is not skipped later, +// so a value like printer/Name.json cannot hide that preset. +// printer_asset is keyed by printer_config zip entry. A flat (unkeyed) object is not read. +std::vector pending_bed_assets(const json &bundle_json, const fs::path &bundle_dir, + std::unordered_set &skip_names, + const std::unordered_set &zip_entries) +{ + std::vector pending; + if (!bundle_json.contains("printer_asset") || !bundle_json["printer_asset"].is_object()) + return pending; + const json &assets = bundle_json["printer_asset"]; + for (auto it = assets.begin(); it != assets.end(); ++it) { + const std::string printer_key = it.key(); + if (zip_entries.count(printer_key) == 0) { + BOOST_LOG_TRIVIAL(warning) << "Printer bundle asset entry is ignored, no matching printer in the zip: " << printer_key; + continue; + } + if (!it.value().is_object()) + continue; + const json &per_printer = it.value(); + for (const BedAssetSlot &slot : k_bed_asset_slots) { + if (!per_printer.contains(slot.option) || !per_printer[slot.option].is_string()) + continue; + const std::string raw = per_printer[slot.option].get(); + std::string zip_name; + std::string folder; + if (!accepted_bed_zip_name(raw, slot, bundle_dir, zip_name, folder)) { + BOOST_LOG_TRIVIAL(warning) << "Printer bundle asset path is rejected: " << raw; + continue; + } + const std::string filename = zip_name.substr(zip_name.rfind('/') + 1); + const fs::path final_path = fs::absolute(bundle_dir / "bed" / folder / filename); + pending.push_back({slot.option, zip_name, bed_asset_dest_utf8(final_path), printer_key, final_path, {}}); + skip_names.insert(zip_name); + } + } + return pending; +} + +// Stage under temp_folder. The bundle bed/ directory is not touched until a printer preset keeps the path. +bool stage_bed_asset(mz_zip_archive &zip, PendingBedAsset &asset, const fs::path &temp_folder) +{ + size_t size = 0; + void *buf = mz_zip_reader_extract_file_to_heap(&zip, asset.zip_name.c_str(), &size, MZ_ZIP_FLAG_CASE_SENSITIVE); + if (buf == nullptr) { + BOOST_LOG_TRIVIAL(warning) << "Printer bundle asset is missing, skipping " << asset.option << ": " << asset.zip_name; + return false; + } + const std::string filename = asset.zip_name.substr(asset.zip_name.rfind('/') + 1); + asset.staged = temp_folder / "bed" / asset.final_path.parent_path().filename() / filename; + boost::system::error_code ec; + fs::create_directories(asset.staged.parent_path(), ec); + const std::string staged_utf8 = bed_asset_dest_utf8(asset.staged); + bool wrote = false; + if (!ec) { + boost::nowide::ofstream out(staged_utf8, std::ios::binary | std::ios::trunc); + wrote = static_cast(out); + if (wrote && size > 0) + wrote = static_cast(out.write(static_cast(buf), static_cast(size))); + } + mz_free(buf); + if (!wrote) { + BOOST_LOG_TRIVIAL(warning) << "Printer bundle asset could not be written, skipping " << asset.option << ": " << staged_utf8; + fs::remove(asset.staged, ec); + return false; + } + return true; +} + +bool install_bed_asset(const PendingBedAsset &asset) +{ + boost::system::error_code ec; + fs::create_directories(asset.final_path.parent_path(), ec); + if (!ec) + fs::copy_file(asset.staged, asset.final_path, fs::copy_options::overwrite_existing, ec); + if (ec) { + BOOST_LOG_TRIVIAL(warning) << "Printer bundle asset could not be installed: " << asset.dest; + return false; + } + return true; +} + +bool is_pending_bed_asset_entry(const std::string &entry, const std::unordered_set &names) +{ + return names.count(entry) != 0 || names.count(zip_slashes(entry)) != 0; +} + +bool printer_kept_bed_paths(const PresetCollection &printers, const std::map &paths) +{ + if (paths.empty()) + return false; + for (const Preset &preset : printers.get_presets()) { + for (const auto &entry : paths) + if (preset.config.opt_string(entry.first) == entry.second) + return true; + } + return false; +} + +} // namespace + +bool append_printer_bed_assets(mz_zip_archive &zip, const DynamicPrintConfig &config, json &bundle_structure, + const std::string &printer_config_entry, int printer_index) +{ + if (printer_config_entry.empty() || printer_index < 0) + return true; + json assets = json::object(); + for (const BedAssetSlot &slot : k_bed_asset_slots) { + if (!config.has(slot.option)) + continue; + const std::string stored = config.opt_string(slot.option); + if (stored.empty()) + continue; + if (!utf8_is_file(stored)) { + BOOST_LOG_TRIVIAL(warning) << "Bed asset file is missing, skipping " << slot.option << ": " << stored; + continue; + } + const std::string ext = bed_asset_extension(stored, slot.option); + if (ext.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Bed asset extension is not packable, skipping " << slot.option << ": " << stored; + continue; + } + boost::nowide::ifstream in(stored, std::ios::binary); + if (!in) { + BOOST_LOG_TRIVIAL(warning) << "Bed asset file cannot be read, skipping " << slot.option << ": " << stored; + continue; + } + const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (in.bad()) { + BOOST_LOG_TRIVIAL(warning) << "Bed asset file cannot be read, skipping " << slot.option << ": " << stored; + continue; + } + const std::string zip_name = std::string("bed/") + std::to_string(printer_index) + "/" + slot.stem + ext; + const void *data = bytes.empty() ? static_cast("") : static_cast(bytes.data()); + if (mz_zip_writer_add_mem(&zip, zip_name.c_str(), data, bytes.size(), MZ_DEFAULT_COMPRESSION) == MZ_FALSE) { + BOOST_LOG_TRIVIAL(error) << "Failed to add bed asset to printer bundle: " << zip_name; + return false; + } + assets[slot.option] = zip_name; + } + if (!assets.empty()) + bundle_structure["printer_asset"][printer_config_entry] = std::move(assets); + return true; +} + // Import presets from UI control PresetsConfigSubstitutions PresetBundle::import_presets(std::vector & files, std::function override_confirm, @@ -1773,14 +2028,44 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " No bundle_structure.json found, importing presets into the user preset directory"; } + json bundle_json = json::object(); + std::unordered_set bed_asset_entries; + std::vector bed_assets; + std::map> bed_paths_by_printer; + bool bed_paths_loaded = false; + if (has_bundle_structure && !bundle_base_dir.empty()) { + try { + boost::nowide::ifstream manifest_in(metadata_path.string()); + manifest_in >> bundle_json; + } catch (const std::exception &err) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " failed to read bundle manifest: " << err.what(); + bundle_json = json::object(); + } + std::unordered_set zip_entries; + const int zip_file_count = mz_zip_reader_get_num_files(&zip_archive); + for (int zi = 0; zi < zip_file_count; ++zi) { + mz_zip_archive_file_stat st; + if (mz_zip_reader_file_stat(&zip_archive, zi, &st)) + zip_entries.insert(zip_slashes(st.m_filename)); + } + bed_assets = pending_bed_assets(bundle_json, bundle_base_dir, bed_asset_entries, zip_entries); + for (PendingBedAsset &asset : bed_assets) + if (stage_bed_asset(zip_archive, asset, temp_folder)) + bed_paths_by_printer[asset.printer_key].emplace(asset.option, asset.dest); + } + // Extract Files int num_files = mz_zip_reader_get_num_files(&zip_archive); + const std::map no_bed_paths; for (int i = 0; i < num_files; i++) { mz_zip_archive_file_stat file_stat; status = mz_zip_reader_file_stat(&zip_archive, i, &file_stat); if (status) { std::string file_name = file_stat.m_filename; + const std::string zip_entry = zip_slashes(file_name); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " From zip file: " << file << ". Read file name: " << file_stat.m_filename; + if (is_pending_bed_asset_entry(file_name, bed_asset_entries)) + continue; size_t index = file_name.find_last_of("/\\"); if (std::string::npos != index) { file_name = file_name.substr(index + 1); @@ -1798,13 +2083,26 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector if (MZ_FALSE == status) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Failed to open target file: " << target_file_path; } else { + const auto paths_it = bed_paths_by_printer.find(zip_entry); + const std::map &this_bed_paths = + paths_it != bed_paths_by_printer.end() ? paths_it->second : no_bed_paths; bool is_success = import_json_presets(substitutions, target_file_path, override_confirm, rule, overwrite, result, - has_bundle_structure ? bundle_base_dir.string() : std::string()); + has_bundle_structure ? bundle_base_dir.string() : std::string(), + this_bed_paths, &bed_paths_loaded); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " import target file: " << target_file_path << " import result" << is_success; } } } + // Copy into bed/ only after this import loads a printer that still holds the staged path. + // A preset left from an earlier import must not count: declining this import leaves its files alone. + if (bed_paths_loaded) { + for (const PendingBedAsset &asset : bed_assets) { + if (printer_kept_bed_paths(printers, {{asset.option, asset.dest}})) + install_bed_asset(asset); + } + } + // Set imported_time to current time if not already set if (metadata.imported_time == 0) { metadata.imported_time = std::time(nullptr); @@ -1846,7 +2144,9 @@ bool PresetBundle::import_json_presets(PresetsConfigSubstitutions & s ForwardCompatibilitySubstitutionRule rule, int & overwrite, std::vector & result, - const std::string & bundle_dir) + const std::string & bundle_dir, + const std::map & bed_asset_paths, + bool * bed_paths_loaded) { try { DynamicPrintConfig config; @@ -1910,6 +2210,10 @@ bool PresetBundle::import_json_presets(PresetsConfigSubstitutions & s if (inherit_preset) inherits_value = inherit_preset->name; // keep the base_id redo below in sync } + if (collection == &printers) { + for (const auto &entry : bed_asset_paths) + config.option(entry.first, true)->value = entry.second; + } if (inherit_preset) { new_config = inherit_preset->config; new_config.apply(std::move(config)); @@ -1927,6 +2231,8 @@ bool PresetBundle::import_json_presets(PresetsConfigSubstitutions & s new_config.apply(std::move(config)); extend_default_config_length(new_config, true, default_preset.config); } + if (bed_paths_loaded != nullptr && collection == &printers && !bed_asset_paths.empty()) + *bed_paths_loaded = true; Preset &preset = collection->load_preset(collection->path_from_name(name, inherit_preset == nullptr), preset_name, std::move(new_config), false); preset.bundle_id = load_origin.bundle_id; diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 797602922b..c6f79b741e 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -319,7 +319,9 @@ public: ForwardCompatibilitySubstitutionRule rule, int& overwrite, std::vector& result, - const std::string& bundle_dir = ""); + const std::string& bundle_dir = "", + const std::map &bed_asset_paths = {}, + bool *bed_paths_loaded = nullptr); void save_user_presets(AppConfig& config, std::map& need_to_delete_list); void check_and_fix_user_presets_syncinfo(const std::string& user_id); diff --git a/src/libslic3r/PrinterBedAssets.hpp b/src/libslic3r/PrinterBedAssets.hpp new file mode 100644 index 0000000000..74f0c767aa --- /dev/null +++ b/src/libslic3r/PrinterBedAssets.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "miniz_extension.hpp" + +#include + +#include +#include + +namespace Slic3r { + +class DynamicPrintConfig; + +// Not on PresetBundle.hpp: miniz.h's zlib-compat macros rename GUI methods (GLTexture::Compressor::compress). +// printer_config_entry is the zip name also pushed onto bundle_structure["printer_config"]. +// printer_index is that entry's 0-based index in the exported printer_config list. +bool append_printer_bed_assets(mz_zip_archive &zip, const DynamicPrintConfig &config, nlohmann::json &bundle_structure, + const std::string &printer_config_entry, int printer_index); + +} // namespace Slic3r diff --git a/src/slic3r/GUI/CreatePresetsDialog.cpp b/src/slic3r/GUI/CreatePresetsDialog.cpp index 12d966fbc5..c0dcddc2d5 100644 --- a/src/slic3r/GUI/CreatePresetsDialog.cpp +++ b/src/slic3r/GUI/CreatePresetsDialog.cpp @@ -65,6 +65,7 @@ #include #include #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PrinterBedAssets.hpp" #include "I18N.hpp" #include "GUI_App.hpp" #include "MsgDialog.hpp" @@ -3674,9 +3675,20 @@ ExportConfigsDialog::~ExportConfigsDialog() } void ExportConfigsDialog::on_dpi_changed(const wxRect &suggested_rect) { + if (m_include_bed_assets) + m_include_bed_assets->Rescale(); Layout(); } +void ExportConfigsDialog::update_include_bed_assets_enabled() +{ + const bool on = get_curr_radio_type(m_export_type_btns) == m_exprot_type.preset_bundle; + if (m_include_bed_assets) + m_include_bed_assets->Enable(on); + if (m_include_bed_assets_label) + m_include_bed_assets_label->Enable(on); +} + void ExportConfigsDialog::show_export_result(const ExportCase &export_case) { MessageDialog *msg_dlg = nullptr; @@ -3819,6 +3831,17 @@ wxBoxSizer *ExportConfigsDialog::create_export_config_item(wxWindow *parent) static_export_printer_preset_bundle_text->SetFont(Label::Body_12); static_export_printer_preset_bundle_text->SetForegroundColour(wxColour("#6B6B6B")); radioBoxSizer->Add(static_export_printer_preset_bundle_text, 0, wxEXPAND | wxLEFT, FromDIP(22)); + wxBoxSizer *include_bed_sizer = new wxBoxSizer(wxHORIZONTAL); + m_include_bed_assets = new ::CheckBox(parent); + m_include_bed_assets->SetValue(true); + include_bed_sizer->Add(m_include_bed_assets, 0, wxALIGN_CENTER_VERTICAL, 0); + m_include_bed_assets_label = new wxStaticText(parent, wxID_ANY, _L("Include custom bed texture and model")); + m_include_bed_assets_label->SetFont(Label::Body_12); + m_include_bed_assets_label->SetForegroundColour(wxColour("#6B6B6B")); + include_bed_sizer->Add(m_include_bed_assets_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(5)); + radioBoxSizer->Add(0, 0, 0, wxTOP, FromDIP(6)); + radioBoxSizer->Add(include_bed_sizer, 0, wxEXPAND | wxLEFT, FromDIP(22)); + update_include_bed_assets_enabled(); radioBoxSizer->Add(create_radio_item(m_exprot_type.filament_bundle, parent, wxEmptyString, m_export_type_btns), 0, wxEXPAND | wxTOP, FromDIP(10)); wxStaticText *static_export_filament_preset_bundle_text = new wxStaticText(parent, wxID_ANY, _L("User's filament preset set.\nCan be shared with others."), wxDefaultPosition, wxDefaultSize); @@ -3988,9 +4011,10 @@ void ExportConfigsDialog::select_curr_radiobox(std::vectorSetValue(false); } } + update_include_bed_assets_enabled(); } -ExportConfigsDialog::ExportCase ExportConfigsDialog::archive_preset_bundle_to_file(const wxString &path) +ExportConfigsDialog::ExportCase ExportConfigsDialog::archive_preset_bundle_to_file(const wxString &path, bool include_bed_assets) { std::string export_path = initial_file_path(path, ""); if (export_path.empty() || "initial_failed" == export_path) return ExportCase::EXPORT_CANCEL; @@ -4092,6 +4116,12 @@ ExportConfigsDialog::ExportCase ExportConfigsDialog::archive_preset_bundle_to_fi bundle_structure["filament_config"] = filament_configs; bundle_structure["process_config"] = process_configs; + if (include_bed_assets && + !append_printer_bed_assets(zip_archive, printer_preset->config, bundle_structure, printer_config_file_name, 0)) { + mz_zip_writer_end(&zip_archive); + return ExportCase::ADD_FILE_FAIL; + } + std::string bundle_structure_str = bundle_structure.dump(); status = mz_zip_writer_add_mem(&zip_archive, BUNDLE_STRUCTURE_JSON_NAME, bundle_structure_str.data(), bundle_structure_str.size(), MZ_DEFAULT_COMPRESSION); if (MZ_FALSE == status) { @@ -4339,7 +4369,7 @@ wxWindow *ExportConfigsDialog::create_dialog_buttons(wxWindow* parent) const wxString curr_radio_type = get_curr_radio_type(m_export_type_btns); if (curr_radio_type == m_exprot_type.preset_bundle) { - export_case = archive_preset_bundle_to_file(path); + export_case = archive_preset_bundle_to_file(path, m_include_bed_assets->GetValue()); } else if (curr_radio_type == m_exprot_type.filament_bundle) { export_case = archive_filament_bundle_to_file(path); } else if (curr_radio_type == m_exprot_type.printer_preset) { diff --git a/src/slic3r/GUI/CreatePresetsDialog.hpp b/src/slic3r/GUI/CreatePresetsDialog.hpp index 39714af1fe..197dfdf142 100644 --- a/src/slic3r/GUI/CreatePresetsDialog.hpp +++ b/src/slic3r/GUI/CreatePresetsDialog.hpp @@ -271,6 +271,7 @@ protected: private: void data_init(); void select_curr_radiobox(std::vector> &radiobox_list, int btn_idx); + void update_include_bed_assets_enabled(); void on_dpi_changed(const wxRect &suggested_rect) override; void show_export_result(const ExportCase &export_case); bool has_check_box_selected(); @@ -284,7 +285,7 @@ private: int initial_zip_archive(mz_zip_archive &zip_archive, const std::string &file_path); ExportCase save_zip_archive_to_file(mz_zip_archive &zip_archive); ExportCase save_presets_to_zip(const std::string &export_file, const std::vector> &config_paths); - ExportCase archive_preset_bundle_to_file(const wxString &path); + ExportCase archive_preset_bundle_to_file(const wxString &path, bool include_bed_assets); ExportCase archive_filament_bundle_to_file(const wxString &path); ExportCase archive_printer_preset_to_file(const wxString &path); ExportCase archive_filament_preset_to_file(const wxString &path); @@ -304,6 +305,8 @@ private: wxGridSizer * m_preset_sizer = nullptr; wxPanel * m_presets_window = nullptr; wxStaticText * m_serial_text = nullptr; + ::CheckBox * m_include_bed_assets = nullptr; + wxStaticText * m_include_bed_assets_label = nullptr; }; class CreatePresetForPrinterDialog : public DPIDialog diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index e44938d03b..02d607650f 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include @@ -22,6 +24,7 @@ #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/miniz_extension.hpp" +#include "libslic3r/PrinterBedAssets.hpp" #include "test_utils.hpp" @@ -5853,6 +5856,609 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th } } +namespace { + +const std::string kTextureBytes("\x89PNG\x00tex", 8); +const std::string kModelBytes("stl\x00model", 9); +const std::string kOtherBytes("\x89PNG\x00other", 10); + +void write_bytes(const std::string &utf8_path, const std::string &bytes) +{ + const auto slash = utf8_path.find_last_of("/\\"); + if (slash != std::string::npos) { +#ifdef _WIN32 + fs::create_directories(fs::path(boost::nowide::widen(utf8_path.substr(0, slash)))); +#else + fs::create_directories(utf8_path.substr(0, slash)); +#endif + } + boost::nowide::ofstream out(utf8_path, std::ios::binary | std::ios::trunc); + REQUIRE(out); + out.write(bytes.data(), static_cast(bytes.size())); + REQUIRE(out); +} + +fs::path write_printer_json(const DynamicPrintConfig &defaults, const fs::path &file, const std::string &name, + const std::string &texture, const std::string &model, const std::string &inherits = {}) +{ + DynamicPrintConfig config(defaults); + config.option("printer_settings_id", true)->value = name; + config.option("inherits", true)->value = inherits; + if (texture.empty()) + config.erase("bed_custom_texture"); + else + config.option("bed_custom_texture", true)->value = texture; + if (model.empty()) + config.erase("bed_custom_model"); + else + config.option("bed_custom_model", true)->value = model; + fs::create_directories(file.parent_path()); + config.save_to_json(file.string(), name, "User", "1.0.0"); + return file; +} + +nlohmann::json bed_manifest(const std::string &bundle_id) +{ + nlohmann::json structure = nlohmann::json::object(); + structure["id"] = bundle_id; + structure["bundle_type"] = "printer config bundle"; + structure["printer_config"] = nlohmann::json::array({"printer/BedPrinter.json"}); + return structure; +} + +void set_printer_bed_asset(nlohmann::json &structure, const std::string &option, const std::string &zip_name, + const std::string &printer_entry = "printer/BedPrinter.json") +{ + structure["printer_asset"][printer_entry][option] = zip_name; +} + +nlohmann::json pack_bed_zip(const fs::path &zip_file, const fs::path &printer_json, const DynamicPrintConfig &defaults, + const std::string &texture, const std::string &model, const std::string &bundle_id, + bool include_bed_assets = true) +{ + DynamicPrintConfig config(defaults); + if (!texture.empty()) + config.option("bed_custom_texture", true)->value = texture; + if (!model.empty()) + config.option("bed_custom_model", true)->value = model; + nlohmann::json structure = bed_manifest(bundle_id); + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + const std::string printer_bytes = read_file(printer_json); + REQUIRE(open_zip_writer(&zip, zip_file.string())); + REQUIRE(mz_zip_writer_add_mem(&zip, "printer/BedPrinter.json", printer_bytes.data(), printer_bytes.size(), + MZ_DEFAULT_COMPRESSION)); + if (include_bed_assets) + REQUIRE(append_printer_bed_assets(zip, config, structure, "printer/BedPrinter.json", 0)); + const std::string manifest = structure.dump(); + REQUIRE(mz_zip_writer_add_mem(&zip, BUNDLE_STRUCTURE_JSON_NAME, manifest.data(), manifest.size(), + MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_finalize_archive(&zip)); + REQUIRE(close_zip_writer(&zip)); + return structure; +} + +std::vector zip_entry_names(const fs::path &zip_file) +{ + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_reader(&zip, zip_file.string())); + std::vector names; + const int n = static_cast(mz_zip_reader_get_num_files(&zip)); + for (int i = 0; i < n; ++i) { + mz_zip_archive_file_stat st; + REQUIRE(mz_zip_reader_file_stat(&zip, i, &st)); + names.emplace_back(st.m_filename); + } + REQUIRE(close_zip_reader(&zip)); + return names; +} + +const Preset *import_named_printer(PresetBundle &bundle, const fs::path &zip_file, const fs::path &data_root, + const std::string &bare_name, int confirm = 1) +{ + ScopedDataDir scoped(data_root); + AppConfig config; + std::vector files{zip_file.string()}; + bundle.import_presets(files, [confirm](std::string const &) { return confirm; }, + ForwardCompatibilitySubstitutionRule::Disable, config); + for (const Preset &preset : bundle.printers.get_presets()) + if (get_preset_bare_name(preset.name) == bare_name && !preset.file.empty()) + return &preset; + return nullptr; +} + +const Preset *import_bed(PresetBundle &bundle, const fs::path &zip_file, const fs::path &data_root, int confirm = 1) +{ + return import_named_printer(bundle, zip_file, data_root, "BedPrinter", confirm); +} + +struct BedRoots { + ScopedTemporaryDir temp_dir; + fs::path src; + fs::path data_root; + PresetBundle bundle; + BedRoots() + : src(temp_dir.path() / "src") + , data_root(temp_dir.path() / "datadir") + {} +}; + +} // namespace + +TEST_CASE("Printer bundle round trip packs bed texture and model and rewrites both keys", "[Preset][Bundle]") +{ + // tex_rel "*" builds a non-ASCII source path under the temp directory. + const auto pack_import = [](const std::string &bundle_id, const std::string &tex_rel, const std::string &mod_rel, + const std::string &tex_bytes, const std::string &mod_bytes, bool tex_exists, + bool mod_exists, int imports) { + ScopedTemporaryDir temp_dir; + const fs::path src = temp_dir.path() / "src"; + const fs::path data_root = temp_dir.path() / "datadir"; + std::string texture = tex_rel.empty() ? std::string() : (src / tex_rel).make_preferred().string(); + std::string model = mod_rel.empty() ? std::string() : (src / mod_rel).make_preferred().string(); + if (tex_rel == "*") { + const fs::path dir = fs::path(src.wstring()) / fs::path(boost::nowide::widen("\xE5\xBA\x8A")); + texture = boost::nowide::narrow((dir / fs::path(boost::nowide::widen("\xE7\xBA\xB9\xE7\x90\x86.png"))).wstring()); + } + if (tex_exists) + write_bytes(texture, tex_bytes); + if (mod_exists) + write_bytes(model, mod_bytes); + PresetBundle bundle; + const fs::path printer_json = write_printer_json(bundle.printers.default_preset().config, src / "BedPrinter.json", + "BedPrinter", texture, model); + const fs::path zip_file = src / "bundle.orca_printer"; + const nlohmann::json structure = pack_bed_zip(zip_file, printer_json, bundle.printers.default_preset().config, + texture, model, bundle_id); + const Preset *saved = nullptr; + for (int i = 0; i < imports; ++i) + saved = import_bed(bundle, zip_file, data_root); + REQUIRE(saved != nullptr); + const std::string got[] = {saved->config.opt_string("bed_custom_texture"), saved->config.opt_string("bed_custom_model")}; + const std::string rel[] = {tex_rel, mod_rel}; + const std::string bytes[] = {tex_bytes, mod_bytes}; + const std::string path[] = {texture, model}; + const bool exists[] = {tex_exists, mod_exists}; + const char *option[] = {"bed_custom_texture", "bed_custom_model"}; + const char *stem[] = {"texture", "model"}; + for (int slot = 0; slot < 2; ++slot) { + if (exists[slot]) { + const std::string ext = fs::path(rel[slot] == "*" ? "x.png" : rel[slot]).extension().string(); + const std::string zip_name = std::string("bed/0/") + stem[slot] + ext; + REQUIRE(structure["printer_asset"]["printer/BedPrinter.json"][option[slot]] == zip_name); + REQUIRE(fs::path(got[slot]).filename().string() == std::string(stem[slot]) + fs::path(zip_name).extension().string()); + REQUIRE(fs::path(got[slot]).parent_path().filename() == "0"); + REQUIRE(fs::path(got[slot]).parent_path().parent_path().filename() == "bed"); + REQUIRE(got[slot].find(bundle_id) != std::string::npos); + REQUIRE(read_file(got[slot]) == bytes[slot]); + REQUIRE(got[slot] != path[slot]); + } else if (!rel[slot].empty() && rel[slot] != "*") { + REQUIRE_FALSE(structure.value("printer_asset", nlohmann::json::object()) + .value("printer/BedPrinter.json", nlohmann::json::object()) + .contains(option[slot])); + REQUIRE(got[slot] == path[slot]); + } else if (rel[slot].empty()) { + REQUIRE(got[slot].empty()); + } + } + if (imports > 1) { + int copies = 0; + for (fs::recursive_directory_iterator it(data_root), end; it != end; ++it) + if (it->path().filename() == "texture.png" || it->path().filename() == "model.stl") + ++copies; + REQUIRE(copies == (tex_exists ? 1 : 0) + (mod_exists ? 1 : 0)); + } + }; + + SECTION("both assets are packed from the preset config and rewritten on import") { + pack_import("bed-roundtrip", "fixture/baseline_bed_texture.png", "fixture/baseline_bed_model.stl", + kTextureBytes, kModelBytes, true, true, 1); + } + SECTION("texture only") { + pack_import("texture-only", "only.png", "", kTextureBytes, "", true, false, 1); + } + SECTION("model only") { + pack_import("model-only", "", "only.stl", "", kModelBytes, false, true, 1); + } + SECTION("texture and model share a source basename") { + pack_import("shared-basename", "a/plate.png", "b/plate.stl", kTextureBytes, kOtherBytes, true, true, 1); + } + SECTION("importing the same bundle twice keeps one copy of the assets") { + // First import installs kTextureBytes / kModelBytes. The second pack uses different bytes so + // the Yes-overwrite path (copy_file overwrite_existing) is actually proven. + BedRoots bed; + const std::string texture = (bed.src / "twice.png").make_preferred().string(); + const std::string model = (bed.src / "twice.stl").make_preferred().string(); + write_bytes(texture, kTextureBytes); + write_bytes(model, kModelBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, + bed.src / "BedPrinter.json", "BedPrinter", texture, model); + const fs::path zip_file = bed.src / "bundle.orca_printer"; + pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config, texture, model, "reimport"); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + const std::string got_tex = saved->config.opt_string("bed_custom_texture"); + const std::string got_mod = saved->config.opt_string("bed_custom_model"); + REQUIRE(read_file(got_tex) == kTextureBytes); + REQUIRE(read_file(got_mod) == kModelBytes); + write_bytes(texture, kOtherBytes); + write_bytes(model, std::string("stl\x00other", 9)); + pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config, texture, model, "reimport"); + saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == got_tex); + REQUIRE(saved->config.opt_string("bed_custom_model") == got_mod); + REQUIRE(read_file(got_tex) == kOtherBytes); + REQUIRE(read_file(got_mod) == std::string("stl\x00other", 9)); + } + SECTION("a non-ASCII source path is packed") { + pack_import("non-ascii", "*", "", kTextureBytes, "", true, false, 1); + } +} + +TEST_CASE("An old printer bundle without printer_asset imports unchanged", "[Preset][Bundle]") +{ + BedRoots bed; + const std::string texture = (bed.src / "old.png").make_preferred().string(); + const std::string model = (bed.src / "old.stl").make_preferred().string(); + write_bytes(texture, kTextureBytes); + write_bytes(model, kModelBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", texture, model); + nlohmann::json structure = bed_manifest("old-bundle"); + REQUIRE_FALSE(structure.contains("printer_asset")); + const fs::path zip_file = bed.src / "old.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}}); + // A bed/ folder left by an earlier import into the same bundle folder must survive an asset-less import. + const fs::path old_bed = bed.data_root / "user" / "default" / "_local" / "old-bundle" / "bed"; + write_bytes((old_bed / "texture.png").string(), kTextureBytes); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == texture); + REQUIRE(saved->config.opt_string("bed_custom_model") == model); + CHECK(fs::exists(old_bed / "texture.png")); +} + +TEST_CASE("Printer bundle asset names that escape the bundle directory are rejected", "[Preset][Bundle]") +{ + const char *bad_names[] = {"../../pwned.png", "bed/../../pwned.png", "..\\..\\pwned.png", "C:/Windows/pwned.png", + "machine/texture.png", "/bed/texture.png"}; + const std::string marker = "PWNED-BED-ASSET-MARKER"; + for (const char *name : bad_names) { + DYNAMIC_SECTION("rejects " << name) { + BedRoots bed; + const std::string texture = (bed.src / "kept.png").make_preferred().string(); + const std::string model = (bed.src / "kept.stl").make_preferred().string(); + write_bytes(texture, kTextureBytes); + write_bytes(model, kModelBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, + bed.src / "BedPrinter.json", "BedPrinter", texture, model); + nlohmann::json structure = bed_manifest("traverse"); + set_printer_bed_asset(structure, "bed_custom_texture", name); + std::vector> entries = { + {"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}}; + if (name[0] != '/' && name[0] != '\\') + entries.emplace_back(name, marker); + const fs::path zip_file = bed.src / "bad.orca_printer"; + write_zip(zip_file, entries); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == texture); + REQUIRE(saved->config.opt_string("bed_custom_model") == model); + CHECK_FALSE(any_filename_contains(bed.data_root, fs::path(name).filename().string())); + if (fs::exists(bed.data_root)) + for (fs::recursive_directory_iterator it(bed.data_root), end; it != end; ++it) + if (fs::is_regular_file(*it)) + CHECK(read_file(*it).find(marker) == std::string::npos); + } + } +} + +TEST_CASE("A printer bundle asset that is missing is skipped", "[Preset][Bundle]") +{ + SECTION("a manifest entry with no zip bytes leaves the original path") { + BedRoots bed; + const std::string texture = (bed.src / "gone.png").make_preferred().string(); + write_bytes(texture, kTextureBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", texture, {}); + nlohmann::json structure = bed_manifest("missing-zip"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png"); + const fs::path zip_file = bed.src / "missing.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}}); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == texture); + CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png")); + } + + SECTION("a missing file at export is omitted and the export still succeeds") { + BedRoots bed; + const std::string missing = (bed.src / "missing.png").make_preferred().string(); + const std::string model = (bed.src / "present.stl").make_preferred().string(); + write_bytes(model, kModelBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", missing, model); + const fs::path zip_file = bed.src / "partial.orca_printer"; + const nlohmann::json structure = pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config, + missing, model, "missing-export"); + REQUIRE_FALSE(structure["printer_asset"]["printer/BedPrinter.json"].contains("bed_custom_texture")); + REQUIRE(structure["printer_asset"]["printer/BedPrinter.json"]["bed_custom_model"] == "bed/0/model.stl"); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == missing); + REQUIRE(read_file(saved->config.opt_string("bed_custom_model")) == kModelBytes); + REQUIRE(fs::path(saved->config.opt_string("bed_custom_model")).filename() == "model.stl"); + CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png")); + } +} + +TEST_CASE("An inherited printer bundle rewrites bed paths taken from the parent", "[Preset][Bundle]") +{ + BedRoots bed; + const std::string texture = (bed.src / "parent.png").make_preferred().string(); + const std::string model = (bed.src / "parent.stl").make_preferred().string(); + write_bytes(texture, kTextureBytes); + write_bytes(model, kModelBytes); + DynamicPrintConfig parent(bed.bundle.printers.default_preset().config); + parent.option("printer_settings_id", true)->value = "ParentPrinter"; + parent.option("inherits", true)->value.clear(); + parent.option("bed_custom_texture", true)->value = texture; + parent.option("bed_custom_model", true)->value = model; + bed.bundle.printers.load_preset((bed.src / "ParentPrinter.json").string(), "ParentPrinter", std::move(parent), false); + const fs::path child_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", {}, {}, "ParentPrinter"); + const nlohmann::json written = nlohmann::json::parse(read_file(child_json)); + REQUIRE_FALSE(written.contains("bed_custom_texture")); + REQUIRE_FALSE(written.contains("bed_custom_model")); + nlohmann::json structure = bed_manifest("inherit-bed"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png"); + set_printer_bed_asset(structure, "bed_custom_model", "bed/0/model.stl"); + const fs::path zip_file = bed.src / "inherit.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(child_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}, + {"bed/0/texture.png", kTextureBytes}, {"bed/0/model.stl", kModelBytes}}); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") != texture); + REQUIRE(saved->config.opt_string("bed_custom_model") != model); + REQUIRE(read_file(saved->config.opt_string("bed_custom_texture")) == kTextureBytes); + REQUIRE(read_file(saved->config.opt_string("bed_custom_model")) == kModelBytes); + REQUIRE(fs::path(saved->config.opt_string("bed_custom_texture")).filename() == "texture.png"); + REQUIRE(fs::path(saved->config.opt_string("bed_custom_model")).filename() == "model.stl"); +} + +TEST_CASE("A printer bundle rejects bed assets whose extension the bed dialog does not allow", "[Preset][Bundle]") +{ + BedRoots bed; + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", {}, {}); + nlohmann::json structure = bed_manifest("bad-ext"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.stl"); + set_printer_bed_asset(structure, "bed_custom_model", "bed/0/model.exe"); + const fs::path zip_file = bed.src / "bad-ext.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}, + {"bed/0/texture.stl", kTextureBytes}, {"bed/0/model.exe", std::string("MZ")}}); + REQUIRE(import_bed(bed.bundle, zip_file, bed.data_root) != nullptr); + CHECK_FALSE(any_filename_contains(bed.data_root, "model.exe")); + CHECK_FALSE(any_filename_contains(bed.data_root, "texture.stl")); +} + +TEST_CASE("A manifest asset name that points at a preset does not drop that preset", "[Preset][Bundle]") +{ + BedRoots bed; + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", {}, {}); + nlohmann::json structure = bed_manifest("name-clash"); + set_printer_bed_asset(structure, "bed_custom_texture", "printer/BedPrinter.json"); + const fs::path zip_file = bed.src / "clash.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}}); + REQUIRE(import_bed(bed.bundle, zip_file, bed.data_root) != nullptr); + CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png")); +} + +TEST_CASE("A declined printer bundle import leaves existing bed files alone", "[Preset][Bundle]") +{ + BedRoots bed; + const fs::path kept = bed.data_root / "user" / "default" / "_local" / "keep-bed" / "bed" / "texture.png"; + const std::string original("kept-texture"); + write_bytes(kept.string(), original); + DynamicPrintConfig existing(bed.bundle.printers.default_preset().config); + existing.option("printer_settings_id", true)->value = "BedPrinter"; + existing.option("inherits", true)->value.clear(); + bed.bundle.printers.load_preset((bed.src / "existing.json").string(), "_local/keep-bed/BedPrinter", std::move(existing), + false); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", {}, {}); + nlohmann::json structure = bed_manifest("keep-bed"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png"); + set_printer_bed_asset(structure, "bed_custom_model", "bed/0/model.stl"); + const fs::path zip_file = bed.src / "keep.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}, + {"bed/0/texture.png", kTextureBytes}, {"bed/0/model.stl", kModelBytes}}); + import_bed(bed.bundle, zip_file, bed.data_root, 0); + // This import wrote nothing: the existing texture is unchanged and model.stl was not added. + REQUIRE(fs::exists(kept)); + REQUIRE(read_file(kept) == original); + CHECK_FALSE(fs::exists(kept.parent_path() / "model.stl")); + CHECK_FALSE(fs::exists(kept.parent_path() / "0" / "model.stl")); +} + +TEST_CASE("A declined re-import does not replace bed bytes an earlier import wrote", "[Preset][Bundle]") +{ + BedRoots bed; + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", {}, {}); + nlohmann::json structure = bed_manifest("reimport-no"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png"); + const std::string manifest = structure.dump(); + const std::string printer_bytes = read_file(printer_json); + const fs::path first_zip = bed.src / "first.orca_printer"; + const fs::path second_zip = bed.src / "second.orca_printer"; + write_zip(first_zip, {{"printer/BedPrinter.json", printer_bytes}, {BUNDLE_STRUCTURE_JSON_NAME, manifest}, + {"bed/0/texture.png", kTextureBytes}}); + write_zip(second_zip, {{"printer/BedPrinter.json", printer_bytes}, {BUNDLE_STRUCTURE_JSON_NAME, manifest}, + {"bed/0/texture.png", kOtherBytes}}); + const Preset *saved = import_bed(bed.bundle, first_zip, bed.data_root); + REQUIRE(saved != nullptr); + const std::string installed = saved->config.opt_string("bed_custom_texture"); + REQUIRE(read_file(installed) == kTextureBytes); + import_bed(bed.bundle, second_zip, bed.data_root, 0); + REQUIRE(fs::exists(installed)); + REQUIRE(read_file(installed) == kTextureBytes); +} + +TEST_CASE("Export with bed assets disabled packs no bed files and writes no printer_asset", "[Preset][Bundle]") +{ + BedRoots bed; + const std::string texture = (bed.src / "off.png").make_preferred().string(); + const std::string model = (bed.src / "off.stl").make_preferred().string(); + write_bytes(texture, kTextureBytes); + write_bytes(model, kModelBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", texture, model); + const fs::path zip_file = bed.src / "off.orca_printer"; + const nlohmann::json structure = pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config, + texture, model, "bed-off", false); + REQUIRE_FALSE(structure.contains("printer_asset")); + for (const std::string &name : zip_entry_names(zip_file)) + REQUIRE(name.compare(0, 4, "bed/") != 0); +} + +TEST_CASE("Two printers in one bundle keep separate bed folders", "[Preset][Bundle]") +{ + BedRoots bed; + const std::string tex_a = (bed.src / "a.png").make_preferred().string(); + const std::string mod_a = (bed.src / "a.stl").make_preferred().string(); + const std::string tex_b = (bed.src / "b.png").make_preferred().string(); + const std::string mod_b = (bed.src / "b.stl").make_preferred().string(); + write_bytes(tex_a, kTextureBytes); + write_bytes(mod_a, kModelBytes); + write_bytes(tex_b, kOtherBytes); + write_bytes(mod_b, std::string("stl\x00other", 9)); + const fs::path json_a = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "PrinterA.json", + "PrinterA", tex_a, mod_a); + const fs::path json_b = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "PrinterB.json", + "PrinterB", tex_b, mod_b); + nlohmann::json structure = nlohmann::json::object(); + structure["id"] = "two-printers"; + structure["bundle_type"] = "printer config bundle"; + structure["printer_config"] = nlohmann::json::array({"printer/PrinterA.json", "printer/PrinterB.json"}); + DynamicPrintConfig config_a(bed.bundle.printers.default_preset().config); + config_a.option("bed_custom_texture", true)->value = tex_a; + config_a.option("bed_custom_model", true)->value = mod_a; + DynamicPrintConfig config_b(bed.bundle.printers.default_preset().config); + config_b.option("bed_custom_texture", true)->value = tex_b; + config_b.option("bed_custom_model", true)->value = mod_b; + const fs::path zip_file = bed.src / "two.orca_printer"; + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + const std::string bytes_a = read_file(json_a); + const std::string bytes_b = read_file(json_b); + REQUIRE(open_zip_writer(&zip, zip_file.string())); + REQUIRE(mz_zip_writer_add_mem(&zip, "printer/PrinterA.json", bytes_a.data(), bytes_a.size(), MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_add_mem(&zip, "printer/PrinterB.json", bytes_b.data(), bytes_b.size(), MZ_DEFAULT_COMPRESSION)); + REQUIRE(append_printer_bed_assets(zip, config_a, structure, "printer/PrinterA.json", 0)); + REQUIRE(append_printer_bed_assets(zip, config_b, structure, "printer/PrinterB.json", 1)); + const std::string manifest = structure.dump(); + REQUIRE(mz_zip_writer_add_mem(&zip, BUNDLE_STRUCTURE_JSON_NAME, manifest.data(), manifest.size(), + MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_finalize_archive(&zip)); + REQUIRE(close_zip_writer(&zip)); + REQUIRE(structure["printer_asset"]["printer/PrinterA.json"]["bed_custom_texture"] == "bed/0/texture.png"); + REQUIRE(structure["printer_asset"]["printer/PrinterB.json"]["bed_custom_texture"] == "bed/1/texture.png"); + ScopedDataDir scoped(bed.data_root); + AppConfig app_config; + std::vector files{zip_file.string()}; + bed.bundle.import_presets(files, [](std::string const &) { return 1; }, + ForwardCompatibilitySubstitutionRule::Disable, app_config); + const Preset *saved_a = nullptr; + const Preset *saved_b = nullptr; + for (const Preset &preset : bed.bundle.printers.get_presets()) { + if (preset.file.empty()) + continue; + if (get_preset_bare_name(preset.name) == "PrinterA") + saved_a = &preset; + if (get_preset_bare_name(preset.name) == "PrinterB") + saved_b = &preset; + } + REQUIRE(saved_a != nullptr); + REQUIRE(saved_b != nullptr); + const std::string got_tex_a = saved_a->config.opt_string("bed_custom_texture"); + const std::string got_tex_b = saved_b->config.opt_string("bed_custom_texture"); + REQUIRE(fs::path(got_tex_a).parent_path().filename() == "0"); + REQUIRE(fs::path(got_tex_b).parent_path().filename() == "1"); + REQUIRE(got_tex_a != got_tex_b); + REQUIRE(read_file(got_tex_a) == kTextureBytes); + REQUIRE(read_file(got_tex_b) == kOtherBytes); + REQUIRE(read_file(saved_a->config.opt_string("bed_custom_model")) == kModelBytes); + REQUIRE(read_file(saved_b->config.opt_string("bed_custom_model")) == std::string("stl\x00other", 9)); +} + +TEST_CASE("A printer_asset entry keyed to a printer not in the zip is ignored", "[Preset][Bundle]") +{ + BedRoots bed; + const std::string texture = (bed.src / "kept.png").make_preferred().string(); + write_bytes(texture, kTextureBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", texture, {}); + nlohmann::json structure = bed_manifest("ghost-key"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png", "printer/Ghost.json"); + const fs::path zip_file = bed.src / "ghost.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}, + {"bed/0/texture.png", kOtherBytes}}); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == texture); + CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png")); +} + +TEST_CASE("Keyed printer_asset rejects malformed bed paths", "[Preset][Bundle]") +{ + const char *bad_names[] = {"bed/texture.png", "bed/x/texture.png", "bed/0/../texture.png", "bed/0/model.png"}; + const std::string marker = "PWNED-BED-ASSET-MARKER"; + for (const char *name : bad_names) { + DYNAMIC_SECTION("rejects " << name) { + BedRoots bed; + const std::string texture = (bed.src / "kept.png").make_preferred().string(); + write_bytes(texture, kTextureBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, + bed.src / "BedPrinter.json", "BedPrinter", texture, {}); + nlohmann::json structure = bed_manifest("bad-keyed"); + set_printer_bed_asset(structure, "bed_custom_texture", name); + std::vector> entries = { + {"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}, + {name, marker}}; + const fs::path zip_file = bed.src / "bad-keyed.orca_printer"; + write_zip(zip_file, entries); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == texture); + if (fs::exists(bed.data_root)) + for (fs::recursive_directory_iterator it(bed.data_root), end; it != end; ++it) + if (fs::is_regular_file(*it)) + CHECK(read_file(*it).find(marker) == std::string::npos); + } + } + + SECTION("field name does not match file kind") { + BedRoots bed; + const std::string texture = (bed.src / "kept.png").make_preferred().string(); + write_bytes(texture, kTextureBytes); + const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json", + "BedPrinter", texture, {}); + nlohmann::json structure = bed_manifest("kind-mismatch"); + set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/model.stl"); + const fs::path zip_file = bed.src / "kind.orca_printer"; + write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}, + {"bed/0/model.stl", kModelBytes}}); + const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root); + REQUIRE(saved != nullptr); + REQUIRE(saved->config.opt_string("bed_custom_texture") == texture); + CHECK_FALSE(any_filename_contains(bed.data_root, "model.stl")); + } +} + TEST_CASE("A saved printer preset reloads the tool layout it was saved with", "[Preset][Bundle][IMEX]") { ScopedTemporaryDir temp_dir;