Compare commits

...
Author SHA1 Message Date
Hanif Koh 3071d525ee Expose App, GL, Printer and Project Facts to Plugins
Plugins that help users file bug reports could only guess these from log lines: the app config is
deny-listed, and the project path, a project export and the device list had no API.

orca.host gains app_info(), gl_info() and selected_printer(), and Plater gains project_path() and
export_3mf_copy(path). The copy export leaves the project's file name, saved state and model
unchanged, does not write the signed-in account as the designer, and raises the audit event of
open(path, "w") so the plugin gets the same permissions as for writing the file itself.
2026-10-10 03:35:39 +08:00
6 changed files with 198 additions and 13 deletions
+2
View File
@@ -396,6 +396,8 @@ public:
Slic3r::TaskManager* getTaskManager() { return m_task_manager; }
HMSQuery* get_hms_query() { return hms_query; }
NetworkAgent* getAgent() { return m_agent; }
// Version that wrote the app config before this run; empty when there was no app config.
const boost::optional<Semver>& last_config_version() const { return m_last_config_version; }
// Reconcile the live printer agent with the stored preset selection.
void switch_printer_agent();
+3
View File
@@ -47,6 +47,9 @@ public:
const std::string& get_vendor() const;
const std::string& get_renderer() const;
// False until a GL context has been made current and queried; the getters below detect
// on first use, which needs a current context.
bool is_detected() const { return m_detected; }
bool is_core_profile() const { return m_core_profile; }
bool is_mesa() const;
+51 -11
View File
@@ -19318,6 +19318,38 @@ void Plater::send_gcode_finish(wxString name)
auto out_str = GUI::format(_L("The file %s has been sent to the printer's storage space and can be viewed on the printer."), name);
p->notification_manager->push_exporting_finished_notification(out_str, "", false);
}
namespace {
// export_3mf() assigns archive paths to previously unsaved SVGs. Puts them back, so an export that
// is not a project save does not change what a later project save writes.
class SvgArchivePathsRestorer
{
public:
explicit SvgArchivePathsRestorer(Model& model)
{
for (ModelObject* object : model.objects)
for (ModelVolume* volume : object->volumes)
if (volume != nullptr && volume->emboss_shape.has_value() && volume->emboss_shape->svg_file.has_value()) {
std::string* path_in_3mf = &volume->emboss_shape->svg_file->path_in_3mf;
m_paths.emplace_back(path_in_3mf, *path_in_3mf);
}
}
~SvgArchivePathsRestorer() { restore(); }
SvgArchivePathsRestorer(const SvgArchivePathsRestorer&) = delete;
SvgArchivePathsRestorer& operator=(const SvgArchivePathsRestorer&) = delete;
void restore()
{
for (const auto& [path_in_3mf, previous_path] : m_paths)
*path_in_3mf = previous_path;
}
private:
std::vector<std::pair<std::string*, std::string>> m_paths;
};
} // namespace
void Plater::export_core_3mf()
{
wxString path = p->get_export_file(FT_3MF);
@@ -19326,6 +19358,23 @@ void Plater::export_core_3mf()
export_3mf(path_u8, SaveStrategy::Silence);
}
bool Plater::export_3mf_copy(const boost::filesystem::path& output_path)
{
Model& model = p->model;
SvgArchivePathsRestorer svg_paths(model);
// With no design info, export_3mf() writes the signed-in account's user id as the designer; an
// empty one keeps it out. export_3mf() also drops design info without a designer name afterwards.
const std::shared_ptr<ModelDesignInfo> design_info = model.design_info;
if (design_info == nullptr)
model.design_info = std::make_shared<ModelDesignInfo>();
ScopeGuard restore_design_info([&model, design_info]() { model.design_info = design_info; });
// The project save's layout, plus Silence so the project file name stays as it is. Unlike a save
// it never adds FullPathSources: a copy is for sharing, and those are paths on this machine.
return export_3mf(output_path, SaveStrategy::SplitModel | SaveStrategy::ShareMesh | SaveStrategy::Silence) == 0;
}
// Export the current project as a "published" 3MF: a pure export that never touches the
// project's file name, dirty state, backup path or title, and attaches the published metadata
// to the model only for the duration of the export (a later Save Project is a normal 3MF).
@@ -19375,19 +19424,10 @@ int Plater::export_published_3mf(const std::vector<std::string>& published_keys,
std::string();
const std::string prev_payload = had_payload ? model.model_info->metadata_items.at(ORCA_PUBLISHED_CONFIG_TAG) : std::string();
// export_3mf() assigns archive paths to previously unsaved SVGs. Preserve those fields too,
// otherwise a publish changes what a later normal project save writes.
std::vector<std::pair<std::string*, std::string>> previous_svg_paths;
for (ModelObject* object : model.objects)
for (ModelVolume* volume : object->volumes)
if (volume != nullptr && volume->emboss_shape.has_value() && volume->emboss_shape->svg_file.has_value()) {
std::string* path_in_3mf = &volume->emboss_shape->svg_file->path_in_3mf;
previous_svg_paths.emplace_back(path_in_3mf, *path_in_3mf);
}
SvgArchivePathsRestorer svg_paths(model);
auto restore_temporary_state = [&]() {
for (const auto& [path_in_3mf, previous_path] : previous_svg_paths)
*path_in_3mf = previous_path;
svg_paths.restore();
if (!had_model_info) {
model.model_info = nullptr;
+3
View File
@@ -565,6 +565,9 @@ public:
void export_gcode_3mf(bool export_all = false);
void send_gcode_finish(wxString name);
void export_core_3mf();
// Write the project to output_path as a copy: the project's file name, dirty state and model stay
// as they are, and the signed-in account is not added as the designer. False when the write fails.
bool export_3mf_copy(const boost::filesystem::path& output_path);
// Export a "published" 3MF embedding the author-selected settings in the file metadata; a
// pure export that leaves the in-memory project untouched.
int export_published_3mf(const std::vector<std::string>& published_keys, const std::vector<Slic3r::PublishedMaterialEntry>& material_keys);
+127 -1
View File
@@ -1,16 +1,36 @@
#include "PluginHostBindings.hpp"
#include <pybind11/cast.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/AppConfig.hpp>
#include <libslic3r/Semver.hpp>
#include <libslic3r_version.h>
#include <slic3r/GUI/BuildCommit.hpp>
#include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp>
#include <slic3r/GUI/GUI.hpp>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/OpenGLManager.hpp>
#include <slic3r/GUI/Plater.hpp>
#include <slic3r/Utils/CloudProvider.hpp>
#include <slic3r/Utils/NetworkAgent.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/Utils/bambu_networking.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/optional/optional.hpp>
#include <memory>
#include <stdexcept>
#include <wx/app.h>
#include <wx/string.h>
#include <wx/thread.h>
#include <string>
#include <utility>
namespace py = pybind11;
@@ -41,6 +61,94 @@ PresetBundle* current_preset_bundle()
return preset_bundle;
}
GUI::GUI_App& current_app()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
return GUI::wxGetApp();
}
// The plater and the device list are only safe to touch from the UI thread, and the 3MF export
// renders thumbnails, which needs the UI thread's GL context.
void require_main_thread(const char* function_name)
{
if (!wxIsMainThread())
throw std::runtime_error(std::string(function_name) + "() must be called from the UI thread");
}
void export_3mf_copy(GUI::Plater& plater, const std::string& path)
{
require_main_thread("export_3mf_copy");
if (!boost::iends_with(path, ".3mf"))
throw py::value_error("export_3mf_copy() needs a path ending in .3mf");
// The thumbnails are rendered by the 3D view, which may not exist yet while plugins load.
if (!GUI::OpenGLManager::get_gl_info().is_detected())
throw std::runtime_error("export_3mf_copy() needs the 3D view, which is not ready yet");
// The file is written from C++, so raise the audit event Python's open(path, "w") raises: the
// plugin gets the same deny list, permissions and prompt as for writing the file itself.
if (PySys_Audit("open", "ssi", path.c_str(), "w", 0) < 0)
throw py::error_already_set();
if (!plater.export_3mf_copy(GUI::into_path(GUI::from_u8(path))))
throw std::runtime_error("Failed to write " + path);
}
py::object gl_info()
{
const GUI::OpenGLManager::GLInfo& info = GUI::OpenGLManager::get_gl_info();
if (!info.is_detected())
return py::none();
py::dict out;
out["vendor"] = info.get_vendor();
out["renderer"] = info.get_renderer();
out["version"] = info.get_version();
out["glsl_version"] = info.get_glsl_version();
out["core_profile"] = info.is_core_profile();
return std::move(out);
}
py::dict app_info()
{
GUI::GUI_App& app = current_app();
// The app config has no lock of its own; the UI thread is where it is written.
require_main_thread("app_info");
py::dict out;
out["version"] = SoftFever_VERSION;
out["build"] = GUI::build_commit_label;
out["mode"] = app.is_editor() ? "editor" : "gcode viewer";
out["language"] = GUI::into_u8(app.current_language_code_safe());
// The app config file is deny-listed for plugins; these are the parts a bug report needs.
const boost::optional<Semver>& config_version = app.last_config_version();
out["app_config_version"] = config_version && config_version->valid() ? config_version->to_string_sf() : std::string();
out["stealth_mode"] = app.app_config != nullptr && app.app_config->get_stealth_mode();
out["is_signed_in"] = app.is_user_login(ORCA_CLOUD_PROVIDER);
out["is_bambu_signed_in"] = app.is_user_login(BBL_CLOUD_PROVIDER);
out["network_plugin_version"] = NetworkAgent::is_network_module_loaded() ? NetworkAgent::get_version() : std::string();
const NetworkLibraryLoadError load_error = NetworkAgent::get_load_error();
out["network_plugin_error"] = load_error.has_error ? load_error.message : std::string();
return out;
}
py::object selected_printer()
{
GUI::GUI_App& app = current_app();
require_main_thread("selected_printer");
DeviceManager* devices = app.getDeviceManager();
MachineObject* machine = devices != nullptr ? devices->get_selected_machine() : nullptr;
if (machine == nullptr)
return py::none();
// Agent, model, link and firmware only: no name, serial, address or access code. Other agents
// fill the model and firmware with placeholders, so those come from Bambu printers only.
const bool bambu = machine->printer_agent_id == BBL_PRINTER_AGENT_ID;
py::dict out;
out["agent"] = machine->printer_agent_id;
out["model_id"] = bambu ? machine->printer_type : std::string();
out["connection"] = machine->connection_type();
out["online"] = machine->is_online();
out["connected"] = machine->is_connected();
out["firmware"] = bambu ? machine->get_ota_version() : std::string();
return std::move(out);
}
} // namespace
// Access to the live GUI application: the Plater and the module-level
@@ -52,7 +160,15 @@ void host_bindings::register_app(py::module_& host)
.def("model", static_cast<Model& (GUI::Plater::*)()>(&GUI::Plater::model), py::return_value_policy::reference_internal)
.def("is_project_dirty", &GUI::Plater::is_project_dirty)
.def("is_presets_dirty", &GUI::Plater::is_presets_dirty)
.def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture);
.def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture)
// Path the project was last opened from or saved to; empty while it has never been saved.
.def("project_path", [](GUI::Plater& plater) {
require_main_thread("project_path");
return GUI::into_u8(plater.get_project_filename(".3mf"));
})
.def("export_3mf_copy", &export_3mf_copy, py::arg("path"),
"Write the current project, unsaved changes included, to path as a 3MF copy. The project's "
"file name and saved state are not changed, and the signed-in account is not written as the designer.");
host.def("plater", &current_plater, py::return_value_policy::reference);
host.def("model", []() -> Model& {
@@ -68,6 +184,16 @@ void host_bindings::register_app(py::module_& host)
throw std::runtime_error("OrcaSlicer application is not initialized");
return GUI::into_u8(GUI::wxGetApp().current_language_code_safe());
});
host.def("app_info", &app_info,
"The running app: version, build, mode, language, app_config_version (the version that wrote the app config "
"before this run, empty when there was none), stealth_mode, is_signed_in (Orca Cloud), is_bambu_signed_in (Bambu Cloud), network_plugin_version (empty "
"when the Bambu network plugin is not loaded) and network_plugin_error (empty unless it failed to load).");
host.def("gl_info", &gl_info,
"OpenGL vendor, renderer, version, glsl_version and core_profile of the 3D view; None before it has been created.");
host.def("selected_printer", &selected_printer,
"Agent, model_id, connection ('lan' or 'cloud'), online, connected and firmware of the printer selected in the "
"device list; model_id and firmware are empty for non-Bambu agents. None when none is selected. Print hosts "
"configured in the printer preset (OctoPrint and the like) are not in the device list.");
}
} // namespace Slic3r
+12 -1
View File
@@ -133,7 +133,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
{
py::object host = import_orca_module().attr("host");
for (const char* function_name : { "preset_bundle", "plater", "model" }) {
for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) {
CAPTURE(function_name);
try {
host.attr(function_name)();
@@ -145,6 +145,17 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
}
}
TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
CHECK(host.attr("gl_info")().is_none());
py::object plater_type = host.attr("Plater");
CHECK(has_attr(plater_type, "project_path"));
CHECK(has_attr(plater_type, "export_3mf_copy"));
}
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");