diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index f3a78f894a..3fc3c87424 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -215,6 +215,7 @@ src/libslic3r/GCode.cpp src/libslic3r/GCodeWriter.cpp src/libslic3r/GCode/ToolOrdering.cpp src/libslic3r/GCode/SeamPlacer.cpp +src/libslic3r/GCode/GCodeStatistics.cpp src/libslic3r/ExtrusionEntity.cpp src/libslic3r/Flow.cpp src/libslic3r/Format/AMF.cpp diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 2a158f4b12..ef0e4bbe3a 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -144,6 +144,7 @@ #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Exception.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCode/GCodeStatistics.hpp" #include "libslic3r/GCode/ToolOrdering.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/PresetBundle.hpp" @@ -207,6 +208,7 @@ std::map cli_errors = { {CLI_OBJECT_ORIENT_FAILED, "An error occurred when auto-orienting object(s)."}, {CLI_MODIFIED_PARAMS_TO_PRINTER, "Found modified parameter in printer preset in the 3mf file, which should not be changed."}, {CLI_FILE_VERSION_NOT_SUPPORTED, "Unsupported 3MF version. Please make sure the 3MF file was created with the official version of Bambu Studio, not a beta version."}, + {CLI_EXPORT_STATISTICS_ERROR, "Failed exporting the G-code statistics."}, {CLI_NO_SUITABLE_OBJECTS, "The plate has nothing to slice: it is empty, or no object on it is both printable and fully inside the print volume."}, {CLI_VALIDATE_ERROR, "There are some incorrect slicing parameters in the 3mf. Please verify the slicing of all plates in Orca Slicer before uploading."}, {CLI_OBJECTS_PARTLY_INSIDE, "Some objects are located over the boundary of the heated bed."}, @@ -1539,6 +1541,9 @@ int CLI::run(int argc, char **argv) if (allow_mix_temp_option) allow_mix_temp = allow_mix_temp_option->value; + const ConfigOptionBool* export_statistics_option = m_config.option("export_statistics"); + const bool export_statistics = export_statistics_option != nullptr && export_statistics_option->value; + ConfigOptionBool* avoid_extrusion_cali_region_option = m_config.option("avoid_extrusion_cali_region"); if (avoid_extrusion_cali_region_option) avoid_extrusion_cali_region = avoid_extrusion_cali_region_option->value; @@ -7081,6 +7086,16 @@ int CLI::run(int argc, char **argv) record_exit_reson(outfile_dir, CLI_GCODE_PATH_IN_UNPRINTABLE_AREA, index + 1, cli_errors[CLI_GCODE_PATH_IN_UNPRINTABLE_AREA], sliced_info); flush_and_exit(CLI_GCODE_PATH_IN_UNPRINTABLE_AREA); } + // Without an output directory the G-code is a temporary file, kept only in an exported 3MF. + if (export_statistics && !outfile_dir.empty()) { + try { + export_gcode_statistics(*gcode_result, gcode_statistics_path(outfile)); + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(error) << "plate " << index + 1 << ": " << ex.what(); + record_exit_reson(outfile_dir, CLI_EXPORT_STATISTICS_ERROR, index + 1, cli_errors[CLI_EXPORT_STATISTICS_ERROR], sliced_info); + flush_and_exit(CLI_EXPORT_STATISTICS_ERROR); + } + } //outfile_final = (dynamic_cast(print))->print_statistics().finalize_output_path(outfile); @@ -7209,7 +7224,7 @@ int CLI::run(int argc, char **argv) std::vector thumbnails, no_light_thumbnails, top_thumbnails, pick_thumbnails; std::vector plate_bboxes; PlateDataPtrs plate_data_list; - partplate_list.store_to_3mf_structure(plate_data_list); + partplate_list.store_to_3mf_structure(plate_data_list, true, -1, export_statistics); if (sliced_plate == -1) { for (int i = 0; i < plate_data_list.size(); i++) { diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index afa3b61bc1..fa9aec8531 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -265,6 +265,8 @@ set(lisbslic3r_sources GCode/FanMover.hpp GCode/GCodeProcessor.cpp GCode/GCodeProcessor.hpp + GCode/GCodeStatistics.cpp + GCode/GCodeStatistics.hpp GCode/ElegooGCodeProcessorHelper.cpp GCode.hpp GCode/PchipInterpolatorHelper.cpp diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 54e55fd0a0..aca428f171 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -9053,6 +9053,16 @@ bool _BBS_3MF_Exporter::_add_gcode_file_to_archive(mz_zip_archive& archive, cons BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", store %1% to 3mf %2%\n") % src_gcode_file % gcode_in_3mf; } }); + for (const PlateData *plate_data : plate_data_list2) { + if (plate_data->statistics.empty()) + continue; + const std::string statistics_in_3mf = (boost::format(STATISTICS_FILE_FORMAT) % (plate_data->plate_index + 1)).str(); + if (!mz_zip_writer_add_mem(&archive, statistics_in_3mf.c_str(), plate_data->statistics.data(), plate_data->statistics.size(), + MZ_DEFAULT_COMPRESSION)) { + add_error("Unable to add statistics file to archive"); + result = false; + } + } return result; } diff --git a/src/libslic3r/Format/bbs_3mf.hpp b/src/libslic3r/Format/bbs_3mf.hpp index 489870b8fc..611cc26e03 100644 --- a/src/libslic3r/Format/bbs_3mf.hpp +++ b/src/libslic3r/Format/bbs_3mf.hpp @@ -32,6 +32,7 @@ struct ThumbnailData; #define PLATE_THUMBNAIL_SMALL_HEIGHT 128 #define GCODE_FILE_FORMAT "Metadata/plate_%1%.gcode" +#define STATISTICS_FILE_FORMAT "Metadata/plate_%1%.statistics.json" #define THUMBNAIL_FILE_FORMAT "Metadata/plate_%1%.png" #define NO_LIGHT_THUMBNAIL_FILE_FORMAT "Metadata/plate_no_light_%1%.png" #define TOP_FILE_FORMAT "Metadata/top_%1%.png" @@ -100,6 +101,8 @@ struct PlateData std::string nozzle_volume_types; std::string gcode_file; std::string gcode_file_md5; + // The G-code's statistics as JSON, stored with the G-code when not empty. + std::string statistics; std::string thumbnail_file; std::string no_light_thumbnail_file; ThumbnailData plate_thumbnail; @@ -174,6 +177,8 @@ enum class SaveStrategy // Keep this separate from SplitModel, which uses the 0x1000 bit as part of its // production-extension value. MinimalPublished = 1 << 13, + // With WithGcode, also the G-code's statistics. + WithStatistics = 1 << 14, SplitModel = 0x1000 | ProductionExt, Encrypted = SecureContentExt | SplitModel, diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index dd17cc4fdd..74d0e38496 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2625,8 +2625,15 @@ static void set_mass_locator(GCodeProcessor &processor, const Print &print) } Object &o = objects.emplace_back(Object{ object, int(object_masses.size()), count, bodies_total, {}, std::move(bodies), {}, {} }); object_masses.resize(object_masses.size() + object->instances().size()); - for (size_t instance = 0; instance < object->instances().size(); ++instance) - object_masses[o.first_instance + instance].assembly = assembly; + const ModelInstancePtrs &model_instances = object->model_object()->instances; + for (size_t instance = 0; instance < object->instances().size(); ++instance) { + GCodeProcessorResult::ObjectMass &mass = object_masses[o.first_instance + instance]; + mass.assembly = assembly; + mass.name = object->model_object()->name; + // Copies of another rotation or scale slice as objects of their own, so number them in the model object. + mass.instance = int(std::find(model_instances.begin(), model_instances.end(), object->instances()[instance].model_instance) - + model_instances.begin()); + } bodies_total += int(count * object->instances().size()); for (const Layer *layer : layers) { o.print_zs.emplace_back(layer->print_z); diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 4fcdf5589b..87c08617d1 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -298,6 +298,9 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; BoundingBoxf3 box; // Of an object, whether it is an assembly. bool assembly{ false }; + // Of an object instance, its model object's name and its index among that object's instances. + std::string name; + int instance{ -1 }; Sum total() const { return printed_up_to_layer.empty() ? Sum{} : printed_up_to_layer.back(); } void add(const Sum &sum, const BoundingBoxf3 &extent, size_t layer); diff --git a/src/libslic3r/GCode/GCodeStatistics.cpp b/src/libslic3r/GCode/GCodeStatistics.cpp new file mode 100644 index 0000000000..2320b8b7e5 --- /dev/null +++ b/src/libslic3r/GCode/GCodeStatistics.cpp @@ -0,0 +1,200 @@ +#include "GCodeStatistics.hpp" + +#include "GCodeProcessor.hpp" +#include "libslic3r/Exception.hpp" +#include "libslic3r/ExtrusionEntity.hpp" +#include "libslic3r/I18N.hpp" +#include "libslic3r/format.hpp" +#include "libslic3r/libslic3r.h" +#include "libslic3r_version.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +namespace { + +using json = nlohmann::ordered_json; + +double rounded(double value, int decimals) +{ + const double scale = std::pow(10., decimals); + return std::round(value * scale) / scale; +} + +// As the object masses weigh it, at the default density for a filament without one. +double weight_of(const GCodeProcessorResult &result, size_t filament, double volume) +{ + const bool has_density = filament < result.filament_densities.size() && result.filament_densities[filament] > 0.f; + // mm³ at g/cm³ weigh mg. + return 0.001 * volume * (has_density ? result.filament_densities[filament] : DEFAULT_FILAMENT_DENSITY); +} + +struct Usage +{ + double time{ 0. }; + double volume{ 0. }; + double weight{ 0. }; + + void add(const Usage &other) + { + time += other.time; + volume += other.volume; + weight += other.weight; + } + + json to_json() const { return json{ { "time", rounded(time, 3) }, { "volume", rounded(volume, 3) }, { "weight", rounded(weight, 4) } }; } +}; + +json features_json(const std::map &features) +{ + json out = json::object(); + for (const auto &[role, usage] : features) + out[ExtrusionEntity::role_to_string(role)] = usage.to_json(); + return out; +} + +} // namespace + +std::string gcode_statistics_json(const GCodeProcessorResult &result) +{ + constexpr size_t normal = static_cast(PrintEstimatedStatistics::ETimeMode::Normal); + + struct Layer + { + float z{ 0.f }; + double travel_time{ 0. }; + Usage total; + std::map features; + }; + std::vector layers; + std::map features; + double travel_time = 0.; + + // As the preview sums them: a layer takes the time of all its moves, a feature that of its extrusions. + for (const GCodeProcessorResult::MoveVertex &move : result.moves) { + if (layers.size() <= move.layer_id) + layers.resize(move.layer_id + 1); + Layer &layer = layers[move.layer_id]; + const double time = move.time[normal]; + layer.total.time += time; + if (move.type == EMoveType::Travel) { + layer.travel_time += time; + travel_time += time; + } + // A vertex added inside a move to show its acceleration repeats the move's extrusion. + if (move.type != EMoveType::Extrude || move.internal_only) + continue; + if (move.extrusion_role != erCustom) + layer.z = move.position.z(); + Usage usage; + usage.time = time; + usage.volume = double(move.delta_extruder) * M_PI * sqr(0.5 * double(result.filament_diameters[move.extruder_id])); + usage.weight = weight_of(result, move.extruder_id, usage.volume); + layer.total.volume += usage.volume; + layer.total.weight += usage.weight; + layer.features[move.extrusion_role].add(usage); + features[move.extrusion_role].add(usage); + } + + // As the preview's filament table, which counts the flush too. + const PrintEstimatedStatistics &stats = result.print_statistics; + double volume = 0., weight = 0., cost = 0.; + json filaments_json = json::array(); + for (const auto &entry : stats.total_volumes_per_extruder) { + const size_t filament = entry.first; + const auto use = [&result, filament](const std::map &volumes) { + const auto it = volumes.find(filament); + const double used = it == volumes.end() ? 0. : it->second; + return json{ { "volume", rounded(used, 3) }, { "weight", rounded(weight_of(result, filament, used), 4) } }; + }; + const double filament_weight = weight_of(result, filament, entry.second); + // Priced per kilogram. + const double filament_cost = filament < result.filament_costs.size() ? 0.001 * filament_weight * result.filament_costs[filament] : 0.; + volume += entry.second; + weight += filament_weight; + cost += filament_cost; + filaments_json.push_back(json{ { "id", filament + 1 }, + { "length", rounded(entry.second / (M_PI * sqr(0.5 * double(result.filament_diameters[filament]))), 2) }, + { "volume", rounded(entry.second, 3) }, + { "weight", rounded(filament_weight, 4) }, + { "cost", rounded(filament_cost, 2) }, + { "model", use(stats.model_volumes_per_extruder) }, + { "support", use(stats.support_volumes_per_extruder) }, + { "prime_tower", use(stats.wipe_tower_volumes_per_extruder) }, + { "flush", use(stats.flush_per_filament) } }); + } + + json summary = json{ { "time", rounded(stats.modes[normal].time, 3) }, + { "prepare_time", rounded(stats.modes[normal].prepare_time, 3) }, + { "travel_time", rounded(travel_time, 3) }, + { "travel_distance", rounded(stats.total_travel_distance, 2) }, + { "layer_count", layers.size() }, + { "volume", rounded(volume, 3) }, + { "weight", rounded(weight, 4) }, + { "filament_cost", rounded(cost, 2) }, + { "filament_changes", stats.total_filament_changes }, + { "filaments", filaments_json }, + { "features", features_json(features) } }; + + json layers_json = json::array(); + for (const Layer &layer : layers) + layers_json.push_back(json{ { "z", rounded(layer.z, 3) }, + { "time", rounded(layer.total.time, 3) }, + { "travel_time", rounded(layer.travel_time, 3) }, + { "volume", rounded(layer.total.volume, 3) }, + { "weight", rounded(layer.total.weight, 4) }, + { "features", features_json(layer.features) } }); + + // The supports of an instance are numbered as the instance. + json objects_json = json::array(); + for (size_t i = 0; i < result.object_masses.size(); ++i) { + const GCodeProcessorResult::ObjectMass &object = result.object_masses[i]; + const GCodeProcessorResult::ObjectMass::Sum part = object.total(); + const GCodeProcessorResult::ObjectMass::Sum support = i < result.support_masses.size() ? result.support_masses[i].total() : + GCodeProcessorResult::ObjectMass::Sum{}; + // In mg, as volumes in mm³ at g/cm³. + objects_json.push_back(json{ { "name", object.name }, + { "instance", object.instance }, + { "volume", rounded(part.volume, 3) }, + { "weight", rounded(0.001 * part.mass, 4) }, + { "support_volume", rounded(support.volume, 3) }, + { "support_weight", rounded(0.001 * support.mass, 4) } }); + } + + // The version goes up only when a key is removed, renamed or changes meaning. + const json out = json{ { "version", 1 }, + { "generator", std::string(SLIC3R_APP_NAME) + " " + SoftFever_VERSION }, + { "units", json{ { "time", "s" }, { "length", "mm" }, { "volume", "mm3" }, { "weight", "g" } } }, + { "summary", summary }, + { "layers", layers_json }, + { "objects", objects_json } }; + return out.dump(2); +} + +std::string gcode_statistics_path(const std::string &gcode_path) +{ + return boost::filesystem::path(gcode_path).replace_extension(".statistics.json").string(); +} + +void export_gcode_statistics(const GCodeProcessorResult &result, const std::string &path) +{ + // Binary, so the file reads the same on every platform and inside a 3MF. + boost::nowide::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc); + file << gcode_statistics_json(result); + file.close(); + if (file.fail()) + throw ExportError(format(_u8L("Failed to write the G-code statistics to %1%."), path)); +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/GCodeStatistics.hpp b/src/libslic3r/GCode/GCodeStatistics.hpp new file mode 100644 index 0000000000..f969511984 --- /dev/null +++ b/src/libslic3r/GCode/GCodeStatistics.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace Slic3r { + +struct GCodeProcessorResult; + +// The statistics of a plate's G-code as JSON, in seconds, millimeters and grams: totals, per layer and per object instance. +std::string gcode_statistics_json(const GCodeProcessorResult &result); +// Where the statistics of the G-code at gcode_path are written. +std::string gcode_statistics_path(const std::string &gcode_path); +// Throws ExportError when the file cannot be written. +void export_gcode_statistics(const GCodeProcessorResult &result, const std::string &path); + +} // namespace Slic3r diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index fae5d77d36..bab1d1b586 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -13040,6 +13040,13 @@ CLIMiscConfigDef::CLIMiscConfigDef() def->cli_params = "dir"; def->set_default_value(new ConfigOptionString()); + def = this->add("export_statistics", coBool); + def->label = L("Export statistics"); + def->tooltip = L("Write the statistics of each sliced plate as JSON, as plate_N.statistics.json next to its G-code in the output " + "directory and inside the 3MF of --export-3mf: print time, filament and feature totals, and the same per layer " + "and per object."); + def->set_default_value(new ConfigOptionBool(false)); + def = this->add("debug", coInt); def->label = L("Debug level"); def->tooltip = L("Sets debug logging level. 0:fatal, 1:error, 2:warning, 3:info, 4:debug, 5:trace\n"); diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index d9d30573fc..eb171e4ee0 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -66,6 +66,7 @@ #define CLI_OBJECT_ORIENT_FAILED -22 #define CLI_MODIFIED_PARAMS_TO_PRINTER -23 #define CLI_FILE_VERSION_NOT_SUPPORTED -24 +#define CLI_EXPORT_STATISTICS_ERROR -25 #define CLI_NO_SUITABLE_OBJECTS -50 diff --git a/src/slic3r/GUI/BackgroundSlicingProcess.cpp b/src/slic3r/GUI/BackgroundSlicingProcess.cpp index c5a1737b79..5030196348 100644 --- a/src/slic3r/GUI/BackgroundSlicingProcess.cpp +++ b/src/slic3r/GUI/BackgroundSlicingProcess.cpp @@ -6,6 +6,7 @@ #include #include +#include "libslic3r/AppConfig.hpp" #include "libslic3r/Config.hpp" #include #include @@ -57,6 +58,7 @@ #include "libslic3r_version.h" #include "slic3r/GUI/Plater.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCode/GCodeStatistics.hpp" #include "libslic3r/PresetBundle.hpp" #include "slic3r/GUI/PartPlate.hpp" #include "slic3r/Utils/PrintHost.hpp" @@ -781,6 +783,7 @@ void BackgroundSlicingProcess::schedule_export(const std::string& path, bool exp this->invalidate_step(bspsGCodeFinalize); m_export_path = path; m_export_path_on_removable_media = export_path_on_removable_media; + m_export_statistics = GUI::wxGetApp().app_config->get_bool("export_statistics"); } void BackgroundSlicingProcess::schedule_upload(Slic3r::PrintHostJob upload_job) @@ -906,6 +909,8 @@ void BackgroundSlicingProcess::finalize_gcode() BOOST_LOG_TRIVIAL(error) << "Unexpected fail code(" << (int) copy_ret_val << ") durring copy_file() to " << export_path << "."; break; } + if (m_export_statistics) + export_gcode_statistics(*m_gcode_result, gcode_statistics_path(export_path)); m_print->set_status(100, GUI::format(_L("G-code file exported to %1%"), export_path)); } @@ -953,6 +958,8 @@ void BackgroundSlicingProcess::export_gcode() // throw Slic3r::ExportError(_utf8(L("Unknown error when exporting G-code."))); break; } + if (m_export_statistics) + export_gcode_statistics(*m_gcode_result, gcode_statistics_path(export_path)); // BBS auto evt = new wxCommandEvent(m_event_export_finished_id, GUI::wxGetApp().mainframe->m_plater->GetId()); diff --git a/src/slic3r/GUI/BackgroundSlicingProcess.hpp b/src/slic3r/GUI/BackgroundSlicingProcess.hpp index cebeb52e0f..231bee811e 100644 --- a/src/slic3r/GUI/BackgroundSlicingProcess.hpp +++ b/src/slic3r/GUI/BackgroundSlicingProcess.hpp @@ -258,6 +258,8 @@ private: // but once set, it cannot be re-set. std::string m_export_path; bool m_export_path_on_removable_media = false; + // Read from the preferences when the export is scheduled, as the background thread may not read them. + bool m_export_statistics = false; // Print host upload job to schedule after slicing is complete, used by schedule_upload(), // empty by default (ie. no upload to schedule) PrintHostJob m_upload_job; diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 882ede309c..35d0da7525 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -19,6 +19,7 @@ #include "libslic3r/GCode/WipeTower.hpp" #include "libslic3r/CustomGCode.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCode/GCodeStatistics.hpp" #include #include #include "libslic3r/Arrange.hpp" @@ -7539,7 +7540,7 @@ int PartPlateList::rebuild_plates_after_arrangement(bool recycle_plates, bool ex return ret; } -int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info, int plate_idx) +int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info, int plate_idx, bool with_statistics) { int ret = 0; @@ -7613,6 +7614,8 @@ int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool w } //parse filament info plate_data_item->parse_filament_info(m_plate_list[i]->get_slice_result()); + if (with_statistics) + plate_data_item->statistics = gcode_statistics_json(*m_plate_list[i]->get_slice_result()); // Record mixed (virtual) filaments actually used on this plate. // Source is ToolOrdering::used_mixed_filaments (slots that appeared in diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 92fc8377c7..ba6fb74a29 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -1070,7 +1070,7 @@ public: /* load/store releted functions, with_gcode = true and plate_idx = -1, export all gcode * if with_gcode = true and specify plate_idx, export plate_idx gcode only */ - int store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info = true, int plate_idx = -1); + int store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info = true, int plate_idx = -1, bool with_statistics = false); int load_from_3mf_structure(PlateDataPtrs& plate_data_list, int filament_count = 1); //load gcode files int load_gcode_files(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index cf9dddeee7..c91ba60808 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -19564,7 +19564,10 @@ void Plater::export_gcode_3mf(bool export_all) int plate_idx = get_partplate_list().get_curr_plate_index(); if (export_all) plate_idx = PLATE_ALL_IDX; - export_3mf(output_path, SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::WithGcode | SaveStrategy::SkipModel, plate_idx); // BBS: silence + SaveStrategy strategy = SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::WithGcode | SaveStrategy::SkipModel; // BBS: silence + if (wxGetApp().app_config->get_bool("export_statistics")) + strategy = strategy | SaveStrategy::WithStatistics; + export_3mf(output_path, strategy, plate_idx); RemovableDriveManager& removable_drive_manager = *wxGetApp().removable_drive_manager(); @@ -20418,7 +20421,8 @@ int Plater::export_3mf(const boost::filesystem::path& output_path, SaveStrategy //BBS: add bbs 3mf logic PlateDataPtrs plate_data_list; - p->partplate_list.store_to_3mf_structure(plate_data_list, (strategy & SaveStrategy::WithGcode || strategy & SaveStrategy::WithSliceInfo), export_plate_idx); + p->partplate_list.store_to_3mf_structure(plate_data_list, (strategy & SaveStrategy::WithGcode || strategy & SaveStrategy::WithSliceInfo), export_plate_idx, + strategy & SaveStrategy::WithStatistics); // BBS: backup PresetBundle& preset_bundle = *wxGetApp().preset_bundle; diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index cca6bebe0b..dc726ca6f6 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -2312,6 +2312,13 @@ void PreferencesDialog::create_items() auto item_show_unsupported = create_item_checkbox(_L("Show unsupported presets"), _L("Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected."), "show_unsupported_presets"); g_sizer->Add(item_show_unsupported); + auto item_export_statistics = create_item_checkbox(_L("Export statistics with G-code"), + _L("If enabled, exporting a G-code file also writes its statistics as JSON next to it, as .statistics.json, " + "and an exported sliced 3MF file holds them inside: print time, filament and feature totals, " + "and the same per layer and per object."), + "export_statistics"); + g_sizer->Add(item_export_statistics); + //// DEVELOPER > Experimental Features g_sizer->Add(create_item_title(_L("Experimental Features")), 1, wxEXPAND); diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index da15226ff4..a8e0f1575d 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(${_TEST_NAME}_tests test_fill.cpp test_flow.cpp test_gcode_processor.cpp + test_gcode_statistics.cpp test_gcode_timing.cpp test_gcode.cpp test_gcodeprocessor.cpp diff --git a/tests/fff_print/test_gcode_statistics.cpp b/tests/fff_print/test_gcode_statistics.cpp new file mode 100644 index 0000000000..20682aa463 --- /dev/null +++ b/tests/fff_print/test_gcode_statistics.cpp @@ -0,0 +1,124 @@ +#include +#include +#include + +#include "libslic3r/Config.hpp" +#include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCode/GCodeStatistics.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Print.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Utils.hpp" + +#include "test_helpers.hpp" +#include "test_utils.hpp" + +#include + +#include +#include +#include +#include +#include + +using namespace Slic3r; + +TEST_CASE("The statistics of a sliced plate add up per layer, feature and object instance", "[GCodeStatistics]") +{ + // A 20 mm cube at 0.2 mm layers prints 100 of them; without skirt or brim all its filament goes to its instances. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "brim_type", "no_brim" }, + { "filament_density", 1.25 } }); + Print print; + Model model; + Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2); + GCodeProcessorResult result; + Test::gcode(print, &result); + + ScopedTemporaryFile file(".json"); + export_gcode_statistics(result, file.string()); + std::string text; + load_string_file(file.path(), text); + const nlohmann::json statistics = nlohmann::json::parse(text); + + const nlohmann::json &summary = statistics["summary"]; + const nlohmann::json &layers = statistics["layers"]; + REQUIRE(layers.size() == 100); + CHECK(summary["layer_count"] == 100); + CHECK_THAT(layers.front()["z"].get(), Catch::Matchers::WithinAbs(0.2, 1e-3)); + CHECK_THAT(layers.back()["z"].get(), Catch::Matchers::WithinAbs(20., 1e-3)); + + double time = 0., volume = 0.; + for (const nlohmann::json &layer : layers) { + time += layer["time"].get(); + volume += layer["volume"].get(); + double features_time = 0.; + for (const auto &[name, feature] : layer["features"].items()) + features_time += feature["time"].get(); + CHECK(features_time <= layer["time"].get() + 1e-3); + } + // Each value is rounded to the millisecond and the cubic millimeter. + CHECK_THAT(time, Catch::Matchers::WithinAbs(summary["time"].get(), 0.1)); + CHECK_THAT(volume, Catch::Matchers::WithinAbs(summary["volume"].get(), 0.1)); + CHECK_THAT(summary["weight"].get(), Catch::Matchers::WithinRel(summary["volume"].get() * 1.25 / 1000., 1e-4)); + CHECK(summary.contains("filament_cost")); + + // The filament splits by use as the preview's table does. + REQUIRE(summary["filaments"].size() == 1); + const nlohmann::json &filament = summary["filaments"].front(); + double used = 0.; + for (const char *use : { "model", "support", "prime_tower", "flush" }) + used += filament[use]["volume"].get(); + CHECK_THAT(used, Catch::Matchers::WithinAbs(filament["volume"].get(), 0.01)); + + const nlohmann::json &features = summary["features"]; + CHECK(features.contains("Outer wall")); + CHECK(features.contains("Sparse infill")); + double model_volume = 0.; + for (const auto &[name, feature] : features.items()) + if (name != "Custom") + model_volume += feature["volume"].get(); + + const nlohmann::json &objects = statistics["objects"]; + REQUIRE(objects.size() == 2); + double objects_volume = 0.; + for (size_t i = 0; i < objects.size(); ++i) { + CHECK(objects[i]["name"] == model.objects.front()->name); + CHECK(objects[i]["instance"] == int(i)); + CHECK(objects[i]["support_volume"].get() == 0.); + objects_volume += objects[i]["volume"].get(); + } + CHECK_THAT(objects_volume, Catch::Matchers::WithinAbs(model_volume, 0.1)); +} + +TEST_CASE("Copies sliced as objects of their own keep their number in the model object", "[GCodeStatistics]") +{ + // A copy of another scale slices as a print object of its own. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" } }); + Print print; + Model model; + Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2); + model.objects.front()->instances.back()->set_scaling_factor(Vec3d(0.5, 0.5, 0.5)); + model.objects.front()->ensure_on_bed(); + print.apply(model, config); + REQUIRE(print.objects().size() == 2); + GCodeProcessorResult result; + Test::gcode(print, &result); + + const nlohmann::json objects = nlohmann::json::parse(gcode_statistics_json(result))["objects"]; + std::set instances; + for (const nlohmann::json &object : objects) + instances.insert(object["instance"].get()); + CHECK(instances == std::set{ 0, 1 }); +} + +TEST_CASE("The statistics file takes the G-code's name with the statistics extension", "[GCodeStatistics]") +{ + CHECK(gcode_statistics_path("plate_1.gcode") == "plate_1.statistics.json"); + CHECK(gcode_statistics_path("out/Cube_PLA_1h.gcode") == "out/Cube_PLA_1h.statistics.json"); +} diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 3d59c08d24..f7b5049ebe 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -410,6 +410,44 @@ static bool load_project(const std::string& path, Model& model, const ScopedTemp return loaded; } +TEST_CASE("A plate's G-code statistics are stored next to its G-code", "[3mf]") +{ + const bool with_statistics = GENERATE(false, true); + ScopedTemporaryFile gcode(".gcode"); + save_string_file(gcode.path(), "G1 X10 Y10 E1\n"); + + Model model; + ModelObject* object = model.add_object(); + object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + ScopedTemporaryDir backup_dir("orca_statistics_src"); + model.set_backup_path(backup_dir.string()); + + // A sliced plate's info reads the printer settings. + DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config(); + PlateData plate; + plate.plate_index = 0; + plate.gcode_file = gcode.string(); + plate.is_sliced_valid = true; + if (with_statistics) + plate.statistics = R"({ "version": 1 })"; + ScopedTemporaryFile file(".3mf"); + const std::string path = file.string(); + StoreParams sp; + sp.path = path.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::WithGcode; + sp.plate_data_list.push_back(&plate); + REQUIRE(store_bbs_3mf(sp)); + + std::string stored; + REQUIRE(read_cad_recipe_entry(path, stored, "Metadata/plate_1.gcode")); + CHECK(read_cad_recipe_entry(path, stored, "Metadata/plate_1.statistics.json") == with_statistics); + if (with_statistics) + CHECK(stored == plate.statistics); +} + TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]") { const int plate_id = GENERATE(0, -1);