From dfd3444ae7d713b0f1ebfdbb241fbeeb4b7b0d84 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 19 Aug 2026 19:20:31 +0800 Subject: [PATCH 001/124] feat: initial draft of lifecycle events API for plugins --- src/libslic3r/GCode.cpp | 49 ++++++- src/libslic3r/LifecycleEvents.hpp | 135 ++++++++++++++++++++ src/libslic3r/Preset.cpp | 10 ++ src/libslic3r/Print.cpp | 38 +++++- src/slic3r/GUI/DeviceCore/DevManager.cpp | 27 +++- src/slic3r/GUI/DeviceManager.cpp | 28 +++- src/slic3r/GUI/GLCanvas3D.cpp | 31 ++++- src/slic3r/GUI/GUI_App.cpp | 22 ++++ src/slic3r/GUI/GUI_ObjectList.cpp | 15 +++ src/slic3r/GUI/Jobs/ArrangeJob.cpp | 8 ++ src/slic3r/GUI/Jobs/FillBedJob.cpp | 8 ++ src/slic3r/GUI/Jobs/OrientJob.cpp | 7 + src/slic3r/GUI/Plater.cpp | 58 +++++++++ src/slic3r/GUI/Tab.cpp | 15 +++ src/slic3r/Utils/PrintHost.cpp | 18 +++ src/slic3r/plugin/PluginHooks.cpp | 13 ++ src/slic3r/plugin/PluginManager.cpp | 21 +++ src/slic3r/plugin/PluginManager.hpp | 4 +- src/slic3r/plugin/PyPluginTrampoline.hpp | 6 + src/slic3r/plugin/PythonPluginBridge.cpp | 41 ++++++ src/slic3r/plugin/PythonPluginInterface.hpp | 4 + 21 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 src/libslic3r/LifecycleEvents.hpp diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 18d805936e..6f5f2ad8e4 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -7,6 +7,7 @@ #include "I18N.hpp" #include "GCode.hpp" #include "Exception.hpp" +#include "LifecycleEvents.hpp" #include "ExtrusionEntity.hpp" #include "EdgeGrid.hpp" #include "Geometry/ConvexHull.hpp" @@ -2446,6 +2447,13 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu m_writer.set_is_bbl_machine(print->is_BBL_printer()); print->set_started(psGCodeExport); + { + LifecycleEventContext ctx; + ctx.name = path; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::GCodeExportStarted, ctx); + } + // check if any custom gcode contains keywords used by the gcode processor to // produce time estimation and gcode toolpaths std::vector> validation_res = DoExport::validate_custom_gcode(*print); @@ -2481,12 +2489,22 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu m_processor.set_print(print); GCodeOutputStream file(boost::nowide::fopen(path_tmp.c_str(), "wb"), m_processor); if (! file.is_open()) { - BOOST_LOG_TRIVIAL(error) << std::string("G-code export to ") + path + " failed.\nCannot open the file for writing.\n" << std::endl; + std::string err_msg = std::string("G-code export to ") + path + " failed.\nCannot open the file for writing.\n"; + BOOST_LOG_TRIVIAL(error) << err_msg << std::endl; if (!fs::exists(folder)) { //fs::create_directory(folder); - BOOST_LOG_TRIVIAL(error) << "the parent path " + folder.string() +" is not there!!!" << std::endl; + std::string add_err_msg = "the parent path " + folder.string() +" is not there!!!"; + BOOST_LOG_TRIVIAL(error) << add_err_msg << std::endl; + err_msg += add_err_msg; } - throw Slic3r::RuntimeError(std::string("G-code export to ") + path + " failed.\nCannot open the file for writing.\n"); + { + LifecycleEventContext ctx; + ctx.name = path; + ctx.code = LifecycleEvtCode::Error; + ctx.msg = err_msg; + fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); + } + throw Slic3r::RuntimeError(err_msg); } try { @@ -2497,11 +2515,18 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu boost::nowide::remove(path_tmp.c_str()); throw Slic3r::RuntimeError(std::string("G-code export to ") + path + " failed\nIs the disk full?\n"); } - } catch (std::exception & /* ex */) { + } catch (std::exception &ex) { // Rethrow on any exception. std::runtime_exception and CanceledException are expected to be thrown. // Close and remove the file. file.close(); boost::nowide::remove(path_tmp.c_str()); + { + LifecycleEventContext ctx; + ctx.name = path; + ctx.code = LifecycleEvtCode::Error; + ctx.msg = ex.what(); + fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); + } throw; } file.close(); @@ -2607,6 +2632,13 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu std::error_code ret = rename_file(path_tmp, path); if (ret) { + { + LifecycleEventContext ctx; + ctx.name = path; + ctx.code = LifecycleEvtCode::Error; + ctx.msg = "Failed to rename the output G-code file: " + ret.message(); + fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); + } throw Slic3r::RuntimeError( std::string("Failed to rename the output G-code file from ") + path_tmp + " to " + path + '\n' + "error code " + ret.message() + '\n' + "Is " + path_tmp + " locked?" + '\n'); @@ -2617,7 +2649,14 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu BOOST_LOG_TRIVIAL(info) << "Exporting G-code finished" << log_memory_info(); print->set_done(psGCodeExport); - + + { + LifecycleEventContext ctx; + ctx.name = path; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); + } + // Orca: label_object_enabled reflects whether objects are labeled in the g-code (EXCLUDE_OBJECT / // M486), which is driven by exclude_object for every printer if(result != nullptr) diff --git a/src/libslic3r/LifecycleEvents.hpp b/src/libslic3r/LifecycleEvents.hpp new file mode 100644 index 0000000000..1528e97b46 --- /dev/null +++ b/src/libslic3r/LifecycleEvents.hpp @@ -0,0 +1,135 @@ +#pragma once + +// LifecycleEvents.hpp +// -------------------- +// Application lifecycle events (project, slicing, plate editing, preset, printer connection +// activity) that other subsystems -- chiefly the plugin layer above libslic3r -- may want to +// observe. Lives in libslic3r rather than the plugin layer because some events fire from inside +// the slicing engine itself; see fire_lifecycle_event() below. + +#include +#include +#include + +namespace Slic3r +{ + enum class LifecycleEvent { + // Project (3mf) + NewProject, + ProjectOpened, + ProjectBeforeSave, + ProjectAfterSave, + ProjectClosed, + + // Slicing pipeline + SliceStarted, + SliceGeometryFinished, + GCodeExportStarted, + GCodeExportFinished, + SlicingJobComplete, + + // Plate/model editing + ObjectAdded, + ObjectDeleted, + ObjectTransformed, + + // Preset + PresetSelected, + PresetSaved, + + // Printer/device + PrintStateChanged, + DeviceOnlineChanged, + DeviceDiscovered, + DeviceSelected, + DeviceConnected, + DeviceDisconnected, + UploadStarted, + UploadFinished, + }; + + // Scoped so callers must qualify (LifecycleEvtCode::Error, not ERROR) -- ERROR/OK collide with + // Windows macros (wingdi.h) as unqualified names. + enum class LifecycleEvtCode { Ok, Error, Warn }; + + struct LifecycleEventContext + { + // The primary subject identifier for the event. This is event-specific (for example, a + // project/output path, preset name, device id, or object name), must not contain status or + // prose, and may be empty when the event has no single subject. + std::string name; + + // Outcome of the operation represented by the event. For state-change and start events, + // Ok means that the event occurred; it does not imply that a future operation succeeded. + LifecycleEvtCode code = LifecycleEvtCode::Ok; + + // Optional human-readable detail or diagnostic text. It is not a stable parsing contract; + // machine-readable data should be represented by a dedicated field or event instead. + std::string msg; + }; + + inline std::string lifecycle_event_to_string(LifecycleEvent event) + { + switch (event) { + case LifecycleEvent::NewProject: return "NewProject"; + case LifecycleEvent::ProjectOpened: return "ProjectOpened"; + case LifecycleEvent::ProjectBeforeSave: return "ProjectBeforeSave"; + case LifecycleEvent::ProjectAfterSave: return "ProjectAfterSave"; + case LifecycleEvent::ProjectClosed: return "ProjectClosed"; + + case LifecycleEvent::SliceStarted: return "SliceStarted"; + case LifecycleEvent::SliceGeometryFinished: return "SliceGeometryFinished"; + case LifecycleEvent::GCodeExportStarted: return "GCodeExportStarted"; + case LifecycleEvent::GCodeExportFinished: return "GCodeExportFinished"; + case LifecycleEvent::SlicingJobComplete: return "SlicingJobComplete"; + + case LifecycleEvent::ObjectAdded: return "ObjectAdded"; + case LifecycleEvent::ObjectDeleted: return "ObjectDeleted"; + case LifecycleEvent::ObjectTransformed: return "ObjectTransformed"; + + case LifecycleEvent::PresetSelected: return "PresetSelected"; + case LifecycleEvent::PresetSaved: return "PresetSaved"; + case LifecycleEvent::PrintStateChanged: return "PrintStateChanged"; + + case LifecycleEvent::DeviceOnlineChanged: return "DeviceOnlineChanged"; + case LifecycleEvent::DeviceDiscovered: return "DeviceDiscovered"; + case LifecycleEvent::DeviceSelected: return "DeviceSelected"; + case LifecycleEvent::DeviceConnected: return "DeviceConnected"; + case LifecycleEvent::DeviceDisconnected: return "DeviceDisconnected"; + + case LifecycleEvent::UploadStarted: return "UploadStarted"; + case LifecycleEvent::UploadFinished: return "UploadFinished"; + default: return "Unknown"; + } + } + + inline std::string lifecycle_evt_code_to_string(LifecycleEvtCode code) + { + switch (code) { + case LifecycleEvtCode::Ok: return "Ok"; + case LifecycleEvtCode::Error: return "Error"; + case LifecycleEvtCode::Warn: return "Warn"; + default: return "Unknown"; + } + } + + // Global cross-layer seam (mirrors ConfigBase::set_resolve_capability_fn): any libslic3r code can + // fire a lifecycle event without depending on the plugin layer above it, which installs the + // dispatcher here at startup. Not tied to Print/GCode specifically, since nothing here should + // require callers to hold a Print& just to report an event. + using LifecycleHookFn = std::function; + + inline LifecycleHookFn& lifecycle_hook_fn() + { + static LifecycleHookFn fn; + return fn; + } + + inline void set_lifecycle_hook_fn(LifecycleHookFn fn) { lifecycle_hook_fn() = std::move(fn); } + + inline void fire_lifecycle_event(LifecycleEvent event, const LifecycleEventContext& ctx) + { + if (const LifecycleHookFn& fn = lifecycle_hook_fn(); fn) + fn(event, ctx); + } +} diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 9ae8b86fd8..22a0c4f6a7 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -45,6 +45,7 @@ #include #include "libslic3r.h" +#include "LifecycleEvents.hpp" #include "Utils.hpp" #include "Time.hpp" #include "PlaceholderParser.hpp" @@ -2931,6 +2932,7 @@ void PresetCollection::save_current_preset(const std::string &new_name, bool det // 1) Find the preset with a new_name or create a new one, // initialize it with the edited config. auto it = this->find_preset_internal(new_name); + const bool preset_existed = (it != m_presets.end() && it->name == new_name); if (it != m_presets.end() && it->name == new_name) { // Preset with the same name found. Preset &preset = *it; @@ -3038,6 +3040,14 @@ void PresetCollection::save_current_preset(const std::string &new_name, bool det this->get_selected_preset().save(&(parent_preset->config)); else this->get_selected_preset().save(nullptr); + + { + LifecycleEventContext ctx; + ctx.name = new_name; + ctx.msg = preset_existed ? "overwrite" : "new"; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::PresetSaved, ctx); + } } bool PresetCollection::delete_current_preset() diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 1af28255ee..68bff0cd8e 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -8,6 +8,7 @@ #include "Flow.hpp" #include "Geometry/ConvexHull.hpp" #include "I18N.hpp" +#include "LifecycleEvents.hpp" #include "ShortestPath.hpp" #include "Thread.hpp" #include "Time.hpp" @@ -2252,6 +2253,13 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (m_objects.empty()) return; + { + LifecycleEventContext ctx; + ctx.name = output_filename(); + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::SliceStarted, ctx); + } + for (PrintObject *obj : m_objects) obj->clear_shared_object(); @@ -2818,6 +2826,13 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } BOOST_LOG_TRIVIAL(info) << "Slicing process finished." << log_memory_info(); + + { + LifecycleEventContext ctx; + ctx.code = LifecycleEvtCode::Ok; + ctx.name = output_filename(); + fire_lifecycle_event(LifecycleEvent::SliceGeometryFinished, ctx); + } } // G-code export process, running at a background thread. @@ -4434,6 +4449,13 @@ void Print::set_gcode_file_invalidated() //BBS: add gcode file preload logic void Print::export_gcode_from_previous_file(const std::string& file, GCodeProcessorResult* result, ThumbnailsGeneratorCallback thumbnail_cb) { + { + LifecycleEventContext ctx; + ctx.name = file; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::GCodeExportStarted, ctx); + } + try { GCodeProcessor processor; GCodeProcessor::s_IsBBLPrinter = is_BBL_printer(); @@ -4453,13 +4475,27 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces *result = std::move(processor.extract_result()); result->filament_change_sequence = filament_seq_loaded; result->nozzle_change_sequence = nozzle_seq_loaded; - } catch (std::exception & /* ex */) { + } catch (std::exception &ex) { BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": found errors when process gcode file %1%") %file.c_str(); + { + LifecycleEventContext ctx; + ctx.name = file; + ctx.code = LifecycleEvtCode::Error; + ctx.msg = ex.what(); + fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); + } throw Slic3r::RuntimeError( std::string("Failed to process the G-code file ") + file + " from previous 3mf\n"); } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": process the G-code file %1% successfully")%file.c_str(); + + { + LifecycleEventContext ctx; + ctx.name = file; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); + } } std::tuple Print::object_skirt_offset(double margin_height) const diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index edc958ec53..e3a785f430 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -10,6 +10,7 @@ #include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "libslic3r/Time.hpp" @@ -328,7 +329,10 @@ namespace Slic3r obj->bind_state = "free"; obj->last_alive = Slic3r::Utils::get_current_time_utc(); - obj->m_is_online = true; + // Route through set_online_state() (rather than writing m_is_online directly) so the + // DeviceOnlineChanged lifecycle event fires consistently; same effective value/behavior + // here since the object was already online in the common case. + obj->set_online_state(true); obj->set_dev_name(dev_name); /* if (!obj->dev_ip.empty()) { Slic3r::GUI::wxGetApp().app_config->set_str("ip_address", obj->dev_id, obj->dev_ip); @@ -345,6 +349,10 @@ namespace Slic3r obj->bind_sec_link = sec_link; obj->dev_connection_name = connection_name; obj->bind_ssdp_version = ssdp_version; + // Discovery establishes the initial reachability state. Do not report it as an + // online transition; DeviceDiscovered below is the lifecycle event for a new + // device. Subsequent updates route through set_online_state(), so a known device + // still emits DeviceOnlineChanged when its reachability actually changes. obj->m_is_online = true; //load access code @@ -361,6 +369,15 @@ namespace Slic3r BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " New Machine, dev_id= " << dev_id << ", ip = " << dev_ip <<", printer_name = " << dev_name << ", con_type= " << connect_type <<", signal= " << printer_signal << ", bind_state= " << bind_state; + + // First discovery of a genuinely new device (not a periodic SSDP/heartbeat update to + // an already-known one, which is handled in the branch above). + { + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceDiscovered, ctx); + } } update_local_machine(*obj); } @@ -935,8 +952,14 @@ namespace Slic3r } void DeviceManager::OnSelectedMachineChanged(const std::string& /*pre_dev_id*/, - const std::string& /*new_dev_id*/) + const std::string& new_dev_id) { + { + LifecycleEventContext ctx; + ctx.name = new_dev_id; // empty string is a valid deselection + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceSelected, ctx); + } if (MachineObject* obj_ = get_selected_machine()) { GUI::wxGetApp().sidebar().update_sync_status(obj_); if(m_agent->get_filament_sync_mode() == FilamentSyncMode::subscription) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index f4befe78c1..ba818aefd6 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -3,6 +3,7 @@ #include "libslic3r/Time.hpp" #include "libslic3r/Thread.hpp" #include "slic3r/Utils/NetworkAgent.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "GuiColor.hpp" #include "GUI_App.hpp" @@ -2591,7 +2592,15 @@ void MachineObject::reset() void MachineObject::set_print_state(std::string status) { + const bool changed = (print_status != status); print_status = status; + if (changed) { + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + ctx.msg = print_status; + fire_lifecycle_event(LifecycleEvent::PrintStateChanged, ctx); + } } int MachineObject::connect(bool use_openssl) @@ -2613,6 +2622,10 @@ int MachineObject::connect(bool use_openssl) int MachineObject::disconnect() { if (m_agent) { + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceDisconnected, ctx); return m_agent->disconnect_printer(); } return -1; @@ -2643,8 +2656,16 @@ bool MachineObject::is_connecting() void MachineObject::set_online_state(bool on_off) { + const bool changed = (m_is_online != on_off); m_is_online = on_off; if (!on_off) m_active_state = NotActive; + if (changed) { + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + ctx.msg = on_off ? "online" : "offline"; + fire_lifecycle_event(LifecycleEvent::DeviceOnlineChanged, ctx); + } } bool MachineObject::is_info_ready(bool check_version) const @@ -4566,8 +4587,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ try { if (j.contains("event")) { if (j["event"].contains("event")) { - if (j["event"]["event"].get() == "client.disconnected") + if (j["event"]["event"].get() == "client.disconnected") { set_online_state(false); + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceDisconnected, ctx); + } else if (j["event"]["event"].get() == "client.connected") set_online_state(true); } diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index f6ffb75405..469ab8120e 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -39,6 +39,7 @@ #include "slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp" #include "slic3r/Utils/UndoRedo.hpp" #include "slic3r/Utils/MacDarkMode.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include @@ -5061,8 +5062,16 @@ void GLCanvas3D::do_move(const std::string& snapshot_type) //BBS: nofity object list to update wxGetApp().plater()->sidebar().obj_list()->update_plate_values_for_items(); - if (object_moved) + if (object_moved) { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + ctx.msg = "moved"; + if (done.size() == 1) + ctx.name = m_model->objects[done.begin()->first]->name; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectTransformed, ctx); + post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_MOVED)); + } // BBS: support wipe-tower for multi-plates for (int plate_id = 0; plate_id < wipe_tower_origins.size(); plate_id++) { @@ -5183,8 +5192,16 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type) //BBS: nofity object list to update wxGetApp().plater()->sidebar().obj_list()->update_plate_values_for_items(); - if (!done.empty()) + if (!done.empty()) { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + ctx.msg = "rotated"; + if (done.size() == 1) + ctx.name = m_model->objects[done.begin()->first]->name; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectTransformed, ctx); + post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_ROTATED)); + } m_dirty = true; } @@ -5275,8 +5292,16 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type) //BBS: notify object info update wxGetApp().plater()->show_object_info(); - if (!done.empty()) + if (!done.empty()) { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + ctx.msg = "scaled"; + if (done.size() == 1) + ctx.name = m_model->objects[done.begin()->first]->name; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectTransformed, ctx); + post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_SCALED)); + } m_dirty = true; } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 0d4e3f35cf..83b2b2303d 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -2169,6 +2169,11 @@ void GUI_App::init_networking_callbacks() obj->command_get_access_code(); if (m_agent) m_agent->install_device_cert(obj->get_dev_id(), obj->is_lan_mode_printer()); + + LifecycleEventContext ctx; + ctx.name = obj->get_dev_id(); + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceConnected, ctx); } }); }); @@ -2207,6 +2212,11 @@ void GUI_App::init_networking_callbacks() obj->command_get_version(); event.SetInt(0); event.SetString(obj->get_dev_id()); + + LifecycleEventContext ctx; + ctx.name = obj->get_dev_id(); + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceConnected, ctx); } else if (state == ConnectStatus::ConnectStatusFailed) { // Orca: only update status if same device id if (m_device_manager->selected_machine != dev_id) return; @@ -2222,10 +2232,22 @@ void GUI_App::init_networking_callbacks() wxGetApp().show_dialog(text); } event.SetInt(-1); + + { + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceDisconnected, ctx); + } } else if (state == ConnectStatus::ConnectStatusLost) { m_device_manager->set_selected_machine(""); event.SetInt(-1); BOOST_LOG_TRIVIAL(info) << "set_on_local_connect_fn: state = lost"; + + LifecycleEventContext ctx; + ctx.name = dev_id; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::DeviceDisconnected, ctx); } else { event.SetInt(-1); BOOST_LOG_TRIVIAL(info) << "set_on_local_connect_fn: state = " << state; diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index dc89f5f9bb..e786b7241b 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -9,6 +9,7 @@ #include "BitmapComboBox.hpp" #include "MainFrame.hpp" #include "slic3r/Utils/UndoRedo.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "OptionsGroup.hpp" #include "Tab.hpp" @@ -3530,7 +3531,14 @@ void ObjectList::delete_all_connectors_for_object(int obj_idx) obj->delete_connectors(); if (obj->volumes.empty() || !obj->has_solid_mesh()) { + const std::string deleted_obj_name = obj->name; model.delete_object(idx); + { + Slic3r::LifecycleEventContext ctx; + ctx.name = deleted_obj_name; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectDeleted, ctx); + } m_objects_model->Delete(m_objects_model->GetItemById(idx)); continue; } @@ -4099,6 +4107,13 @@ void ObjectList::add_object_to_list(size_t obj_idx, bool call_selection_changed, const auto item = m_objects_model->AddObject(model_object, warning_bitmap, model_object->is_cut()); Expand(m_objects_model->GetParent(item)); + { + Slic3r::LifecycleEventContext ctx; + ctx.name = model_object->name; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectAdded, ctx); + } + if (!do_info_update) return; diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 6b74b71af1..71a5e58344 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -13,6 +13,7 @@ #include "slic3r/GUI/NotificationManager.hpp" #include "slic3r/GUI/format.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "libnest2d/common.hpp" @@ -699,6 +700,13 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) { BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(":arrange m_unprintable: name: %4%, bed_id %1%, trans {%2%,%3%}") % ap.bed_idx % unscale(ap.translation(X)) % unscale(ap.translation(Y)) % ap.name; } + { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + ctx.msg = "arranged"; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectTransformed, ctx); + } + m_plater->update(); // BBS //wxGetApp().obj_manipul()->set_dirty(); diff --git a/src/slic3r/GUI/Jobs/FillBedJob.cpp b/src/slic3r/GUI/Jobs/FillBedJob.cpp index a9a66cb96f..502282c307 100644 --- a/src/slic3r/GUI/Jobs/FillBedJob.cpp +++ b/src/slic3r/GUI/Jobs/FillBedJob.cpp @@ -6,6 +6,7 @@ #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "libnest2d/common.hpp" #include @@ -347,6 +348,13 @@ void FillBedJob::finalize(bool canceled, std::exception_ptr &eptr) m_plater->arrange(); } m_plater->update(); + + { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + ctx.msg = "arranged"; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectTransformed, ctx); + } } m_plater->mark_plate_toolbar_image_dirty(); diff --git a/src/slic3r/GUI/Jobs/OrientJob.cpp b/src/slic3r/GUI/Jobs/OrientJob.cpp index ee8ea875c0..ed9117e8bf 100644 --- a/src/slic3r/GUI/Jobs/OrientJob.cpp +++ b/src/slic3r/GUI/Jobs/OrientJob.cpp @@ -5,6 +5,7 @@ #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/NotificationManager.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "libslic3r/PresetBundle.hpp" @@ -254,6 +255,12 @@ void OrientJob::finalize(bool canceled, std::exception_ptr &eptr) mesh.apply(); } + if (!m_selected.empty()) { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + ctx.msg = "auto_oriented"; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectTransformed, ctx); + } m_plater->update(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 2225812ff6..197ece0823 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -8375,7 +8375,14 @@ void Plater::priv::remove(size_t obj_idx) view3D->enable_layers_editing(false); m_worker.cancel_all(); + std::string obj_name = (obj_idx < model.objects.size()) ? model.objects[obj_idx]->name : std::to_string(obj_idx); model.delete_object(obj_idx); + { + Slic3r::LifecycleEventContext ctx; + ctx.name = obj_name; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectDeleted, ctx); + } //BBS: notify partplate the instance removed partplate_list.notify_instance_removed(obj_idx, -1); update(); @@ -8408,7 +8415,14 @@ bool Plater::priv::delete_object_from_model(size_t obj_idx, bool refresh_immedia if (obj->is_cut()) sidebar->obj_list()->invalidate_cut_info_for_object(obj_idx); + std::string obj_name = obj->name; model.delete_object(obj_idx); + { + Slic3r::LifecycleEventContext ctx; + ctx.name = obj_name; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ObjectDeleted, ctx); + } //BBS: notify partplate the instance removed partplate_list.notify_instance_removed(obj_idx, -1); @@ -8458,6 +8472,14 @@ void Plater::priv::reset(bool apply_presets_change) clear_warnings(); + const std::string closed_project_name = into_u8(get_project_filename()); + if (!closed_project_name.empty() || !model.objects.empty()) { + Slic3r::LifecycleEventContext ctx; + ctx.name = closed_project_name; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ProjectClosed, ctx); + } + set_project_filename(""); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " call set_project_filename: empty"; @@ -10868,11 +10890,15 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt) notification_manager->set_slicing_progress_export_possible(); // Reset the "export G-code path" name, so that the automatic background processing will be enabled again. + const std::string lifecycle_job_name = this->background_process.fff_print() ? + this->background_process.fff_print()->output_filename() : std::string(); this->background_process.reset_export(); // This bool stops showing export finished notification even when process_completed_with_error is false bool has_error = false; + std::string lifecycle_error_msg; if (evt.error()) { auto message = evt.format_error_message(); + lifecycle_error_msg = message.first; if (evt.critical_error()) { if (q->m_tracking_popup_menu) { // We don't want to pop-up a message box when tracking a pop-up menu. @@ -10910,6 +10936,14 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt) is_finished = true; } + { + Slic3r::LifecycleEventContext ctx; + ctx.name = lifecycle_job_name; + ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn : (has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok); + ctx.msg = evt.cancelled() ? "cancelled" : (has_error ? lifecycle_error_msg : std::string()); + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::SlicingJobComplete, ctx); + } + //BBS: set the current plater's slice result to valid if (!this->background_process.empty()) this->background_process.get_current_plate()->update_slice_result_valid_state(evt.success()); @@ -13068,6 +13102,11 @@ int Plater::new_project(bool skip_confirm, bool silent, const wxString& project_ //get_partplate_list().reinit(); //get_partplate_list().update_slice_context_to_current_plate(p->background_process); //p->preview->update_gcode_result(p->partplate_list.get_current_slice_result()); + if (!silent) { + Slic3r::LifecycleEventContext ctx; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::NewProject, ctx); + } reset(transfer_preset_changes); reset_project_dirty_after_save(); reset_project_dirty_initial_presets(); @@ -13199,6 +13238,13 @@ void Plater::load_project(wxString const& filename2, p->set_project_name(_L("Untitled")); } + { + Slic3r::LifecycleEventContext ctx; + ctx.name = into_u8(load_restore ? originfile : filename); + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ProjectOpened, ctx); + } + } else { if (using_exported_file()) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " using ecported set project filename: " << filename; @@ -13261,11 +13307,23 @@ int Plater::save_project(bool saveAs) if (full_pathnames) { save_strategy = save_strategy | SaveStrategy::FullPathSources; } + { + Slic3r::LifecycleEventContext ctx; + ctx.name = into_u8(filename); + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ProjectBeforeSave, ctx); + } if (export_3mf(into_path(filename), save_strategy) < 0) { MessageDialog(this, _L("Failed to save the project.\nPlease check whether the folder exists online or if other programs have the project file open."), _L("Save project"), wxOK | wxICON_WARNING).ShowModal(); return wxID_CANCEL; } + { + Slic3r::LifecycleEventContext ctx; + ctx.name = into_u8(filename); + ctx.code = Slic3r::LifecycleEvtCode::Ok; + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::ProjectAfterSave, ctx); + } Slic3r::remove_backup(model(), false); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 1a31355d0e..9d6e2299c0 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -36,6 +36,7 @@ #include "slic3r/Utils/NetworkAgentFactory.hpp" #include "slic3r/Utils/PresetUpdater.hpp" #include "slic3r/plugin/PluginConfig.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "Plater.hpp" #include "MainFrame.hpp" #include "format.hpp" @@ -6900,6 +6901,20 @@ bool Tab::select_preset( } load_current_preset(); + { + Slic3r::LifecycleEventContext ctx; + ctx.name = preset_name; + ctx.code = Slic3r::LifecycleEvtCode::Ok; + switch (m_type) { + case Preset::TYPE_PRINT: ctx.msg = "print"; break; + case Preset::TYPE_SLA_PRINT: ctx.msg = "sla_print"; break; + case Preset::TYPE_FILAMENT: ctx.msg = "filament"; break; + case Preset::TYPE_SLA_MATERIAL: ctx.msg = "sla_material"; break; + case Preset::TYPE_PRINTER: ctx.msg = "printer"; break; + default: break; + } + Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::PresetSelected, ctx); + } if (delete_third_printer) { wxGetApp().CallAfter([filament_presets, process_presets]() { diff --git a/src/slic3r/Utils/PrintHost.cpp b/src/slic3r/Utils/PrintHost.cpp index 7f69d5e087..0952019b10 100644 --- a/src/slic3r/Utils/PrintHost.cpp +++ b/src/slic3r/Utils/PrintHost.cpp @@ -23,6 +23,7 @@ #include "CrealityPrint.hpp" #include "../GUI/PrintHostDialogs.hpp" #include "../GUI/MainFrame.hpp" +#include "slic3r/plugin/PluginManager.hpp" #include "Obico.hpp" #include "Flashforge.hpp" #include "SimplyPrint.hpp" @@ -358,12 +359,29 @@ void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job) { emit_progress(0); // Indicate the upload is starting + // Captured before upload_data is moved into upload() below. + const std::string upload_filename = the_job.upload_data.source_path.filename().string(); + + { + LifecycleEventContext ctx; + ctx.name = upload_filename; + ctx.code = LifecycleEvtCode::Ok; + fire_lifecycle_event(LifecycleEvent::UploadStarted, ctx); + } + bool success = the_job.printhost->upload(std::move(the_job.upload_data), [this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); }, [this](wxString error) { this->error_fn(std::move(error)); }, [this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); } ); + { + LifecycleEventContext ctx; + ctx.name = upload_filename; + ctx.code = success ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error; + fire_lifecycle_event(LifecycleEvent::UploadFinished, ctx); + } + if (success) { emit_progress(100); if (the_job.switch_to_device_tab) { diff --git a/src/slic3r/plugin/PluginHooks.cpp b/src/slic3r/plugin/PluginHooks.cpp index c6aaaf803c..2d84df5fdb 100644 --- a/src/slic3r/plugin/PluginHooks.cpp +++ b/src/slic3r/plugin/PluginHooks.cpp @@ -7,6 +7,7 @@ #include "libslic3r/Config.hpp" #include "libslic3r/Exception.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/Print.hpp" #include "libslic3r_version.h" @@ -46,6 +47,16 @@ void install_capability_resolver() }); } +// Global libslic3r-side seam (Slic3r::fire_lifecycle_event, in libslic3r/LifecycleEvents.hpp): +// broadcasts to every loaded, enabled capability regardless of type, unlike the SlicingPipeline +// hook below which only targets picker-selected SlicingPipeline capabilities. +void install_lifecycle_event_hook() +{ + set_lifecycle_hook_fn([](LifecycleEvent event, const LifecycleEventContext& ctx) { + PluginManager::instance().dispatch_lifecycle_event(event, ctx); + }); +} + // Print::process() fires this hook at each pipeline seam on the slicing worker // thread; here we run the picker-selected SlicingPipeline capabilities. Per // capability we acquire the GIL, honor cancellation, and convert a plugin @@ -122,12 +133,14 @@ void install() { install_capability_resolver(); install_slicing_pipeline_hook(); + install_lifecycle_event_hook(); } void uninstall() { ConfigBase::set_resolve_capability_fn(nullptr); Print::set_slicing_pipeline_hook_fn(nullptr); + set_lifecycle_hook_fn(nullptr); } } // namespace Slic3r::plugin_hooks diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 3587c0a824..3675f0e466 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -1,5 +1,6 @@ #include "PluginManager.hpp" +#include #include #include #include @@ -1987,4 +1988,24 @@ ExecutionResult PluginManager::run_script_capability(const std::string& plugin_k return result; } +void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) { + for (const auto& cap : get_plugin_capabilities()) { + if (!cap || !cap->is_enabled()) continue; + try { + cap->on_lifecycle_event(evt, ctx); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": plugin '" << cap->audit_plugin_key() << "/" << cap->name() + << "' on_lifecycle_event(" << lifecycle_event_to_string(evt) << ") threw: " << ex.what() + << " [ctx name='" << ctx.name << "', code=" << lifecycle_evt_code_to_string(ctx.code) + << ", msg='" << ctx.msg << "']"; + } catch (...) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": plugin '" << cap->audit_plugin_key() << "/" << cap->name() + << "' on_lifecycle_event(" << lifecycle_event_to_string(evt) + << ") threw a non-standard exception" + << " [ctx name='" << ctx.name << "', code=" << lifecycle_evt_code_to_string(ctx.code) + << ", msg='" << ctx.msg << "']"; + } + } +} + } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index ddb0bf9dce..3d6be9981a 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -20,7 +21,6 @@ #include #include "CloudPluginService.hpp" -#include "PluginFsUtils.hpp" #include "PluginDescriptor.hpp" #include "PluginLoader.hpp" #include "PluginConfig.hpp" @@ -204,6 +204,8 @@ public: ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error); + void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx); + private: PluginManager() = default; PluginManager(const PluginManager&) = delete; diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index e36c0b375c..a36fd8100a 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -129,6 +129,12 @@ public: { ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_cancelled); } + + void on_lifecycle_event(LifecycleEvent event, const LifecycleEventContext& ctx) override + { + ORCA_PY_OVERRIDE_AUDITED( + ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_lifecycle_event, event, ctx); + } }; class PyPluginInterfaceTrampoline : public PyPluginCommonTrampoline diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 328ebbace1..264b72a24c 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -338,6 +338,44 @@ void bind_python_api(pybind11::module_& m) .value("FatalError", PluginResult::FatalError) .export_values(); + py::enum_(m, "LifecycleEvent", "Application lifecycle moment passed to on_lifecycle_event") + .value("NewProject", LifecycleEvent::NewProject) + .value("ProjectOpened", LifecycleEvent::ProjectOpened) + .value("ProjectBeforeSave", LifecycleEvent::ProjectBeforeSave) + .value("ProjectAfterSave", LifecycleEvent::ProjectAfterSave) + .value("ProjectClosed", LifecycleEvent::ProjectClosed) + .value("SliceStarted", LifecycleEvent::SliceStarted) + .value("SliceGeometryFinished", LifecycleEvent::SliceGeometryFinished) + .value("GCodeExportStarted", LifecycleEvent::GCodeExportStarted) + .value("GCodeExportFinished", LifecycleEvent::GCodeExportFinished) + .value("SlicingJobComplete", LifecycleEvent::SlicingJobComplete) + .value("ObjectAdded", LifecycleEvent::ObjectAdded) + .value("ObjectDeleted", LifecycleEvent::ObjectDeleted) + .value("ObjectTransformed", LifecycleEvent::ObjectTransformed) + .value("PresetSelected", LifecycleEvent::PresetSelected) + .value("PresetSaved", LifecycleEvent::PresetSaved) + .value("PrintStateChanged", LifecycleEvent::PrintStateChanged) + .value("DeviceOnlineChanged", LifecycleEvent::DeviceOnlineChanged) + .value("DeviceDiscovered", LifecycleEvent::DeviceDiscovered) + .value("DeviceSelected", LifecycleEvent::DeviceSelected) + .value("DeviceConnected", LifecycleEvent::DeviceConnected) + .value("DeviceDisconnected", LifecycleEvent::DeviceDisconnected) + .value("UploadStarted", LifecycleEvent::UploadStarted) + .value("UploadFinished", LifecycleEvent::UploadFinished) + .export_values(); + + py::enum_(m, "LifecycleEvtCode", "Outcome code accompanying a LifecycleEventContext") + .value("Ok", LifecycleEvtCode::Ok) + .value("Error", LifecycleEvtCode::Error) + .value("Warn", LifecycleEvtCode::Warn) + .export_values(); + + py::class_(m, "LifecycleEventContext", "Payload accompanying a LifecycleEvent") + .def(py::init<>()) + .def_readonly("name", &LifecycleEventContext::name) + .def_readonly("code", &LifecycleEventContext::code) + .def_readonly("msg", &LifecycleEventContext::msg); + py::class_(m, "PluginContext", "Context shared with plugin entry points") .def(py::init<>()) .def_readwrite("orca_version", &PluginContext::orca_version); @@ -362,6 +400,9 @@ void bind_python_api(pybind11::module_& m) .def("get_type", &PluginCapabilityInterface::get_type) .def("on_load", &PluginCapabilityInterface::on_load) .def("on_unload", &PluginCapabilityInterface::on_unload) + .def("on_lifecycle_event", &PluginCapabilityInterface::on_lifecycle_event, + "Override to react to an application lifecycle moment (LifecycleEvent) and its\n" + "LifecycleEventContext payload. Available on every capability type.") .def("has_config_ui", &PluginCapabilityInterface::has_config_ui, "Override to return True to replace the host's default JSON editor with your own HTML\n" "UI, returned by get_config_ui(). Every capability is configurable and appears in the\n" diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index 4a6df06441..c1e0b17aac 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -10,6 +10,8 @@ #include #include +#include + namespace Slic3r { enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; @@ -167,6 +169,8 @@ public: virtual void on_unload() {} virtual void on_cancelled() {} + virtual void on_lifecycle_event(LifecycleEvent event, const LifecycleEventContext& ctx) { (void) event; (void) ctx; } + // ── C++-only host state, never exposed to Python. Set by the loader at materialization. ── // // The capability owns its own identity and enable flag: they are read once under the GIL, live From 64d04a75f3365fa72a89d22170433e659aaa2be5 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Mon, 24 Aug 2026 11:36:37 +0800 Subject: [PATCH 002/124] feat: additional events --- src/libslic3r/LifecycleEvents.hpp | 50 ++++++++++++++++- src/slic3r/GUI/GUI_ObjectList.cpp | 25 ++++++++- src/slic3r/GUI/Jobs/PrintJob.cpp | 22 ++++++++ src/slic3r/GUI/Jobs/PrintJob.hpp | 3 + src/slic3r/GUI/Jobs/SendJob.cpp | 21 +++++++ src/slic3r/GUI/Jobs/SendJob.hpp | 2 + src/slic3r/GUI/PartPlate.cpp | 35 +++++++++++- src/slic3r/GUI/Plater.cpp | 61 ++++++++++++++++----- src/slic3r/GUI/ProjectDirtyStateManager.cpp | 47 +++++++++++++++- src/slic3r/GUI/ProjectDirtyStateManager.hpp | 23 +++++++- src/slic3r/GUI/TaskManager.cpp | 18 ++++++ src/slic3r/plugin/PythonPluginBridge.cpp | 20 ++++++- 12 files changed, 307 insertions(+), 20 deletions(-) diff --git a/src/libslic3r/LifecycleEvents.hpp b/src/libslic3r/LifecycleEvents.hpp index 1528e97b46..9954836b3e 100644 --- a/src/libslic3r/LifecycleEvents.hpp +++ b/src/libslic3r/LifecycleEvents.hpp @@ -2,8 +2,8 @@ // LifecycleEvents.hpp // -------------------- -// Application lifecycle events (project, slicing, plate editing, preset, printer connection -// activity) that other subsystems -- chiefly the plugin layer above libslic3r -- may want to +// Application lifecycle events (project, slicing, plate editing, preset, printer connection, and +// job activity) that other subsystems -- chiefly the plugin layer above libslic3r -- may want to // observe. Lives in libslic3r rather than the plugin layer because some events fire from inside // the slicing engine itself; see fire_lifecycle_event() below. @@ -20,6 +20,7 @@ namespace Slic3r ProjectBeforeSave, ProjectAfterSave, ProjectClosed, + ProjectDirtyChanged, // Slicing pipeline SliceStarted, @@ -32,6 +33,12 @@ namespace Slic3r ObjectAdded, ObjectDeleted, ObjectTransformed, + ObjectChanged, + ObjectRenamed, + PlateCreated, + PlateDeleted, + PlateSelected, + PlateRenamed, // Preset PresetSelected, @@ -46,6 +53,12 @@ namespace Slic3r DeviceDisconnected, UploadStarted, UploadFinished, + + // Print/send jobs + PrintJobStarted, + PrintJobFinished, + SendJobStarted, + SendJobFinished, }; // Scoped so callers must qualify (LifecycleEvtCode::Error, not ERROR) -- ERROR/OK collide with @@ -66,6 +79,28 @@ namespace Slic3r // Optional human-readable detail or diagnostic text. It is not a stable parsing contract; // machine-readable data should be represented by a dedicated field or event instead. std::string msg; + + // Stable subject/object identifier, when the source model provides one. + std::string id; + + // Previous value for rename and other before/after events. + std::string previous_name; + + // Device identifier for printer and job events. + std::string device_id; + + // Job identifier when the originating queue/task provides one. + std::string job_id; + + // Source subsystem or operation detail, suitable for filtering but not guaranteed to be + // exhaustive across versions. + std::string source; + + // Plate, object, or volume index when the source uses an index rather than a stable id. + int index = -1; + + // Aggregate project dirty state for ProjectDirtyChanged. + bool dirty = false; }; inline std::string lifecycle_event_to_string(LifecycleEvent event) @@ -76,6 +111,7 @@ namespace Slic3r case LifecycleEvent::ProjectBeforeSave: return "ProjectBeforeSave"; case LifecycleEvent::ProjectAfterSave: return "ProjectAfterSave"; case LifecycleEvent::ProjectClosed: return "ProjectClosed"; + case LifecycleEvent::ProjectDirtyChanged: return "ProjectDirtyChanged"; case LifecycleEvent::SliceStarted: return "SliceStarted"; case LifecycleEvent::SliceGeometryFinished: return "SliceGeometryFinished"; @@ -86,6 +122,12 @@ namespace Slic3r case LifecycleEvent::ObjectAdded: return "ObjectAdded"; case LifecycleEvent::ObjectDeleted: return "ObjectDeleted"; case LifecycleEvent::ObjectTransformed: return "ObjectTransformed"; + case LifecycleEvent::ObjectChanged: return "ObjectChanged"; + case LifecycleEvent::ObjectRenamed: return "ObjectRenamed"; + case LifecycleEvent::PlateCreated: return "PlateCreated"; + case LifecycleEvent::PlateDeleted: return "PlateDeleted"; + case LifecycleEvent::PlateSelected: return "PlateSelected"; + case LifecycleEvent::PlateRenamed: return "PlateRenamed"; case LifecycleEvent::PresetSelected: return "PresetSelected"; case LifecycleEvent::PresetSaved: return "PresetSaved"; @@ -99,6 +141,10 @@ namespace Slic3r case LifecycleEvent::UploadStarted: return "UploadStarted"; case LifecycleEvent::UploadFinished: return "UploadFinished"; + case LifecycleEvent::PrintJobStarted: return "PrintJobStarted"; + case LifecycleEvent::PrintJobFinished: return "PrintJobFinished"; + case LifecycleEvent::SendJobStarted: return "SendJobStarted"; + case LifecycleEvent::SendJobFinished: return "SendJobFinished"; default: return "Unknown"; } } diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index e786b7241b..2d492be812 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -1,5 +1,6 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include "GUI_ObjectList.hpp" #include "GUI_Factories.hpp" //#include "GUI_ObjectLayers.hpp" @@ -1200,17 +1201,39 @@ void ObjectList::update_name_in_model(const wxDataViewItem& item) const if (m_objects_model->GetItemType(item) & itObject) { std::string name = m_objects_model->GetName(item).ToUTF8().data(); if (obj->name != name) { + const std::string previous_name = obj->name; obj->name = name; // if object has just one volume, rename this volume too if (obj->volumes.size() == 1) obj->volumes[0]->name = obj->name; Slic3r::save_object_mesh(*obj); + + LifecycleEventContext ctx; + ctx.name = name; + ctx.previous_name = previous_name; + ctx.id = std::to_string(obj->id().id); + ctx.index = obj_idx; + ctx.source = "object"; + fire_lifecycle_event(LifecycleEvent::ObjectRenamed, ctx); } return; } if (volume_id < 0) return; - obj->volumes[volume_id]->name = m_objects_model->GetName(item).ToUTF8().data(); + std::string name = m_objects_model->GetName(item).ToUTF8().data(); + if (obj->volumes[volume_id]->name == name) + return; + + const std::string previous_name = obj->volumes[volume_id]->name; + obj->volumes[volume_id]->name = name; + + LifecycleEventContext ctx; + ctx.name = name; + ctx.previous_name = previous_name; + ctx.id = std::to_string(obj->volumes[volume_id]->id().id); + ctx.index = volume_id; + ctx.source = "volume"; + fire_lifecycle_event(LifecycleEvent::ObjectRenamed, ctx); } void ObjectList::update_name_in_list(int obj_idx, int vol_idx) const diff --git a/src/slic3r/GUI/Jobs/PrintJob.cpp b/src/slic3r/GUI/Jobs/PrintJob.cpp index d79deb955b..cd9b851b2a 100644 --- a/src/slic3r/GUI/Jobs/PrintJob.cpp +++ b/src/slic3r/GUI/Jobs/PrintJob.cpp @@ -1,4 +1,5 @@ #include "PrintJob.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/MTUtils.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/PresetBundle.hpp" @@ -133,6 +134,13 @@ wxString PrintJob::get_http_error_msg(unsigned int status, std::string body) void PrintJob::process(Ctl &ctl) { + LifecycleEventContext start_ctx; + start_ctx.name = m_project_name; + start_ctx.device_id = m_dev_id; + start_ctx.source = "print_job"; + fire_lifecycle_event(LifecycleEvent::PrintJobStarted, start_ctx); + m_lifecycle_started = true; + /* display info */ std::string msg; wxString error_str; @@ -689,6 +697,7 @@ void PrintJob::process(Ctl &ctl) } wxQueueEvent(m_plater, evt); m_job_finished = true; + m_lifecycle_success = true; } } @@ -701,6 +710,19 @@ void PrintJob::finalize(bool canceled, std::exception_ptr &eptr) { eptr = std::current_exception(); } + if (m_lifecycle_started && !m_lifecycle_finished) { + LifecycleEventContext finish_ctx; + finish_ctx.name = m_project_name; + finish_ctx.device_id = m_dev_id; + finish_ctx.source = "print_job"; + finish_ctx.code = canceled ? LifecycleEvtCode::Warn : + (eptr || !m_lifecycle_success ? LifecycleEvtCode::Error : LifecycleEvtCode::Ok); + finish_ctx.msg = canceled ? "cancelled" : (eptr ? "exception" : + (m_lifecycle_success ? "" : "failed")); + fire_lifecycle_event(LifecycleEvent::PrintJobFinished, finish_ctx); + m_lifecycle_finished = true; + } + if (canceled || eptr) return; } diff --git a/src/slic3r/GUI/Jobs/PrintJob.hpp b/src/slic3r/GUI/Jobs/PrintJob.hpp index 9a23b04054..4be68031d9 100644 --- a/src/slic3r/GUI/Jobs/PrintJob.hpp +++ b/src/slic3r/GUI/Jobs/PrintJob.hpp @@ -43,6 +43,9 @@ class PrintJob : public Job std::function m_success_fun{nullptr}; std::string m_dev_id; bool m_job_finished{ false }; + bool m_lifecycle_started{ false }; + bool m_lifecycle_finished{ false }; + bool m_lifecycle_success{ false }; int m_print_job_completed_id = 0; wxString m_completed_evt_data; std::function m_enter_ip_address_fun_fail{ nullptr }; diff --git a/src/slic3r/GUI/Jobs/SendJob.cpp b/src/slic3r/GUI/Jobs/SendJob.cpp index bb52367188..5bf9010201 100644 --- a/src/slic3r/GUI/Jobs/SendJob.cpp +++ b/src/slic3r/GUI/Jobs/SendJob.cpp @@ -1,4 +1,5 @@ #include "SendJob.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/MTUtils.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/PresetBundle.hpp" @@ -146,6 +147,13 @@ void SendJob::process(Ctl &ctl) } } + LifecycleEventContext start_ctx; + start_ctx.name = m_project_name; + start_ctx.device_id = m_dev_id; + start_ctx.source = "send_job"; + fire_lifecycle_event(LifecycleEvent::SendJobStarted, start_ctx); + m_lifecycle_started = true; + int total_plate_num = m_plater->get_partplate_list().get_plate_count(); PartPlate* plate = m_plater->get_partplate_list().get_plate(job_data.plate_idx); @@ -423,6 +431,19 @@ void SendJob::finalize(bool canceled, std::exception_ptr &eptr) eptr = std::current_exception(); } + if (m_lifecycle_started && !m_lifecycle_finished) { + LifecycleEventContext finish_ctx; + finish_ctx.name = m_project_name; + finish_ctx.device_id = m_dev_id; + finish_ctx.source = "send_job"; + finish_ctx.code = canceled ? LifecycleEvtCode::Warn : + (eptr ? LifecycleEvtCode::Error : (m_job_finished ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error)); + finish_ctx.msg = canceled ? "cancelled" : (eptr ? "exception" : + (m_job_finished ? "" : "failed")); + fire_lifecycle_event(LifecycleEvent::SendJobFinished, finish_ctx); + m_lifecycle_finished = true; + } + if (canceled || eptr) return; } diff --git a/src/slic3r/GUI/Jobs/SendJob.hpp b/src/slic3r/GUI/Jobs/SendJob.hpp index 1d4e025d6e..86d180ac8c 100644 --- a/src/slic3r/GUI/Jobs/SendJob.hpp +++ b/src/slic3r/GUI/Jobs/SendJob.hpp @@ -22,6 +22,8 @@ class SendJob : public Job PrintPrepareData job_data; std::string m_dev_id; bool m_job_finished{ false }; + bool m_lifecycle_started{ false }; + bool m_lifecycle_finished{ false }; int m_print_job_completed_id = 0; bool m_is_check_mode{false}; bool m_check_and_continue{false}; diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 910c761c06..a2466bc4a7 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -25,6 +25,7 @@ #include "libslic3r/Tesselate.hpp" #include "libslic3r/GCode/ThumbnailData.hpp" #include "libslic3r/Utils.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include "I18N.hpp" #include "GUI_App.hpp" @@ -2527,11 +2528,20 @@ void PartPlate::set_plate_name(const std::string& name) if (boost::equals(m_name, name)) return; + const std::string previous_name = m_name; m_name = name; if (m_print != nullptr) m_print->set_plate_name(name); invalidate_plate_name_texture(); + + if (m_plater != nullptr && !m_plater->is_loading_project()) { + LifecycleEventContext ctx; + ctx.name = name; + ctx.previous_name = previous_name; + ctx.index = m_plate_index; + fire_lifecycle_event(LifecycleEvent::PlateRenamed, ctx); + } } //get the print's object, result and index @@ -4638,9 +4648,16 @@ int PartPlateList::create_plate(bool adjust_position) if (m_plater) { // In GUI mode - wxGetApp().obj_list()->on_plate_added(plate); + wxGetApp().obj_list()->on_plate_added(plate); } + if (m_plater != nullptr && m_intialized && !m_plater->is_loading_project()) { + LifecycleEventContext ctx; + ctx.name = plate->get_plate_name(); + ctx.index = new_index; + fire_lifecycle_event(LifecycleEvent::PlateCreated, ctx); + } + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(":created a new plate %1%") % new_index; return new_index; } @@ -4739,6 +4756,7 @@ int PartPlateList::delete_plate(int index) BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(":plate %1%, has an invalid index %2%") % index % plate->get_index(); return -1; } + const std::string plate_name = plate->get_plate_name(); if (m_plater) { // In GUI mode @@ -4821,6 +4839,13 @@ int PartPlateList::delete_plate(int index) delete plate; + if (m_plater != nullptr && m_intialized && !m_plater->is_loading_project()) { + LifecycleEventContext ctx; + ctx.name = plate_name; + ctx.index = index; + fire_lifecycle_event(LifecycleEvent::PlateDeleted, ctx); + } + // FIX: context of BackgroundSliceProcess and gcode preview need to be updated before ObjectList::reload_all_plates(). #if 0 if (m_plater != nullptr) { @@ -4929,6 +4954,7 @@ int PartPlateList::select_plate(int index) if (m_plate_list.empty() || index >= m_plate_list.size()) { return -1; } + const int previous_index = m_current_plate; // BBS: erase unnecessary snapshot if (get_curr_plate_index() != index && m_intialized) { @@ -4955,6 +4981,13 @@ int PartPlateList::select_plate(int index) //wxQueueEvent(m_plater, new SimpleEvent(EVT_GLCANVAS_PLATE_SELECT)); } + if (previous_index != index && m_intialized && m_plater != nullptr && !m_plater->is_loading_project()) { + LifecycleEventContext ctx; + ctx.name = m_plate_list[index]->get_plate_name(); + ctx.index = index; + fire_lifecycle_event(LifecycleEvent::PlateSelected, ctx); + } + return 0; } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index d9ad76b51f..4da3370681 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -61,6 +61,7 @@ #include #include "libslic3r/libslic3r.h" +#include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/Format/STL.hpp" #include "libslic3r/Format/DRC.hpp" #include "libslic3r/Format/STEP.hpp" @@ -8470,6 +8471,11 @@ void Plater::priv::delete_all_objects_from_model() void Plater::priv::reset(bool apply_presets_change) { + // TakeSnapshot below and load_current_presets() further down each re-evaluate the + // aggregate dirty flag against a baseline that hasn't been reset yet, so they can toggle + // is_dirty() back and forth several times before it settles; coalesce those into one event. + ProjectDirtyStateManager::NotificationSuppressor dirty_notify_suppressor(dirty_state); + Plater::TakeSnapshot snapshot(q, _u8L("Reset Project"), UndoRedo::SnapshotType::ProjectSeparator); clear_warnings(); @@ -13118,21 +13124,28 @@ int Plater::new_project(bool skip_confirm, bool silent, const wxString& project_ ctx.code = Slic3r::LifecycleEvtCode::Ok; Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::NewProject, ctx); } - reset(transfer_preset_changes); - reset_project_dirty_after_save(); - reset_project_dirty_initial_presets(); - wxGetApp().update_saved_preset_from_current_preset(); - update_project_dirty_from_presets(); + { + // Same rationale as in Plater::priv::reset(): the whole reset + preset-reload + + // baseline-reset sequence below settles into its final dirty state only once it + // completes, so hold notifications until then to avoid firing on transient flips. + ProjectDirtyStateManager::NotificationSuppressor dirty_notify_suppressor(p->dirty_state); - //reset project - p->project.reset(); - //set project name - if (project_name.empty()) - p->set_project_name(_L("Untitled")); - else - p->set_project_name(project_name); + reset(transfer_preset_changes); + reset_project_dirty_after_save(); + reset_project_dirty_initial_presets(); + wxGetApp().update_saved_preset_from_current_preset(); + update_project_dirty_from_presets(); - Plater::TakeSnapshot snapshot(this, "New Project", UndoRedo::SnapshotType::ProjectSeparator); + //reset project + p->project.reset(); + //set project name + if (project_name.empty()) + p->set_project_name(_L("Untitled")); + else + p->set_project_name(project_name); + + Plater::TakeSnapshot snapshot(this, "New Project", UndoRedo::SnapshotType::ProjectSeparator); + } Model m; model().load_from(m); // new id avoid same path name @@ -18339,6 +18352,14 @@ void Plater::changed_object(ModelObject &object){ // Check outside bed get_current_canvas3D()->requires_check_outside_state(); + + if (!is_loading_project()) { + LifecycleEventContext ctx; + ctx.name = object.name; + ctx.id = std::to_string(object.id().id); + ctx.source = "geometry"; + fire_lifecycle_event(LifecycleEvent::ObjectChanged, ctx); + } } void Plater::changed_object(int obj_idx) @@ -18375,6 +18396,20 @@ void Plater::changed_objects(const std::vector& object_idxs) // update print this->p->schedule_background_process(); + + if (!is_loading_project()) { + for (size_t obj_idx : object_idxs) { + if (obj_idx >= p->model.objects.size() || p->model.objects[obj_idx] == nullptr) + continue; + + LifecycleEventContext ctx; + ctx.name = p->model.objects[obj_idx]->name; + ctx.id = std::to_string(p->model.objects[obj_idx]->id().id); + ctx.index = static_cast(obj_idx); + ctx.source = "geometry"; + fire_lifecycle_event(LifecycleEvent::ObjectChanged, ctx); + } + } } void Plater::schedule_background_process(bool schedule/* = true*/) diff --git a/src/slic3r/GUI/ProjectDirtyStateManager.cpp b/src/slic3r/GUI/ProjectDirtyStateManager.cpp index c986328f71..53eb3cd353 100644 --- a/src/slic3r/GUI/ProjectDirtyStateManager.cpp +++ b/src/slic3r/GUI/ProjectDirtyStateManager.cpp @@ -6,6 +6,7 @@ #include "MainFrame.hpp" #include "I18N.hpp" #include "Plater.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include @@ -17,13 +18,23 @@ namespace GUI { void ProjectDirtyStateManager::update_from_undo_redo_stack(bool dirty) { + const bool was_dirty = is_dirty(); m_plater_dirty = dirty; + notify_dirty_change(was_dirty, "undo_redo"); if (const Plater *plater = wxGetApp().plater(); plater && wxGetApp().initialized()) wxGetApp().mainframe->update_title(); } +void ProjectDirtyStateManager::set_plater_dirty(bool is_dirty) +{ + const bool was_dirty = this->is_dirty(); + m_plater_dirty = is_dirty; + notify_dirty_change(was_dirty, "plater"); +} + void ProjectDirtyStateManager::update_from_presets() { + const bool was_dirty = is_dirty(); m_presets_dirty = false; // check switching of the presets only for exist/loaded project, but not for new GUI_App &app = wxGetApp(); @@ -45,18 +56,53 @@ void ProjectDirtyStateManager::update_from_presets() } m_presets_dirty |= app.has_unsaved_preset_changes(); m_project_config_dirty = m_initial_project_config != app.preset_bundle->project_config; + notify_dirty_change(was_dirty, "presets"); app.mainframe->update_title(); } void ProjectDirtyStateManager::reset_after_save() { + const bool was_dirty = is_dirty(); this->reset_initial_presets(); m_plater_dirty = false; m_presets_dirty = false; m_project_config_dirty = false; + notify_dirty_change(was_dirty, "save"); wxGetApp().mainframe->update_title(); } +void ProjectDirtyStateManager::notify_dirty_change(bool was_dirty, const char *source) +{ + if (m_suppress_depth > 0) + return; + + const bool dirty = is_dirty(); + if (was_dirty == dirty) + return; + + LifecycleEventContext ctx; + ctx.code = LifecycleEvtCode::Ok; + ctx.dirty = dirty; + ctx.source = source; + fire_lifecycle_event(LifecycleEvent::ProjectDirtyChanged, ctx); +} + +void ProjectDirtyStateManager::begin_suppress_notifications() +{ + if (m_suppress_depth == 0) + m_suppress_entry_dirty = is_dirty(); + ++m_suppress_depth; +} + +void ProjectDirtyStateManager::end_suppress_notifications() +{ + assert(m_suppress_depth > 0); + if (m_suppress_depth > 0) + --m_suppress_depth; + if (m_suppress_depth == 0) + notify_dirty_change(m_suppress_entry_dirty, "batch"); +} + void ProjectDirtyStateManager::reset_initial_presets() { m_initial_presets.fill(std::string{}); @@ -168,4 +214,3 @@ void ProjectDirtyStateManager::render_debug_window() const } // namespace GUI } // namespace Slic3r - diff --git a/src/slic3r/GUI/ProjectDirtyStateManager.hpp b/src/slic3r/GUI/ProjectDirtyStateManager.hpp index 171d19e219..fd121d1d85 100644 --- a/src/slic3r/GUI/ProjectDirtyStateManager.hpp +++ b/src/slic3r/GUI/ProjectDirtyStateManager.hpp @@ -14,21 +14,42 @@ public: void reset_after_save(); void reset_initial_presets(); - void set_plater_dirty(bool is_dirty) { m_plater_dirty = is_dirty; } + void set_plater_dirty(bool is_dirty); bool is_dirty() const { return m_plater_dirty || m_project_config_dirty || m_presets_dirty; } bool is_presets_dirty() const { return m_presets_dirty; } + // RAII guard coalescing dirty-state updates: while any guard is alive, ProjectDirtyChanged + // notifications are held back; when the outermost guard is destroyed, at most one + // notification fires, reflecting only the net change across the whole guarded scope. + class NotificationSuppressor + { + public: + explicit NotificationSuppressor(ProjectDirtyStateManager &owner) : m_owner(owner) { m_owner.begin_suppress_notifications(); } + ~NotificationSuppressor() { m_owner.end_suppress_notifications(); } + NotificationSuppressor(const NotificationSuppressor &) = delete; + NotificationSuppressor &operator=(const NotificationSuppressor &) = delete; + private: + ProjectDirtyStateManager &m_owner; + }; + #if ENABLE_PROJECT_DIRTY_STATE_DEBUG_WINDOW void render_debug_window() const; #endif // ENABLE_PROJECT_DIRTY_STATE_DEBUG_WINDOW private: + void notify_dirty_change(bool was_dirty, const char *source); + void begin_suppress_notifications(); + void end_suppress_notifications(); + // Does the Undo / Redo stack indicate the project is dirty? bool m_plater_dirty { false }; // Do the presets indicate the project is dirty? bool m_presets_dirty { false }; // Is the project config dirty? bool m_project_config_dirty { false }; + // NotificationSuppressor nesting depth and the dirty state observed when the outermost guard began. + int m_suppress_depth { 0 }; + bool m_suppress_entry_dirty { false }; // Keeps track of preset names selected at the time of last project save. std::array m_initial_presets; DynamicPrintConfig m_initial_project_config; diff --git a/src/slic3r/GUI/TaskManager.cpp b/src/slic3r/GUI/TaskManager.cpp index d294d140c5..b0f6f43f58 100644 --- a/src/slic3r/GUI/TaskManager.cpp +++ b/src/slic3r/GUI/TaskManager.cpp @@ -1,6 +1,7 @@ #include "TaskManager.hpp" #include "libslic3r/Thread.hpp" +#include "libslic3r/LifecycleEvents.hpp" #include "nlohmann/json.hpp" #include "MainFrame.hpp" #include "GUI_App.hpp" @@ -210,6 +211,13 @@ int TaskManager::schedule(TaskStateInfo* task) assert(task->state() == TaskState::TS_PENDING); task->set_state(TaskState::TS_SENDING); + LifecycleEventContext start_ctx; + start_ctx.name = task->params().project_name; + start_ctx.device_id = task->params().dev_id; + start_ctx.job_id = std::to_string(task->task_info_id); + start_ctx.source = "task_manager"; + fire_lifecycle_event(LifecycleEvent::PrintJobStarted, start_ctx); + BOOST_LOG_TRIVIAL(trace) << "task_manager: schedule a task to dev_id = " << task->params().dev_id; boost::thread* new_sending_thread = new boost::thread(); *new_sending_thread = Slic3r::create_thread( @@ -237,6 +245,16 @@ int TaskManager::schedule(TaskStateInfo* task) task->set_state(TaskState::TS_SEND_CANCELED); } } + + LifecycleEventContext finish_ctx; + finish_ctx.name = task->params().project_name; + finish_ctx.device_id = task->params().dev_id; + finish_ctx.job_id = std::to_string(task->task_info_id); + finish_ctx.source = "task_manager"; + finish_ctx.code = result == 0 ? LifecycleEvtCode::Ok : + (task->is_canceled() ? LifecycleEvtCode::Warn : LifecycleEvtCode::Error); + finish_ctx.msg = result == 0 ? "" : (task->is_canceled() ? "cancelled" : "failed"); + fire_lifecycle_event(LifecycleEvent::PrintJobFinished, finish_ctx); /* remove from sending task list */ m_scedule_mutex.lock(); diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 34a91a543e..2b4ca52fd8 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -345,6 +345,7 @@ void bind_python_api(pybind11::module_& m) .value("ProjectBeforeSave", LifecycleEvent::ProjectBeforeSave) .value("ProjectAfterSave", LifecycleEvent::ProjectAfterSave) .value("ProjectClosed", LifecycleEvent::ProjectClosed) + .value("ProjectDirtyChanged", LifecycleEvent::ProjectDirtyChanged) .value("SliceStarted", LifecycleEvent::SliceStarted) .value("SliceGeometryFinished", LifecycleEvent::SliceGeometryFinished) .value("GCodeExportStarted", LifecycleEvent::GCodeExportStarted) @@ -353,6 +354,12 @@ void bind_python_api(pybind11::module_& m) .value("ObjectAdded", LifecycleEvent::ObjectAdded) .value("ObjectDeleted", LifecycleEvent::ObjectDeleted) .value("ObjectTransformed", LifecycleEvent::ObjectTransformed) + .value("ObjectChanged", LifecycleEvent::ObjectChanged) + .value("ObjectRenamed", LifecycleEvent::ObjectRenamed) + .value("PlateCreated", LifecycleEvent::PlateCreated) + .value("PlateDeleted", LifecycleEvent::PlateDeleted) + .value("PlateSelected", LifecycleEvent::PlateSelected) + .value("PlateRenamed", LifecycleEvent::PlateRenamed) .value("PresetSelected", LifecycleEvent::PresetSelected) .value("PresetSaved", LifecycleEvent::PresetSaved) .value("PrintStateChanged", LifecycleEvent::PrintStateChanged) @@ -363,6 +370,10 @@ void bind_python_api(pybind11::module_& m) .value("DeviceDisconnected", LifecycleEvent::DeviceDisconnected) .value("UploadStarted", LifecycleEvent::UploadStarted) .value("UploadFinished", LifecycleEvent::UploadFinished) + .value("PrintJobStarted", LifecycleEvent::PrintJobStarted) + .value("PrintJobFinished", LifecycleEvent::PrintJobFinished) + .value("SendJobStarted", LifecycleEvent::SendJobStarted) + .value("SendJobFinished", LifecycleEvent::SendJobFinished) .export_values(); py::enum_(m, "LifecycleEvtCode", "Outcome code accompanying a LifecycleEventContext") @@ -375,7 +386,14 @@ void bind_python_api(pybind11::module_& m) .def(py::init<>()) .def_readonly("name", &LifecycleEventContext::name) .def_readonly("code", &LifecycleEventContext::code) - .def_readonly("msg", &LifecycleEventContext::msg); + .def_readonly("msg", &LifecycleEventContext::msg) + .def_readonly("id", &LifecycleEventContext::id) + .def_readonly("previous_name", &LifecycleEventContext::previous_name) + .def_readonly("device_id", &LifecycleEventContext::device_id) + .def_readonly("job_id", &LifecycleEventContext::job_id) + .def_readonly("source", &LifecycleEventContext::source) + .def_readonly("index", &LifecycleEventContext::index) + .def_readonly("dirty", &LifecycleEventContext::dirty); py::class_(m, "PluginContext", "Context shared with plugin entry points") .def(py::init<>()) From 33a8265a11555043a27c746abcd1670cb82bd3cd Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 25 Aug 2026 15:37:15 +0800 Subject: [PATCH 003/124] fix: slicer regression test --- src/libslic3r/Print.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 2aa9ac8ce9..29086725ea 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2255,7 +2255,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) { LifecycleEventContext ctx; - ctx.name = output_filename(); + ctx.name = std::to_string(m_model.id().id); ctx.code = LifecycleEvtCode::Ok; fire_lifecycle_event(LifecycleEvent::SliceStarted, ctx); } @@ -2829,8 +2829,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) { LifecycleEventContext ctx; + ctx.name = std::to_string(m_model.id().id); ctx.code = LifecycleEvtCode::Ok; - ctx.name = output_filename(); fire_lifecycle_event(LifecycleEvent::SliceGeometryFinished, ctx); } } @@ -4451,8 +4451,9 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces { { LifecycleEventContext ctx; - ctx.name = file; + ctx.name = std::to_string(m_model.id().id); ctx.code = LifecycleEvtCode::Ok; + ctx.msg = file; fire_lifecycle_event(LifecycleEvent::GCodeExportStarted, ctx); } @@ -4479,9 +4480,9 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": found errors when process gcode file %1%") %file.c_str(); { LifecycleEventContext ctx; - ctx.name = file; + ctx.name = std::to_string(m_model.id().id); ctx.code = LifecycleEvtCode::Error; - ctx.msg = ex.what(); + ctx.msg = file + "\n" + ex.what(); fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); } throw Slic3r::RuntimeError( @@ -4492,8 +4493,9 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces { LifecycleEventContext ctx; - ctx.name = file; + ctx.name = std::to_string(m_model.id().id); ctx.code = LifecycleEvtCode::Ok; + ctx.msg = file; fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); } } From fabd08af1e4b54733a4d98c111c316d1b42d3db4 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 25 Aug 2026 15:59:17 +0800 Subject: [PATCH 004/124] update GCodeExport to use model id for ctx.name --- src/libslic3r/GCode.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 6f5f2ad8e4..2e16959a71 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2449,8 +2449,9 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu { LifecycleEventContext ctx; - ctx.name = path; + ctx.name = std::to_string(print->model().id().id); ctx.code = LifecycleEvtCode::Ok; + ctx.msg = path; fire_lifecycle_event(LifecycleEvent::GCodeExportStarted, ctx); } @@ -2499,9 +2500,9 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu } { LifecycleEventContext ctx; - ctx.name = path; + ctx.name = std::to_string(print->model().id().id); ctx.code = LifecycleEvtCode::Error; - ctx.msg = err_msg; + ctx.msg = std::string(path) + "\n" + err_msg; fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); } throw Slic3r::RuntimeError(err_msg); @@ -2522,9 +2523,9 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu boost::nowide::remove(path_tmp.c_str()); { LifecycleEventContext ctx; - ctx.name = path; + ctx.name = std::to_string(print->model().id().id); ctx.code = LifecycleEvtCode::Error; - ctx.msg = ex.what(); + ctx.msg = std::string(path) + "\n" + ex.what(); fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); } throw; @@ -2634,9 +2635,9 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu if (ret) { { LifecycleEventContext ctx; - ctx.name = path; + ctx.name = std::to_string(print->model().id().id); ctx.code = LifecycleEvtCode::Error; - ctx.msg = "Failed to rename the output G-code file: " + ret.message(); + ctx.msg = std::string(path) + "\nFailed to rename the output G-code file: " + ret.message(); fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); } throw Slic3r::RuntimeError( @@ -2652,8 +2653,9 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu { LifecycleEventContext ctx; - ctx.name = path; + ctx.name = std::to_string(print->model().id().id); ctx.code = LifecycleEvtCode::Ok; + ctx.msg = path; fire_lifecycle_event(LifecycleEvent::GCodeExportFinished, ctx); } From ce41b5a3cb6d3f5eb3db4abc66964d75c4acd78e Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 18 Sep 2026 22:33:46 +0800 Subject: [PATCH 005/124] fix: stale audit mode on_lifecycle_event --- src/slic3r/plugin/PyPluginTrampoline.hpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index c23bc30e98..4c77f1ba9c 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -135,8 +135,7 @@ public: void on_lifecycle_event(LifecycleEvent event, const LifecycleEventContext& ctx) override { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_lifecycle_event, event, ctx); + ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_lifecycle_event, event, ctx); } }; From fd9c1218a443a388ac1f01df074b585677d35e21 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Tue, 22 Sep 2026 14:35:14 +0800 Subject: [PATCH 006/124] Keep Python Printer Agent Exceptions Out of the Host IPrinterAgent callers do not catch, so a Python raise, a missing override or a wrongly typed return from a printer agent plugin escaped into the GUI. Each trampoline operation now logs the failure and answers with NetworkAgent's no-agent value: -1 for status codes, the empty value otherwise. --- ...PrinterAgentPluginCapabilityTrampoline.hpp | 179 ++++++++---------- tests/slic3rutils/CMakeLists.txt | 1 + .../slic3rutils/test_plugin_printer_agent.cpp | 155 +++++++++++++++ 3 files changed, 237 insertions(+), 98 deletions(-) create mode 100644 tests/slic3rutils/test_plugin_printer_agent.cpp diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 5491e1cef6..6331ee64a6 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -7,7 +7,31 @@ #include "IPrinterAgent.hpp" #include +#include + +// IPrinterAgent reports failure through its return values and its callers do not catch, so nothing +// the plugin does may leave the trampoline as an exception: a Python raise, a missing override or a +// wrongly typed return is logged and answered with what NetworkAgent returns when no agent is set. +#define ORCA_PY_AGENT_CATCH(name) \ + catch (const std::exception& ex) { this->log_failure(#name, ex.what()); } \ + catch (...) { this->log_failure(#name, "unknown error"); } + +#define ORCA_PY_AGENT_OVERRIDE(ret, name, ...) \ + try { \ + ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE_PURE, ret, PrinterAgentPluginCapability, name, ##__VA_ARGS__); \ + } ORCA_PY_AGENT_CATCH(name) \ + return printer_agent_failure() + namespace Slic3r { +// NetworkAgent's no-agent answer: -1 for a status code, the empty value (false, "", none) otherwise. +template T printer_agent_failure() +{ + if constexpr (std::is_same_v) + return -1; + else if constexpr (!std::is_void_v) + return T{}; +} + class PyPrinterAgentPluginCapabilityTrampoline : public PyPluginCommonTrampoline { public: @@ -15,207 +39,157 @@ public: AgentInfo get_agent_info() override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, AgentInfo, PrinterAgentPluginCapability, - get_agent_info); + ORCA_PY_AGENT_OVERRIDE(AgentInfo, get_agent_info); } int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, connect_printer, dev_id, - dev_ip, username, password, use_ssl); + ORCA_PY_AGENT_OVERRIDE(int, connect_printer, dev_id, dev_ip, username, password, use_ssl); } int disconnect_printer() override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, disconnect_printer); + ORCA_PY_AGENT_OVERRIDE(int, disconnect_printer); } int send_message(std::string dev_id, std::string json_str, int qos, int flag) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message, dev_id, - json_str, qos, flag); + ORCA_PY_AGENT_OVERRIDE(int, send_message, dev_id, json_str, qos, flag); } int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message_to_printer, - dev_id, json_str, qos, flag); + ORCA_PY_AGENT_OVERRIDE(int, send_message_to_printer, dev_id, json_str, qos, flag); } bool start_discovery(bool start, bool sending) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, start_discovery, start, - sending); + ORCA_PY_AGENT_OVERRIDE(bool, start_discovery, start, sending); } int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind_detect, dev_ip, - sec_link, detect); + ORCA_PY_AGENT_OVERRIDE(int, bind_detect, dev_ip, sec_link, detect); } std::string get_user_selected_machine() override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, - get_user_selected_machine); + ORCA_PY_AGENT_OVERRIDE(std::string, get_user_selected_machine); } int set_user_selected_machine(std::string dev_id) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, - set_user_selected_machine, dev_id); + ORCA_PY_AGENT_OVERRIDE(int, set_user_selected_machine, dev_id); } int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, - start_send_gcode_to_sdcard, params, update_fn, cancel_fn, wait_fn); + ORCA_PY_AGENT_OVERRIDE(int, start_send_gcode_to_sdcard, params, update_fn, cancel_fn, wait_fn); } int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_local_print, - params, update_fn, cancel_fn); + ORCA_PY_AGENT_OVERRIDE(int, start_local_print, params, update_fn, cancel_fn); } FilamentSyncMode get_filament_sync_mode() const override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, FilamentSyncMode, PrinterAgentPluginCapability, - get_filament_sync_mode); + ORCA_PY_AGENT_OVERRIDE(FilamentSyncMode, get_filament_sync_mode); } bool fetch_filament_info(std::string dev_id) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, fetch_filament_info, dev_id); + ORCA_PY_AGENT_OVERRIDE(bool, fetch_filament_info, dev_id); } int check_cert() override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, check_cert); + ORCA_PY_AGENT_OVERRIDE(int, check_cert); } void install_device_cert(std::string dev_id, bool lan_only) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, void, PrinterAgentPluginCapability, install_device_cert, dev_id, - lan_only); + ORCA_PY_AGENT_OVERRIDE(void, install_device_cert, dev_id, lan_only); } int ping_bind(std::string ping_code) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, ping_bind, ping_code); + ORCA_PY_AGENT_OVERRIDE(int, ping_bind, ping_code); } int bind(std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind, dev_ip, dev_id, - dev_model, sec_link, timezone, improved, update_fn); + ORCA_PY_AGENT_OVERRIDE(int, bind, dev_ip, dev_id, dev_model, sec_link, timezone, improved, update_fn); } int unbind(std::string dev_id) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, unbind, dev_id); + ORCA_PY_AGENT_OVERRIDE(int, unbind, dev_id); } int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_print, params, - update_fn, cancel_fn, wait_fn); + ORCA_PY_AGENT_OVERRIDE(int, start_print, params, update_fn, cancel_fn, wait_fn); } int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, - start_local_print_with_record, params, update_fn, cancel_fn, wait_fn); + ORCA_PY_AGENT_OVERRIDE(int, start_local_print_with_record, params, update_fn, cancel_fn, wait_fn); } int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_sdcard_print, params, - update_fn, cancel_fn); + ORCA_PY_AGENT_OVERRIDE(int, start_sdcard_print, params, update_fn, cancel_fn); } int get_hms_snapshot(std::string dev_id, std::string file_name, std::function callback) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, get_hms_snapshot, dev_id, - file_name, callback); + ORCA_PY_AGENT_OVERRIDE(int, get_hms_snapshot, dev_id, file_name, callback); } int set_server_callback(OnServerErrFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_server_callback, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_server_callback, fn); } int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_ssdp_msg_fn, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_ssdp_msg_fn, fn); } int set_on_printer_connected_fn(OnPrinterConnectedFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_printer_connected_fn, - fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_printer_connected_fn, fn); } int set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_subscribe_failure_fn, - fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_subscribe_failure_fn, fn); } int set_on_message_fn(OnMessageFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_message_fn, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_message_fn, fn); } int set_on_user_message_fn(OnMessageFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_user_message_fn, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_user_message_fn, fn); } int set_on_local_connect_fn(OnLocalConnectedFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_connect_fn, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_local_connect_fn, fn); } int set_on_local_message_fn(OnMessageFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_message_fn, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_on_local_message_fn, fn); } int set_queue_on_main_fn(QueueOnMainFn fn) override { - ORCA_PY_OVERRIDE_AUDITED( - [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_queue_on_main_fn, fn); + ORCA_PY_AGENT_OVERRIDE(int, set_queue_on_main_fn, fn); } // request_bind_ticket returns its ticket through a std::string* out-param, which pybind11 @@ -223,24 +197,33 @@ public: // returns a (result, ticket) tuple, which we unpack into the int result and the out-param. int request_bind_ticket(std::string* ticket) override { - ORCA_PY_AUDIT_SCOPE(); - ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); - ::Slic3r::PythonGILState gil; - if (!gil) - throw std::runtime_error("Python interpreter is shutting down"); - pybind11::function override = - pybind11::get_override(static_cast(this), "request_bind_ticket"); - if (!override) - pybind11::pybind11_fail("Tried to call pure virtual function \"PrinterAgentPluginCapability::request_bind_ticket\""); try { - pybind11::tuple result = override().cast(); - if (ticket) - *ticket = result[1].cast(); - return result[0].cast(); - } catch (pybind11::error_already_set& err) { - ::Slic3r::log_python_exception_keep(err); - throw; - } + ORCA_PY_AUDIT_SCOPE(); + ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); + ::Slic3r::PythonGILState gil; + if (!gil) + throw std::runtime_error("Python interpreter is shutting down"); + pybind11::function override = + pybind11::get_override(static_cast(this), "request_bind_ticket"); + if (!override) + pybind11::pybind11_fail("Tried to call pure virtual function \"PrinterAgentPluginCapability::request_bind_ticket\""); + try { + pybind11::tuple result = override().cast(); + if (ticket) + *ticket = result[1].cast(); + return result[0].cast(); + } catch (pybind11::error_already_set& err) { + ::Slic3r::log_python_exception_keep(err); + throw; + } + } ORCA_PY_AGENT_CATCH(request_bind_ticket) + return printer_agent_failure(); + } + +private: + void log_failure(const char* operation, const char* error) const + { + BOOST_LOG_TRIVIAL(error) << "Printer agent plugin '" << this->audit_plugin_key() << "': " << operation << " failed: " << error; } }; } // namespace Slic3r diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 3ddacc5a1b..069d87e25b 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -13,6 +13,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_capabilities_in_use.cpp test_plugin_install.cpp test_plugin_lifecycle.cpp + test_plugin_printer_agent.cpp test_slicing_pipeline_bindings.cpp test_slicing_pipeline_config.cpp test_plugin_sort.cpp diff --git a/tests/slic3rutils/test_plugin_printer_agent.cpp b/tests/slic3rutils/test_plugin_printer_agent.cpp new file mode 100644 index 0000000000..b6e5f4a36e --- /dev/null +++ b/tests/slic3rutils/test_plugin_printer_agent.cpp @@ -0,0 +1,155 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace py = pybind11; +using namespace Slic3r; + +namespace { + +// Same idiom as ScopedPluginManager in test_plugin_lifecycle.cpp: the trampolines refuse to call +// into Python unless PythonInterpreter::instance() reports initialized. +struct ScopedPluginManager +{ + bool initialized = PluginManager::instance().initialize(); + + ~ScopedPluginManager() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; + +// The host reaches a printer agent plugin through IPrinterAgent, so the tests do too. The Python +// instance carries the overrides, so it has to outlive every call, as PluginInstanceHandle ensures +// in production. +struct Agent +{ + py::object instance; + std::shared_ptr agent; + + IPrinterAgent* operator->() const { return agent.get(); } + IPrinterAgent& operator*() const { return *agent; } +}; + +Agent make_agent(const std::string& body) +{ + (void) PythonPluginBridge::instance(); // force the embedded module registration into the binary + py::dict globals; + globals["orca"] = py::module_::import("orca"); + + py::exec("class Agent(orca.printer_agent.PrinterAgentBase):\n" + " def get_name(self): return 'agent'\n" + body, globals); + py::object instance = globals["Agent"](); + auto capability = instance.cast>(); + capability->set_audit_plugin_key("agent_plugin"); + return {instance, std::dynamic_pointer_cast(capability)}; +} + +// One operation per return type and dispatch shape; the rest share their macro. +const std::string OPERATIONS[] = {"get_agent_info", "disconnect_printer", "start_discovery", "get_user_selected_machine", + "get_filament_sync_mode", "install_device_cert", "start_local_print", "request_bind_ticket"}; + +// What NetworkAgent answers when no printer agent is set. +void check_answers_like_no_agent(IPrinterAgent& agent) +{ + std::string ticket = "untouched"; + + CHECK(agent.get_agent_info().id.empty()); + CHECK(agent.disconnect_printer() == -1); + CHECK_FALSE(agent.start_discovery(true, false)); + CHECK(agent.get_user_selected_machine().empty()); + CHECK(agent.get_filament_sync_mode() == FilamentSyncMode::none); + CHECK_NOTHROW(agent.install_device_cert("dev", true)); + CHECK(agent.start_local_print(PrintParams{}, nullptr, nullptr) == -1); + CHECK(agent.request_bind_ticket(&ticket) == -1); + CHECK(ticket == "untouched"); +} + +std::string define_all(const std::string& signature_tail, const std::string& statement) +{ + std::string body; + for (const std::string& operation : OPERATIONS) + body += " def " + operation + "(self" + signature_tail + "): " + statement + "\n"; + return body; +} + +} // namespace + +TEST_CASE("A printer agent operation that raises answers like a missing agent", "[PluginPrinterAgent][Python]") +{ + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + + auto agent = make_agent(define_all(", *args", "raise RuntimeError('boom')")); + REQUIRE(agent.agent); + + check_answers_like_no_agent(*agent); + + // The interpreter stays usable. + CHECK(py::eval("1 + 1").cast() == 2); +} + +TEST_CASE("A printer agent that omits its operations answers like a missing agent", "[PluginPrinterAgent][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; + + auto agent = make_agent(""); + REQUIRE(agent.agent); + + check_answers_like_no_agent(*agent); +} + +TEST_CASE("A printer agent operation returning the wrong type answers like a missing agent", "[PluginPrinterAgent][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; + + auto agent = make_agent(define_all(", *args", "return object()")); + REQUIRE(agent.agent); + + check_answers_like_no_agent(*agent); +} + +TEST_CASE("A working printer agent's answers reach the host unchanged", "[PluginPrinterAgent][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; + + auto agent = make_agent(" def get_agent_info(self): return orca.printer_agent.AgentInfo('id', 'name', '1', 'description')\n" + " def disconnect_printer(self): return 7\n" + " def start_discovery(self, start, sending): return start and not sending\n" + " def get_user_selected_machine(self): return 'machine'\n" + " def get_filament_sync_mode(self): return orca.printer_agent.FilamentSyncMode.Pull\n" + " def request_bind_ticket(self): return (3, 'ticket')\n"); + REQUIRE(agent.agent); + + std::string ticket; + + CHECK(agent->get_agent_info().id == "id"); + CHECK(agent->disconnect_printer() == 7); + CHECK(agent->start_discovery(true, false)); + CHECK(agent->get_user_selected_machine() == "machine"); + CHECK(agent->get_filament_sync_mode() == FilamentSyncMode::pull); + CHECK(agent->request_bind_ticket(&ticket) == 3); + CHECK(ticket == "ticket"); +} From 2876374b45d17292972ce13bd9eca83f5c3461db Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:52:25 +0800 Subject: [PATCH 007/124] Add a Dockable HTML Panel API for Plugins (#15736) orca.host.ui.create_dock_panel(html, title, width, height, on_message, on_close, dock) hosts plugin HTML in a pane of the Plater's dock manager, next to the sidebar, and returns a UiDockPanel handle (post/show/hide/close/is_open). The arguments follow create_window(). The panel uses the window.orca bridge of plugin windows, restores its position and size from the saved window layout, hides with the Plater off the Prepare and Preview tabs when floating, and is closed with its plugin; plugin panes are removed in MainFrame::shutdown(). The web view hosting moves out of PluginPage into a shared WebPanel base: bootstrap page and swap to the plugin HTML, theme, element-default and bridge scripts, window.orca message parsing, delivery to the page, and live re-theming, also re-applied on every load after the swap. Pages tabs and docked panels both derive from it. Pages tabs now re-theme in place on a theme change instead of being reloaded, and a window.orca call a host does not support is logged. What the hosts share no longer lives in one of them: the bootstrap page, the base URL and the plugin-window bridge move to Widgets/WebHosting, used by WebDialog and WebPanel alike. The Plater restores plugin panes with a new saved-layout parser, GUI/AuiPaneLayout, kept in its own small header so slic3rutils can test it without pulling in the Plater. The web hosting classes carry no plugin name, so other hosts can reuse them: PluginWebDialog becomes WebDialog (its bootstrap page moves to resources/web/dialog/WebDialog), and destroy_for_plugin(), load_plugin_content() and plugin_defaults_user_script() become destroy_silently(), load_page_html() and element_defaults_user_script(). Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and binding and layout-helper tests in slic3rutils. --- .../{PluginWebDialog => WebDialog}/blank.html | 2 +- sandboxes/orca_dock_panel_plugin_any.py | 146 ++++++++++++++++ src/slic3r/CMakeLists.txt | 12 +- src/slic3r/GUI/AuiPaneLayout.cpp | 20 +++ src/slic3r/GUI/AuiPaneLayout.hpp | 11 ++ src/slic3r/GUI/DockPanel.cpp | 103 +++++++++++ src/slic3r/GUI/DockPanel.hpp | 54 ++++++ src/slic3r/GUI/MainFrame.cpp | 2 + src/slic3r/GUI/Plater.cpp | 133 +++++++++++++++ src/slic3r/GUI/Plater.hpp | 11 ++ .../{PluginWebDialog.cpp => WebDialog.cpp} | 131 ++++---------- .../{PluginWebDialog.hpp => WebDialog.hpp} | 36 ++-- src/slic3r/GUI/WebPanel.cpp | 111 ++++++++++++ src/slic3r/GUI/WebPanel.hpp | 48 ++++++ src/slic3r/GUI/Widgets/WebHosting.cpp | 69 ++++++++ src/slic3r/GUI/Widgets/WebHosting.hpp | 25 +++ src/slic3r/GUI/Widgets/WebViewHostDialog.cpp | 4 +- src/slic3r/GUI/Widgets/WebViewHostDialog.hpp | 6 +- src/slic3r/plugin/host/PluginHostUi.cpp | 160 +++++++++++++++--- src/slic3r/plugin/host/PluginPages.cpp | 98 +++-------- src/slic3r/plugin/host/PluginPages.hpp | 18 +- tests/slic3rutils/test_plugin_host_api.cpp | 74 ++++++++ 22 files changed, 1038 insertions(+), 236 deletions(-) rename resources/web/dialog/{PluginWebDialog => WebDialog}/blank.html (76%) create mode 100644 sandboxes/orca_dock_panel_plugin_any.py create mode 100644 src/slic3r/GUI/AuiPaneLayout.cpp create mode 100644 src/slic3r/GUI/AuiPaneLayout.hpp create mode 100644 src/slic3r/GUI/DockPanel.cpp create mode 100644 src/slic3r/GUI/DockPanel.hpp rename src/slic3r/GUI/{PluginWebDialog.cpp => WebDialog.cpp} (53%) rename src/slic3r/GUI/{PluginWebDialog.hpp => WebDialog.hpp} (74%) create mode 100644 src/slic3r/GUI/WebPanel.cpp create mode 100644 src/slic3r/GUI/WebPanel.hpp create mode 100644 src/slic3r/GUI/Widgets/WebHosting.cpp create mode 100644 src/slic3r/GUI/Widgets/WebHosting.hpp diff --git a/resources/web/dialog/PluginWebDialog/blank.html b/resources/web/dialog/WebDialog/blank.html similarity index 76% rename from resources/web/dialog/PluginWebDialog/blank.html rename to resources/web/dialog/WebDialog/blank.html index 5047c5ba7b..928d754fab 100644 --- a/resources/web/dialog/PluginWebDialog/blank.html +++ b/resources/web/dialog/WebDialog/blank.html @@ -1,5 +1,5 @@ - diff --git a/sandboxes/orca_dock_panel_plugin_any.py b/sandboxes/orca_dock_panel_plugin_any.py new file mode 100644 index 0000000000..514f6c3068 --- /dev/null +++ b/sandboxes/orca_dock_panel_plugin_any.py @@ -0,0 +1,146 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Dock Panel Demo" +# description = "Opens a dockable panel beside the 3D view that lists the objects on the plate." +# author = "OrcaSlicer" +# version = "0.0.1" +# /// +"""Dock Panel Demo -- orca.host.ui.create_dock_panel(). + +Run it from the Plugins dialog. It opens an HTML panel docked on the right of the 3D view, in the +same dock area as the sidebar. Drag its caption to dock it on another side (or float it, where the +platform allows), hide it from the page and run the plugin again to bring it back, or close it with +its close button or from the page. + + page --orca.postMessage({command: 'refresh'})--> plugin.on_message() + page --orca.postMessage({command: 'hide'})--> plugin.on_message() -> panel.hide() + page --orca.close()--> panel closes, plugin.on_close() + plugin --panel.post({command: 'objects', ...})--> page (orca.onMessage) +""" +import orca + +PAGE = """ + + +

Objects on the plate

+

Docked beside the 3D view. Drag the caption to move it.

+ +
+ + + +
+ + + + +
NamePartsCopies
+

Waiting for the plugin...

+ + +""" + + +def plate_objects(): + try: + model = orca.host.model() + except RuntimeError as error: + return {"command": "objects", "error": str(error)} + return { + "command": "objects", + "objects": [ + {"name": obj.name or "(unnamed)", "volumes": obj.volume_count(), "instances": obj.instance_count()} + for obj in model.objects() + ], + } + + +class DockPanelDemo(orca.script.ScriptPluginCapabilityBase): + panel = None + + def get_name(self): + return "Dock Panel Demo" + + def execute(self): + # The capability instance lives as long as the plugin, so a second run finds the open panel. + if self.panel is not None and self.panel.is_open(): + self.panel.show() + return orca.ExecutionResult.success("Dock Panel Demo is already open.") + self.panel = orca.host.ui.create_dock_panel( + html=PAGE, + title="Dock Panel Demo", + width=320, + height=480, + on_message=self.on_message, + on_close=self.on_close, + dock="right", + ) + return orca.ExecutionResult.success("Dock Panel Demo opened.") + + # Called on the UI thread when the page posts. + def on_message(self, message): + command = (message or {}).get("command") + if command == "refresh": + self.panel.post(plate_objects()) + elif command == "hide": + self.panel.hide() + + def on_close(self): + self.panel = None + + +@orca.plugin +class DockPanelDemoPlugin(orca.base): + def register_capabilities(self): + orca.register_capability(DockPanelDemo) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index d925ca6333..7ac307c87b 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -139,8 +139,16 @@ set(SLIC3R_GUI_SOURCES GUI/TerminalDialog.hpp GUI/PluginProgressDialog.cpp GUI/PluginProgressDialog.hpp - GUI/PluginWebDialog.cpp - GUI/PluginWebDialog.hpp + GUI/WebDialog.cpp + GUI/WebDialog.hpp + GUI/DockPanel.cpp + GUI/DockPanel.hpp + GUI/WebPanel.cpp + GUI/WebPanel.hpp + GUI/Widgets/WebHosting.cpp + GUI/Widgets/WebHosting.hpp + GUI/AuiPaneLayout.cpp + GUI/AuiPaneLayout.hpp GUI/DragCanvas.cpp GUI/DragCanvas.hpp GUI/EditGCodeDialog.cpp diff --git a/src/slic3r/GUI/AuiPaneLayout.cpp b/src/slic3r/GUI/AuiPaneLayout.cpp new file mode 100644 index 0000000000..dc93352971 --- /dev/null +++ b/src/slic3r/GUI/AuiPaneLayout.cpp @@ -0,0 +1,20 @@ +#include "AuiPaneLayout.hpp" + +namespace Slic3r { namespace GUI { + +std::string aui_pane_layout_entry(const std::string& layout, const std::string& pane_name) +{ + // Panes are separated by '|'; SavePerspective() escapes a '|' inside a caption as "\|". + const std::string prefix = "name=" + pane_name + ";"; + size_t begin = 0; + for (size_t i = 0; i <= layout.size(); ++i) { + if (i < layout.size() && (layout[i] != '|' || (i > 0 && layout[i - 1] == '\\'))) + continue; + if (layout.compare(begin, prefix.size(), prefix) == 0) + return layout.substr(begin, i - begin); + begin = i + 1; + } + return {}; +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AuiPaneLayout.hpp b/src/slic3r/GUI/AuiPaneLayout.hpp new file mode 100644 index 0000000000..e18b5ff6e1 --- /dev/null +++ b/src/slic3r/GUI/AuiPaneLayout.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Slic3r { namespace GUI { + +// The part a wxAuiManager layout string (wxAuiManager::SavePerspective) holds for `pane_name`, in the +// form wxAuiManager::LoadPaneInfo() takes, or empty when the layout has no such pane. +std::string aui_pane_layout_entry(const std::string& layout, const std::string& pane_name); + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/DockPanel.cpp b/src/slic3r/GUI/DockPanel.cpp new file mode 100644 index 0000000000..0a5d25c4e7 --- /dev/null +++ b/src/slic3r/GUI/DockPanel.cpp @@ -0,0 +1,103 @@ +#include "DockPanel.hpp" + +#include "GUI_App.hpp" +#include "Plater.hpp" +#include "Widgets/WebHosting.hpp" + +#include + +#include +#include + +namespace Slic3r { namespace GUI { + +std::string plugin_pane_name(const std::string& plugin_key, const std::string& title) +{ + std::string name = "plugin:" + plugin_key + ":" + title; + std::replace_if(name.begin(), name.end(), [](char c) { return c == '|' || c == ';' || c == '=' || c == '\\'; }, '_'); + return name; +} + +DockPanel::DockPanel(wxWindow* parent, + const std::string& html, + MessageHandler on_message, + CloseHandler on_close, + CloseHandler on_destroyed) + : WebPanel(parent, web_hosting::orca_bridge_script()) + , m_html(html) + , m_on_message(std::move(on_message)) + , m_on_close(std::move(on_close)) + , m_on_destroyed(std::move(on_destroyed)) +{ + // A link asking for a new window has nowhere to open from a docked panel. + browser()->Bind(wxEVT_WEBVIEW_NEWWINDOW, [](wxWebViewEvent& event) { event.Veto(); }); +} + +DockPanel::~DockPanel() +{ + if (m_on_destroyed) + m_on_destroyed(); +} + +bool DockPanel::on_page_message(const std::string& kind, const nlohmann::json& data) +{ + if (kind == "message") { + if (m_on_message) + m_on_message(data); + return true; + } + if (kind == "close") { + request_close(); + return true; + } + return false; +} + +void DockPanel::push_message(const nlohmann::json& data) +{ + if (!m_closing) + post_to_page(data.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)); +} + +void DockPanel::fire_close() +{ + if (m_closing) + return; + m_closing = true; + if (m_on_close) { + CloseHandler on_close = std::move(m_on_close); + m_on_close = nullptr; + on_close(); + } +} + +void DockPanel::request_close() +{ + if (m_closing) + return; + fire_close(); + // A page-requested close arrives inside the web view's script callback, so destroy later; another + // close path may have destroyed the panel by then. + wxWeakRef self(this); + CallAfter([self]() { + if (self) + self->remove_pane(); + }); +} + +void DockPanel::destroy_silently() +{ + m_closing = true; + m_on_close = nullptr; + remove_pane(); +} + +void DockPanel::remove_pane() +{ + if (Plater* plater = wxGetApp().plater()) + plater->remove_dock_pane(this); + else + Destroy(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/DockPanel.hpp b/src/slic3r/GUI/DockPanel.hpp new file mode 100644 index 0000000000..a9a0a0d6b3 --- /dev/null +++ b/src/slic3r/GUI/DockPanel.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "WebPanel.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +// Stable across sessions so the saved layout finds the pane; free of wxAuiManager layout delimiters. +std::string plugin_pane_name(const std::string& plugin_key, const std::string& title); + +// A WebPanel docked in the Plater, on the plugin-window bridge minus submit. It can be destroyed +// without the GIL, so its hooks must not capture pybind11 objects. +class DockPanel : public WebPanel +{ +public: + using MessageHandler = std::function; + using CloseHandler = std::function; + + // on_close fires once, on a user or page close. on_destroyed runs on every destruction and must + // touch host-side state only. + DockPanel(wxWindow* parent, + const std::string& html, + MessageHandler on_message, + CloseHandler on_close, + CloseHandler on_destroyed); + ~DockPanel() override; + + // Main thread only. + void push_message(const nlohmann::json& data); + // Fires on_close, then removes the pane. + void request_close(); + // Removes the pane without on_close, for plugin unload. Destroys at once: unload always comes from + // the host, never from this panel's own callbacks. + void destroy_silently(); + // Fires on_close at most once; also run by the pane's own close button. + void fire_close(); + +protected: + std::optional page_html() override { return m_html; } + bool on_page_message(const std::string& kind, const nlohmann::json& data) override; + +private: + void remove_pane(); + + std::string m_html; + bool m_closing{false}; + MessageHandler m_on_message; + CloseHandler m_on_close; + CloseHandler m_on_destroyed; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index ec82786432..5ac664f41c 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -1189,6 +1189,8 @@ void MainFrame::shutdown() if (m_project != nullptr) m_project->shutdown(); m_plugin_pages.shutdown(); + if (m_plater != nullptr) + m_plater->remove_dock_panes(); #ifdef __WXGTK__ // Edge panels are child windows — wxWidgets destroys them automatically. m_edge_bottom = nullptr; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 6258cf19af..76156011dd 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -87,6 +87,7 @@ #ifdef __WXGTK__ #include "LinuxDisplayBackend.hpp" #endif +#include "AuiPaneLayout.hpp" #include "GUI_Utils.hpp" #include "GUI_Factories.hpp" #include "wxExtensions.hpp" @@ -6748,6 +6749,14 @@ struct Plater::priv // GUI elements AuiMgr m_aui_mgr; + // Live dock panes. `on_close` runs when the user closes one from its close button; `shown` is + // what the owner asked for. + struct DockPane + { + std::function on_close; + bool shown{true}; + }; + std::map m_dock_panes; wxString m_default_window_layout; wxPanel* current_panel{ nullptr }; std::vector panels; @@ -6920,6 +6929,11 @@ struct Plater::priv void update_sidebar(bool force_update = false); void reset_window_layout(); Sidebar::DockingState get_sidebar_docking_state(); + void add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock, + const wxSize& size, std::function on_close); + void remove_dock_pane(wxWindow* window); + void show_dock_pane(wxWindow* window, bool show); + bool dock_pane_visible(const DockPane& dock_pane, const wxAuiPaneInfo& pane) const; bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); } @@ -7500,6 +7514,18 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) panel_3d->SetSizer(panel_sizer); m_aui_mgr.AddPane(panel_3d, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false)); + q->Bind(wxEVT_AUI_PANE_CLOSE, [this](wxAuiManagerEvent& evt) { + const wxAuiPaneInfo* pane = evt.GetPane(); + auto it = pane != nullptr ? m_dock_panes.find(pane->window) : m_dock_panes.end(); + if (it != m_dock_panes.end()) { + const std::function on_close = std::move(it->second.on_close); + m_dock_panes.erase(it); + if (on_close) + on_close(); + } + evt.Skip(); + }); + m_default_window_layout = m_aui_mgr.SavePerspective(); { @@ -8165,6 +8191,14 @@ void Plater::priv::update_sidebar(bool force_update) { } } + for (const auto& [window, dock_pane] : m_dock_panes) { + wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); + if (pane.IsOk() && pane.IsShown() != dock_pane_visible(dock_pane, pane)) { + pane.Show(!pane.IsShown()); + needs_update = true; + } + } + if (needs_update) { notification_manager->set_sidebar_collapsed(sidebar.IsShown()); m_aui_mgr.Update(); @@ -8174,10 +8208,96 @@ void Plater::priv::update_sidebar(bool force_update) { void Plater::priv::reset_window_layout() { m_aui_mgr.LoadPerspective(m_default_window_layout, false); + // Loading a layout docks and hides every pane it does not list, and the default layout lists no + // dock panes: a floating dock pane is docked again, like the rest of the window. + for (const auto& [window, dock_pane] : m_dock_panes) + if (wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); pane.IsOk()) + pane.Show(dock_pane_visible(dock_pane, pane)); sidebar_layout.is_collapsed = false; update_sidebar(true); } +bool Plater::priv::dock_pane_visible(const DockPane& dock_pane, const wxAuiPaneInfo& pane) const +{ + // A floating pane is a top-level window, so it does not hide with the Plater on other tabs. + return dock_pane.shown && (!pane.IsFloating() || sidebar_layout.show); +} + +void Plater::priv::add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock, + const wxSize& size, std::function on_close) +{ + const wxString base_name = wxString::FromUTF8(name); + wxString unique_name = base_name; + for (int i = 2; m_aui_mgr.GetPane(unique_name).IsOk(); ++i) + unique_name = base_name + wxString::Format("#%d", i); + + // A restored layout below already holds pixels. + const wxSize pixels = q->FromDIP(size); + wxAuiPaneInfo info; + info.Name(unique_name).Caption(caption).BestSize(pixels).FloatingSize(pixels).DestroyOnClose(true); + if (dock == "left") + info.Left(); + else if (dock == "bottom") + info.Bottom(); + else + info.Right(); + if (dock == "float") + info.Float(); + + // Put the pane back where it was the last time the window layout was saved with it open. + const std::string saved = aui_pane_layout_entry(wxGetApp().app_config->get("window_layout"), unique_name.utf8_string()); + if (!saved.empty()) { + m_aui_mgr.LoadPaneInfo(wxString::FromUTF8(saved), info); + info.Caption(caption).DestroyOnClose(true).Show(); + } + + // Floating is disabled on Wayland. + if ((m_aui_mgr.GetFlags() & wxAUI_MGR_ALLOW_FLOATING) == 0) { + info.Dock().Floatable(false); + if (info.dock_direction == wxAUI_DOCK_NONE) + info.Right(); + } + + const DockPane& dock_pane = m_dock_panes[window] = DockPane{std::move(on_close)}; + info.Show(dock_pane_visible(dock_pane, info)); + m_aui_mgr.AddPane(window, info); + + // wxAUI does not record a dragged sash in best_size, so track the docked size like the sidebar + // does, for the saved layout. + window->Bind(wxEVT_IDLE, [this, window](wxIdleEvent& evt) { + wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); + if (pane.IsOk() && pane.IsShown() && pane.IsDocked() && pane.rect.GetWidth() > 0 && pane.rect.GetHeight() > 0) { + const bool horizontal = pane.dock_direction == wxAUI_DOCK_TOP || pane.dock_direction == wxAUI_DOCK_BOTTOM; + pane.BestSize(horizontal ? pane.best_size.GetWidth() : pane.rect.GetWidth(), + horizontal ? pane.rect.GetHeight() : pane.best_size.GetHeight()); + } + evt.Skip(); + }); + + m_aui_mgr.Update(); +} + +void Plater::priv::remove_dock_pane(wxWindow* window) +{ + m_dock_panes.erase(window); + if (m_aui_mgr.DetachPane(window)) + m_aui_mgr.Update(); + window->Destroy(); +} + +void Plater::priv::show_dock_pane(wxWindow* window, bool show) +{ + const auto it = m_dock_panes.find(window); + wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); + if (it == m_dock_panes.end() || !pane.IsOk()) + return; + it->second.shown = show; + if (pane.IsShown() == dock_pane_visible(it->second, pane)) + return; + pane.Show(!pane.IsShown()); + m_aui_mgr.Update(); +} + Sidebar::DockingState Plater::priv::get_sidebar_docking_state() { if (!sidebar_layout.is_enabled) { return Sidebar::None; @@ -17772,6 +17892,19 @@ Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_ void Plater::reset_window_layout() { p->reset_window_layout(); } +void Plater::add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock, + const wxSize& size, std::function on_close) +{ + p->add_dock_pane(window, name, caption, dock, size, std::move(on_close)); +} +void Plater::remove_dock_pane(wxWindow* window) { p->remove_dock_pane(window); } +void Plater::remove_dock_panes() +{ + while (!p->m_dock_panes.empty()) + p->remove_dock_pane(p->m_dock_panes.begin()->first); +} +void Plater::show_dock_pane(wxWindow* window, bool show) { p->show_dock_pane(window, show); } + //BBS void Plater::select_curr_plate_all() { p->select_curr_plate_all(); } void Plater::remove_curr_plate_all() { p->remove_curr_plate_all(); } diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index ae70a6a0bb..b30f0298ee 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -476,6 +476,17 @@ public: void reset_window_layout(); + // Dock panes sit alongside the sidebar; `window` must be a child of the Plater. `dock` is + // "left", "right", "bottom" or "float", and `size` is in DIPs. A pane closed from its own close + // button is destroyed after on_close runs; remove_dock_pane() destroys it without calling on_close. + void add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock, + const wxSize& size, std::function on_close); + void remove_dock_pane(wxWindow* window); + void show_dock_pane(wxWindow* window, bool show); + // Removes every dock pane without calling on_close, for MainFrame::shutdown() (app exit and a + // language switch), while the Plater and any floating frames still exist. + void remove_dock_panes(); + // Called after the Preferences dialog is closed and the program settings are saved. // Update the UI based on the current preferences. void update_ui_from_settings(); diff --git a/src/slic3r/GUI/PluginWebDialog.cpp b/src/slic3r/GUI/WebDialog.cpp similarity index 53% rename from src/slic3r/GUI/PluginWebDialog.cpp rename to src/slic3r/GUI/WebDialog.cpp index 7d696dd8db..8bcb517d87 100644 --- a/src/slic3r/GUI/PluginWebDialog.cpp +++ b/src/slic3r/GUI/WebDialog.cpp @@ -1,80 +1,23 @@ -#include "PluginWebDialog.hpp" +#include "WebDialog.hpp" -#include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" - -#include - -#include +#include "slic3r/GUI/Widgets/WebHosting.hpp" #include -#include #include namespace Slic3r { namespace GUI { -namespace { - -// Injected into the top-level page at document start (before the plugin's own -// scripts). Defines window.orca as the only host surface the page may use. It -// references window.wx lazily (at call time) so it never races the backend's -// deferred registration of the "wx" message handler. Guarded against -// double-injection so it is harmless if also prepended. -constexpr char ORCA_BRIDGE_JS[] = R"JS( -(function () { - if (window.top !== window.self) return; - if (window.orca) return; - var handlers = []; - function send(kind, data) { - try { - window.wx.postMessage(JSON.stringify({ - channel: 'orca', kind: kind, data: (data === undefined ? null : data) - })); - } catch (e) { /* bridge not ready yet */ } - } - window.orca = { - postMessage: function (d) { send('message', d); }, - submit: function (d) { send('submit', d); }, - close: function () { send('close'); }, - onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); } - }; - window.__orcaDispatch = function (payload) { - var data = payload ? payload.data : null; - for (var i = 0; i < handlers.length; i++) { - try { handlers[i](data); } catch (e) {} - } - }; -})(); -)JS"; - -// file:// base URL for plugin HTML loaded via SetPage, so self-referencing -// relative URLs resolve against the bundled web resources directory. -wxString web_base_url() -{ - const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string(); - return wxString("file://") + from_u8(dir) + "/"; -} - -// Whether a loaded document is the plugin HTML's own base URL. The web view reports the URL it -// parsed, so any fragment the page navigated to is ignored and the escaping it applies to what the -// resources path holds (a space, a non-ASCII character) is undone first. -bool is_content_url(const wxString& url) -{ - return wxURI::Unescape(url.BeforeFirst('#')) == web_base_url(); -} - -} // namespace - -PluginWebDialog::PluginWebDialog(wxWindow* parent, - const wxString& title, - const std::string& html, - const wxSize& size, - MessageHandler on_message, - SubmitHandler on_submit, - CloseHandler on_close, - CloseHandler on_destroyed, - long wx_style) +WebDialog::WebDialog(wxWindow* parent, + const wxString& title, + const std::string& html, + const wxSize& size, + MessageHandler on_message, + SubmitHandler on_submit, + CloseHandler on_close, + CloseHandler on_destroyed, + long wx_style) : WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style) , m_html(html) , m_on_message(std::move(on_message)) @@ -84,7 +27,7 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent, { // A tiny bundled bootstrap page brings the webview up; the real plugin HTML // is swapped in via SetPage once the bootstrap finishes loading. - create_webview("web/dialog/PluginWebDialog/blank.html", title, size, wxSize(320, 240)); + create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240)); // Paint the window/webview in the themed background so there is no white // flash before the (transparent) bootstrap page and plugin HTML render. @@ -96,22 +39,22 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent, // create_webview() via add_user_scripts(); nothing to add here. // Swap in the plugin HTML once the bootstrap page settles. Bind ERROR too so a // missing/blocked bootstrap resource (e.g. a packaged build) still triggers it. - Bind(wxEVT_WEBVIEW_LOADED, &PluginWebDialog::on_bootstrap_event, this, wv->GetId()); - Bind(wxEVT_WEBVIEW_ERROR, &PluginWebDialog::on_bootstrap_event, this, wv->GetId()); - Bind(wxEVT_WEBVIEW_NAVIGATED, &PluginWebDialog::on_navigated, this, wv->GetId()); + Bind(wxEVT_WEBVIEW_LOADED, &WebDialog::on_bootstrap_event, this, wv->GetId()); + Bind(wxEVT_WEBVIEW_ERROR, &WebDialog::on_bootstrap_event, this, wv->GetId()); + Bind(wxEVT_WEBVIEW_NAVIGATED, &WebDialog::on_navigated, this, wv->GetId()); } - Bind(wxEVT_CLOSE_WINDOW, &PluginWebDialog::on_close_window, this); + Bind(wxEVT_CLOSE_WINDOW, &WebDialog::on_close_window, this); } -void PluginWebDialog::add_user_scripts() +void WebDialog::add_user_scripts() { if (wxWebView* wv = browser()) { - wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script())); - wv->AddUserScript(ORCA_BRIDGE_JS); + wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script())); + wv->AddUserScript(wxString::FromUTF8(web_hosting::orca_bridge_script())); } } -PluginWebDialog::~PluginWebDialog() +WebDialog::~WebDialog() { // Runs on every destruction path. Deliberately NOT a wxEVT_DESTROY handler: // that event is sent from the base ~wxDialog(), after this subclass's members @@ -121,19 +64,19 @@ PluginWebDialog::~PluginWebDialog() m_on_destroyed(); } -void PluginWebDialog::post_message(PluginWebDialog* dialog, const nlohmann::json& data) +void WebDialog::post_message(WebDialog* dialog, const nlohmann::json& data) { if (dialog != nullptr && dialog->is_open()) dialog->push_message(data); } -void PluginWebDialog::request_close(PluginWebDialog* dialog) +void WebDialog::request_close(WebDialog* dialog) { if (dialog != nullptr) dialog->Close(); } -void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog) +void WebDialog::destroy_silently(WebDialog* dialog) { if (dialog == nullptr) return; @@ -147,42 +90,42 @@ void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog) dialog->Destroy(); } -void PluginWebDialog::on_bootstrap_event(wxWebViewEvent& event) +void WebDialog::on_bootstrap_event(wxWebViewEvent& event) { const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED; // The first bootstrap load (or its error) triggers the swap to plugin HTML. if (!m_content_loaded) - load_plugin_content(); + load_page_html(); // WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a reload. // A failed navigation is reported against the page that stayed but never commits. Edge ignores the // base URL and restores SetPage content itself, so nothing matches there. - else if (is_content_url(event.GetURL())) { + else if (web_hosting::is_content_url(event.GetURL())) { if (m_own_page_load) m_own_page_load = false; else if (loaded && m_content_navigated) - load_plugin_content(); + load_page_html(); } if (loaded) m_content_navigated = false; event.Skip(); } -void PluginWebDialog::on_navigated(wxWebViewEvent& event) +void WebDialog::on_navigated(wxWebViewEvent& event) { - m_content_navigated = is_content_url(event.GetURL()); + m_content_navigated = web_hosting::is_content_url(event.GetURL()); event.Skip(); } -void PluginWebDialog::load_plugin_content() +void WebDialog::load_page_html() { m_content_loaded = true; if (wxWebView* wv = browser()) { m_own_page_load = true; - wv->SetPage(wxString::FromUTF8(m_html), web_base_url()); + wv->SetPage(wxString::FromUTF8(m_html), web_hosting::content_base_url()); } } -void PluginWebDialog::on_script_message(const nlohmann::json& payload) +void WebDialog::on_script_message(const nlohmann::json& payload) { if (payload.value("channel", std::string()) == "orca") { const std::string kind = payload.value("kind", std::string()); @@ -202,7 +145,7 @@ void PluginWebDialog::on_script_message(const nlohmann::json& payload) handle_common_script_command(payload); } -void PluginWebDialog::push_message(const nlohmann::json& data) +void WebDialog::push_message(const nlohmann::json& data) { if (!m_open) return; @@ -211,7 +154,7 @@ void PluginWebDialog::push_message(const nlohmann::json& data) call_web_handler(envelope, wxT("__orcaDispatch")); } -void PluginWebDialog::finish(bool submitted, const nlohmann::json& data) +void WebDialog::finish(bool submitted, const nlohmann::json& data) { if (!m_open) return; @@ -230,7 +173,7 @@ void PluginWebDialog::finish(bool submitted, const nlohmann::json& data) Close(); } -void PluginWebDialog::on_close_window(wxCloseEvent&) +void WebDialog::on_close_window(wxCloseEvent&) { if (!m_open) { // finish() already dispatched submit/close and requested the close. @@ -250,7 +193,7 @@ void PluginWebDialog::on_close_window(wxCloseEvent&) Destroy(); } -void PluginWebDialog::fire_submit(const nlohmann::json& data) +void WebDialog::fire_submit(const nlohmann::json& data) { if (m_on_submit) { SubmitHandler cb = std::move(m_on_submit); @@ -258,7 +201,7 @@ void PluginWebDialog::fire_submit(const nlohmann::json& data) } } -void PluginWebDialog::fire_close() +void WebDialog::fire_close() { if (m_close_fired) return; diff --git a/src/slic3r/GUI/PluginWebDialog.hpp b/src/slic3r/GUI/WebDialog.hpp similarity index 74% rename from src/slic3r/GUI/PluginWebDialog.hpp rename to src/slic3r/GUI/WebDialog.hpp index a62e0b260c..aec5de18ef 100644 --- a/src/slic3r/GUI/PluginWebDialog.hpp +++ b/src/slic3r/GUI/WebDialog.hpp @@ -1,5 +1,5 @@ -#ifndef slic3r_GUI_PluginWebDialog_hpp_ -#define slic3r_GUI_PluginWebDialog_hpp_ +#ifndef slic3r_GUI_WebDialog_hpp_ +#define slic3r_GUI_WebDialog_hpp_ #include "Widgets/WebViewHostDialog.hpp" @@ -21,7 +21,7 @@ namespace Slic3r { namespace GUI { // GIL held; the plugin layer wraps any Python callables in a GIL-safe holder. // // Usable both modally (ShowModal -> read result()) and modelessly (Show()). -class PluginWebDialog : public Slic3r::GUI::WebViewHostDialog +class WebDialog : public Slic3r::GUI::WebViewHostDialog { public: using MessageHandler = std::function; @@ -32,20 +32,20 @@ public: // user/JS-initiated close (while the window is alive). on_destroyed runs from // the destructor on every path and must touch host-side state only (no Python // / no derived members). - PluginWebDialog(wxWindow* parent, - const wxString& title, - const std::string& html, - const wxSize& size, - MessageHandler on_message, - SubmitHandler on_submit, - CloseHandler on_close, - CloseHandler on_destroyed, - long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER); - ~PluginWebDialog() override; + WebDialog(wxWindow* parent, + const wxString& title, + const std::string& html, + const wxSize& size, + MessageHandler on_message, + SubmitHandler on_submit, + CloseHandler on_close, + CloseHandler on_destroyed, + long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER); + ~WebDialog() override; - static void post_message(PluginWebDialog* dialog, const nlohmann::json& data); - static void request_close(PluginWebDialog* dialog); - static void destroy_for_plugin(PluginWebDialog* dialog); + static void post_message(WebDialog* dialog, const nlohmann::json& data); + static void request_close(WebDialog* dialog); + static void destroy_silently(WebDialog* dialog); // Push a payload to the page; delivered to handlers registered via // window.orca.onMessage(). MAIN-THREAD ONLY (the plugin layer marshals). @@ -65,7 +65,7 @@ protected: private: void on_bootstrap_event(wxWebViewEvent& event); void on_navigated(wxWebViewEvent& event); - void load_plugin_content(); + void load_page_html(); void on_close_window(wxCloseEvent& event); void fire_submit(const nlohmann::json& data); void fire_close(); @@ -86,4 +86,4 @@ private: }} // namespace Slic3r::GUI -#endif // slic3r_GUI_PluginWebDialog_hpp_ +#endif // slic3r_GUI_WebDialog_hpp_ diff --git a/src/slic3r/GUI/WebPanel.cpp b/src/slic3r/GUI/WebPanel.cpp new file mode 100644 index 0000000000..a8cef9d941 --- /dev/null +++ b/src/slic3r/GUI/WebPanel.cpp @@ -0,0 +1,111 @@ +#include "WebPanel.hpp" + +#include "GUI_App.hpp" +#include "Widgets/WebHosting.hpp" +#include "Widgets/WebView.hpp" +#include "Widgets/WebViewHostDialog.hpp" + +#include + +#include + +namespace Slic3r { namespace GUI { + +WebPanel::WebPanel(wxWindow* parent, const char* bridge_script) + : wxPanel(parent, wxID_ANY) +{ + SetBackgroundColour(wxGetApp().get_window_default_clr()); + auto* sizer = new wxBoxSizer(wxVERTICAL); + SetSizer(sizer); + + // Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available. + m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url()); + m_browser->SetBackgroundColour(GetBackgroundColour()); + m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script())); + m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script())); + m_browser->AddUserScript(wxString::FromUTF8(bridge_script)); + m_browser->Bind(wxEVT_WEBVIEW_LOADED, &WebPanel::on_load_event, this); + m_browser->Bind(wxEVT_WEBVIEW_ERROR, &WebPanel::on_load_event, this); + m_browser->Bind(wxEVT_WEBVIEW_NAVIGATED, &WebPanel::on_navigated, this); + m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebPanel::on_script_message, this); + m_browser->Bind(EVT_WEBVIEW_RECREATED, &WebPanel::on_webview_recreated, this); + sizer->Add(m_browser, 1, wxEXPAND); +} + +void WebPanel::on_load_event(wxWebViewEvent& event) +{ + const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED; + if (!m_content_loaded) { + // The first bootstrap load (or its error) triggers the swap to the plugin HTML. + m_content_loaded = true; + load_page_html(); + } else if (!web_hosting::is_content_url(event.GetURL())) { + // Not our document (a linked page, a substituted error page), or any document on Edge, which ignores + // the base URL and restores SetPage content on a reload itself; either way it takes the app theme. + if (loaded) + apply_theme(); + } else if (m_own_page_load) { + m_own_page_load = false; + // The document-start theme script is fixed at creation, so re-apply the app theme. + if (loaded) + apply_theme(); + } else if (loaded && m_content_navigated) { + // WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a + // reload. A failed navigation is reported against the page that stayed but never commits. + load_page_html(); + } + if (loaded) + m_content_navigated = false; + event.Skip(); +} + +void WebPanel::on_navigated(wxWebViewEvent& event) +{ + m_content_navigated = web_hosting::is_content_url(event.GetURL()); + event.Skip(); +} + +void WebPanel::load_page_html() +{ + if (const std::optional html = page_html()) { + m_own_page_load = true; + m_browser->SetPage(wxString::FromUTF8(*html), web_hosting::content_base_url()); + } +} + +void WebPanel::on_script_message(wxWebViewEvent& event) +{ + const nlohmann::json payload = nlohmann::json::parse(event.GetString().utf8_string(), nullptr, false); + if (!payload.is_object() || payload.value("channel", std::string()) != "orca") + return; + + const std::string kind = payload.value("kind", std::string()); + if (!on_page_message(kind, payload.contains("data") ? payload["data"] : nlohmann::json())) + BOOST_LOG_TRIVIAL(warning) << "WebPanel ignored a window.orca '" << kind << "' call; this host does not support it"; +} + +void WebPanel::on_webview_recreated(wxCommandEvent&) +{ + SetBackgroundColour(wxGetApp().get_window_default_clr()); + m_browser->SetBackgroundColour(GetBackgroundColour()); + Refresh(); + // Handled without Skip(), so WebView::RecreateAll() does not reload the plugin page. + apply_theme(); +} + +void WebPanel::apply_theme() +{ + WebView::RunScript(m_browser, wxString::FromUTF8(WebViewHostDialog::theme_apply_script())); +} + +void WebPanel::post_to_page(const std::string& json) +{ + WebView::RunScript(m_browser, wxString::Format( + "(function dispatch(payload, attempts) {\n" + " if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n" + " if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n" + "})({data: %s}, 0);", + wxString::FromUTF8(json))); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebPanel.hpp b/src/slic3r/GUI/WebPanel.hpp new file mode 100644 index 0000000000..d4c493dbc1 --- /dev/null +++ b/src/slic3r/GUI/WebPanel.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include + +#include +#include + +#include +#include + +namespace Slic3r { namespace GUI { + +// Host-supplied HTML in a web view panel, for any window that embeds or derives from it (today plugin +// Pages tabs and docked panels): bootstrap page and swap, theme and bridge scripts, live re-theming, and +// window.orca messages routed to on_page_message(). +class WebPanel : public wxPanel +{ +public: + WebPanel(wxWindow* parent, const char* bridge_script); + +protected: + wxWebView* browser() const { return m_browser; } + + // Delivers an already serialised JSON value to the page's window.orca.onMessage handlers, + // waiting briefly for the bridge while the page is still loading. Main thread only. + void post_to_page(const std::string& json); + + // The plugin HTML to show once the bootstrap page has loaded, and again when WebKit reloads it; + // std::nullopt leaves it blank. + virtual std::optional page_html() = 0; + // A window.orca message from the page; false for a kind this host does not handle (logged). + virtual bool on_page_message(const std::string& kind, const nlohmann::json& data) = 0; + +private: + void on_load_event(wxWebViewEvent& event); + void on_navigated(wxWebViewEvent& event); + void on_script_message(wxWebViewEvent& event); + void on_webview_recreated(wxCommandEvent& event); + void apply_theme(); + void load_page_html(); + + wxWebView* m_browser{nullptr}; + bool m_content_loaded{false}; + bool m_own_page_load{false}; // a SetPage of the plugin HTML is in flight + bool m_content_navigated{false}; // a navigation to the base URL has committed +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Widgets/WebHosting.cpp b/src/slic3r/GUI/Widgets/WebHosting.cpp new file mode 100644 index 0000000000..a1b0df496a --- /dev/null +++ b/src/slic3r/GUI/Widgets/WebHosting.cpp @@ -0,0 +1,69 @@ +#include "WebHosting.hpp" + +#include "slic3r/GUI/GUI.hpp" + +#include + +#include + +#include + +namespace Slic3r { namespace GUI { namespace web_hosting { + +namespace { + +// Injected into the top-level page at document start (before the plugin's own +// scripts). Defines window.orca as the only host surface the page may use. It +// references window.wx lazily (at call time) so it never races the backend's +// deferred registration of the "wx" message handler. Guarded against +// double-injection so it is harmless if also prepended. +constexpr char ORCA_BRIDGE_JS[] = R"JS( +(function () { + if (window.top !== window.self) return; + if (window.orca) return; + var handlers = []; + function send(kind, data) { + try { + window.wx.postMessage(JSON.stringify({ + channel: 'orca', kind: kind, data: (data === undefined ? null : data) + })); + } catch (e) { /* bridge not ready yet */ } + } + window.orca = { + postMessage: function (d) { send('message', d); }, + submit: function (d) { send('submit', d); }, + close: function () { send('close'); }, + onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); } + }; + window.__orcaDispatch = function (payload) { + var data = payload ? payload.data : null; + for (var i = 0; i < handlers.length; i++) { + try { handlers[i](data); } catch (e) {} + } + }; +})(); +)JS"; + +} // namespace + +wxString bootstrap_url() +{ + return wxString("file://") + from_u8((boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE).make_preferred().string()); +} + +wxString content_base_url() +{ + const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string(); + return wxString("file://") + from_u8(dir) + "/"; +} + +bool is_content_url(const wxString& url) +{ + // The web view reports the URL it parsed, which escapes anything the resources path holds + // (a space, a non-ASCII character), while content_base_url() is the raw path. + return wxURI::Unescape(url.BeforeFirst('#')) == content_base_url(); +} + +const char* orca_bridge_script() { return ORCA_BRIDGE_JS; } + +}}} // namespace Slic3r::GUI::web_hosting diff --git a/src/slic3r/GUI/Widgets/WebHosting.hpp b/src/slic3r/GUI/Widgets/WebHosting.hpp new file mode 100644 index 0000000000..5bf0ebc166 --- /dev/null +++ b/src/slic3r/GUI/Widgets/WebHosting.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace Slic3r { namespace GUI { namespace web_hosting { + +// Shared by the hosts that show plugin HTML: WebDialog and WebPanel. + +// The bundled blank page a plugin web view loads before the plugin HTML is swapped in. +constexpr const char* BOOTSTRAP_PAGE = "web/dialog/WebDialog/blank.html"; + +// The file:// URL of BOOTSTRAP_PAGE. +wxString bootstrap_url(); + +// The file:// base URL plugin HTML is loaded against, so relative URLs resolve to bundled resources. +wxString content_base_url(); + +// Whether `url` is the plugin HTML's base URL, ignoring any fragment. WebKit reports it for the +// injected page, a reload and a failed navigation alike, so a match alone is not a new document. +bool is_content_url(const wxString& url); + +// The window.orca bridge of plugin windows and docked panels. Pages tabs ship their own. +const char* orca_bridge_script(); + +}}} // namespace Slic3r::GUI::web_hosting diff --git a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp index 044fe33cde..e4e3d2878a 100644 --- a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp +++ b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp @@ -75,6 +75,8 @@ if(document.documentElement) } // namespace +std::string WebViewHostDialog::theme_apply_script() { return host_theme_apply_js(); } + // Document-start user script: injects the contract