diff --git a/sandboxes/orca_preset_update_demo.py b/sandboxes/orca_preset_update_demo.py new file mode 100644 index 0000000000..3547ca3008 --- /dev/null +++ b/sandboxes/orca_preset_update_demo.py @@ -0,0 +1,957 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# id = "preset-update-demo" +# name = "Preset Update Demo" +# description = "Edits any setting of the currently edited process, printer, or filament preset through the preset host API." +# author = "OrcaSlicer contributors" +# version = "0.2.0" +# type = "script" +# /// +"""Preset Update Demo — edit any setting of the current preset from a plugin window. + +Install this file in the local ``orca_plugins`` directory, enable the plugin, then run +"Preset Update Demo" from the Plugins dialog. The window lists every setting of the +preset the matching settings tab is editing right now — process, printer, or filament — +with its current serialized value, and writes a value back through +``orca.host.preset..update(...)``. + +Process and printer each have one edited preset, taken from +``current_process_preset()`` / ``current_printer_preset()``. Filament presets are +addressed by slot instead: the window reads one preset per slot from +``current_filament_presets()`` (slot numbers are one-based, matching Orca's labels), +shows the selected slot's preset, and writes through +``orca.host.preset.filament.update(slot, key, value)``. + +Any assigned slot can be edited: the backend selects that slot's preset through Orca's +normal preset selection before it writes, so applying can switch the filament tab and Orca +may ask about unsaved changes first. Only an empty slot, whose mount resolves to no preset, +is unavailable — the window says so instead of offering an edit. A slot whose preset is not +the one the filament settings already have open gets an informational note that applying +will switch to it; it is never presented as a refusal. Two slots that mount the same preset +are one preset, so editing through either of them changes both; the window marks those +slots and says which they are. + +Every row is one ``config_keys()`` entry carrying ``config_value(key)``. + +What ``update()`` does, and what it does not: + + * it runs the same validation and UI update path as editing the setting by hand, so + the change lands in the preset the tab is editing and is never written to disk here + — the preset shows as modified in OrcaSlicer once it differs from the saved one; + * it accepts only settings of that preset category. A key that belongs to another + category, or a value the setting would reject, raises an error and writes nothing; + * it is refused when the selected printer's technology does not use that tab (the + print and filament tabs do nothing under SLA), so this demo covers the three FFF + categories only; + * a setting whose page has not been built yet still runs its owning settings group's + checks and callbacks; behavior that requires an actual widget (such as widget-specific + parsing or clamping) only runs when the field exists; + * on a filament slot it selects that slot's preset first, so Orca may prompt about + unsaved changes in the filament tab before the value is written. + +Values are OrcaSlicer's serialized config syntax — the same text a preset file stores — +so numbers are plain, percentages keep their ``%``, booleans are ``1``/``0``, choices use +the setting's internal key, and lists are semicolon-separated. +""" + +import orca + + +# The three preset categories the main tabs edit. Each key is both the value the page +# sends and the attribute name on orca.host.preset that carries the matching editor. +KINDS = ( + {"kind": "process", "label": "Process"}, + {"kind": "printer", "label": "Printer"}, + {"kind": "filament", "label": "Filament"}, +) +KIND_KEYS = {item["kind"] for item in KINDS} + + +def _editor(kind): + """The host.PresetEditor for a category (orca.host.preset.process / printer). + + Filament is not reached through this helper: it is addressed by slot, so it calls + orca.host.preset.filament.update(slot, key, value) directly. + """ + return getattr(orca.host.preset, kind) + + +def _current_preset(bundle, kind): + """The single preset the matching settings tab edits right now, as a host.Preset. + + Only the process and printer categories have one such preset; filament presets are + addressed by slot instead (see _slot_presets). + """ + if kind == "process": + return bundle.current_process_preset() + return bundle.current_printer_preset() + + +def _slot_presets(bundle): + """The filament preset mounted in each slot, in slot order; a slot may be None.""" + return list(bundle.current_filament_presets()) + + +def _preset_is_open(preset, edited): + """Whether this slot's preset is the one the filament settings already have open. + + A slot with an assigned preset can be edited either way: applying to another slot makes + the backend select that slot's preset first. This only distinguishes the slot that + switches nothing from the ones that will switch the filament tab. + """ + return preset is not None and preset.name == edited.name + + +def _filament_slots(presets, edited): + """One entry per mounted slot: its one-based number, preset name, and open flag. + + The number is the slot label Orca shows; the name is None for a slot that mounts no + preset, which is the only kind that cannot be edited. + """ + return [ + { + "slot": index + 1, + "name": None if preset is None else preset.name, + "open": _preset_is_open(preset, edited), + } + for index, preset in enumerate(presets) + ] + + +def _as_slot(value): + """A requested slot as an int, or None when it is not a whole number.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +def _clamp_slot(slot, count): + """The slot to display: the requested one, kept inside the available range.""" + if count <= 0: + return 0 + number = _as_slot(slot) + if number is None or number < 1: + return 1 + return number if number <= count else count + + +def _parse_slot(slot, count): + """Validate a requested filament slot against the mounted slots (one-based).""" + if count == 0: + raise RuntimeError("No filament slots are mounted") + number = _as_slot(slot) + if number is None: + raise RuntimeError("A filament slot number is required") + if number < 1 or number > count: + raise RuntimeError("Filament slot %d is out of range (1-%d)" % (number, count)) + return number + + +def _rows(preset): + """Every setting of the preset, sorted by key, as [{k, v}]. + + config_value() returns None for a key the preset does not carry; the page shows + those as empty rather than "None". + """ + rows = [] + for key in sorted(preset.config_keys()): + value = preset.config_value(key) + rows.append({"k": key, "v": "" if value is None else str(value)}) + return rows + + +def _state(bundle, kind, slot, message="", error=""): + """The payload the page renders: shown preset, rows, slots, result line.""" + payload = {"command": "state", "kind": kind, "message": message, "error": error} + if kind == "filament": + presets = _slot_presets(bundle) + edited = bundle.filaments.edited_preset() + effective = _clamp_slot(slot, len(presets)) + preset = None if effective == 0 else presets[effective - 1] + payload["slots"] = _filament_slots(presets, edited) + payload["slot"] = effective + # An empty slot has nothing to edit; any assigned slot is editable, and one that is + # not already open makes the backend select it first. + payload["assigned"] = preset is not None + payload["switches"] = preset is not None and not _preset_is_open(preset, edited) + payload["edited_name"] = edited.name + else: + preset = _current_preset(bundle, kind) + payload["preset"] = None if preset is None else { + "name": preset.name, + "is_dirty": bool(preset.is_dirty), + "is_system": bool(preset.is_system), + } + payload["rows"] = [] if preset is None else _rows(preset) + return payload + + +def _apply(kind, key, value, slot=None): + """Set one setting of the preset the page is showing, through the host API. + + A filament preset is addressed by slot number; process and printer each resolve + their single edited preset themselves, so no slot is passed to them. + """ + if kind == "filament": + orca.host.preset.filament.update(slot, key, value) + return + _editor(kind).update(key, value) + + +PAGE = r""" + + + + + + +
+ +

Preset update

+
+ + +
+ +
+ Loading… + + + 0 settings +
+ + + +
+
+ +
+
+ +
+
Select a setting
+
Current value
+
—
+ + +
+
+ + +
+
+ Values use OrcaSlicer's serialized config syntax — the same text a preset + file stores. Numbers are plain (0.2, 3) and percentages keep + their sign (15%). On/off is 1 or 0. A choice uses + the setting's own key, e.g. grid. Lists are semicolon-separated + (0.4;0.4;0.4, or "#FF0000";"#00FF00"). Text keeps its + escapes, so a line break stays \n. A key that does not belong to this + preset, or a value the setting would reject, comes back as an error and writes + nothing. The edit lands in the preset the tab is editing and stays unsaved. +
+
+
+ + + + + +""" + + +class PresetUpdateDemo(orca.script.ScriptPluginCapabilityBase): + window = None + kind = "process" # the category the open window is showing, for a re-run refresh + slot = 1 # the filament slot the open window is showing + + def get_name(self): + return "Preset update demo" + + def execute(self): + try: + if self.window is not None and self.window.is_open(): + self._post_state(self.kind, self.slot) + return orca.ExecutionResult.success("Preset update demo refreshed.") + self.window = orca.host.ui.create_window( + title="Preset update demo", html=PAGE, width=820, height=560, + on_message=self.on_message, on_close=self.on_close, + ) + return orca.ExecutionResult.success("Preset update demo opened.") + except Exception as error: + message = "Preset update demo failed: %s" % error + orca.host.ui.message(message, title="Preset update demo", buttons="ok", icon="error") + return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, message) + + def on_close(self): + self.window = None + + def _post_state(self, kind="process", slot=1, message="", error=""): + try: + payload = _state(orca.host.preset_bundle(), kind, slot, message, error) + except Exception as state_error: + payload = {"command": "state", "kind": kind, "slot": slot, "slots": [], + "assigned": False, "switches": False, "edited_name": None, + "preset": None, "rows": [], "message": message, + "error": str(state_error)} + if self.window is not None and self.window.is_open(): + self.window.post(payload) + + def on_message(self, message): + kind = "process" + slot = 1 + try: + message = message or {} + kind = message.get("kind", "process") + if not isinstance(kind, str) or kind not in KIND_KEYS: + raise RuntimeError("Unknown preset category %r" % (kind,)) + self.kind = kind + slot = message.get("slot", 1) + + command = message.get("command") + if command == "state": + self.slot = slot + self._post_state(kind, slot) + return + if command == "apply": + key = message.get("key") + value = message.get("value") + if not isinstance(key, str) or not key: + raise RuntimeError("A setting key is required") + if not isinstance(value, str): + raise RuntimeError("A value string is required") + where = "" + if kind == "filament": + presets = _slot_presets(orca.host.preset_bundle()) + # Validate the slot strictly: an out-of-range slot must fail loudly + # rather than edit a different filament. A slot with no preset has + # nothing to write, which is the only slot shape the backend refuses. + slot = _parse_slot(slot, len(presets)) + if presets[slot - 1] is None: + raise RuntimeError("Filament slot %d has no preset to edit" % slot) + _apply(kind, key, value, slot) + where = " on slot %d" % slot + else: + _apply(kind, key, value) + shown = value if value else "(empty)" + self.slot = slot + self._post_state( + kind, slot, + message='Set %s = "%s"%s. The edit stays in the preset tab until you save it.' + % (key, shown, where), + ) + return + raise RuntimeError("Unknown command %r" % (command,)) + except Exception as error: + safe_kind = kind if isinstance(kind, str) and kind in KIND_KEYS else "process" + self._post_state(safe_kind, slot, error=str(error)) + + +@orca.plugin +class PresetUpdateDemoPackage(orca.base): + def register_capabilities(self): + orca.register_capability(PresetUpdateDemo) diff --git a/src/slic3r/GUI/OptionsGroup.cpp b/src/slic3r/GUI/OptionsGroup.cpp index 8af4cfeef8..0910f7b763 100644 --- a/src/slic3r/GUI/OptionsGroup.cpp +++ b/src/slic3r/GUI/OptionsGroup.cpp @@ -1332,6 +1332,21 @@ boost::any ConfigOptionsGroup::get_config_value2(const DynamicPrintConfig& confi return ret; } +bool ConfigOptionsGroup::commit_config_value(const t_config_option_key& opt_key, int opt_index) +{ + if (m_config == nullptr) + return false; + + // m_opt_map is filled while the option lines are created, so it still resolves the option when + // the group's controls (and thus its fields) have not been built yet. + const std::string opt_id = option_id_in_map(m_opt_map, opt_key, opt_index); + if (opt_id.empty()) + return false; + + on_change_OG(opt_id, get_config_value2(*m_config, opt_key, opt_index)); + return true; +} + Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index) { Field* field = get_field(opt_key); diff --git a/src/slic3r/GUI/OptionsGroup.hpp b/src/slic3r/GUI/OptionsGroup.hpp index ae629f943e..bea4596e09 100644 --- a/src/slic3r/GUI/OptionsGroup.hpp +++ b/src/slic3r/GUI/OptionsGroup.hpp @@ -119,6 +119,37 @@ using column_t = std::function; using t_optionfield_map = std::map; using t_opt_map = std::map< std::string, std::pair >; +// The option id a group's option map holds for opt_key, and the index it carries. `opt_index` is +// an in/out parameter: a negative value asks for the option's single entry, an explicit one selects +// that index. Returns an empty string when the map does not own the option, or when a negative index +// matches several entries -- a vector option the GUI edits one index at a time, which has no single +// value to commit. A group fills the map while its option lines are created (OptionsGroup::append_line +// / ConfigOptionsGroup::get_option), so this resolves an option even before its controls (and its +// field) have been built. +inline std::string option_id_in_map(const t_opt_map& options, const t_config_option_key& opt_key, int& opt_index) +{ + if (opt_index >= 0) { + for (const auto& opt : options) { + if (opt.second.first == opt_key && opt.second.second == opt_index) { + opt_index = opt.second.second; + return opt.first; + } + } + return {}; + } + + std::string opt_id; + for (const auto& opt : options) { + if (opt.second.first != opt_key) + continue; + if (!opt_id.empty()) + return {}; // several entries: a vector option, not one value + opt_id = opt.first; + opt_index = opt.second.second; + } + return opt_id; +} + class OptionsGroup { public: const bool staticbox{true}; @@ -332,6 +363,15 @@ public: boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1); // BBS: restore all pages in preset boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1); + // Re-run this group's committed-change path for one of its options without a widget event: + // build the option's value from the config this group edits (get_config_value2, the same form + // the option's field reports) and dispatch on_change_OG(), so the group's option-specific + // conversion (e.g. the printer_agent canonicalization) and its registered change handler run + // exactly as for a hand edit. The option map is filled while the option lines are created, so + // this resolves an option whose field is not built yet (a lazy page, or an option the GUI has + // no field for). Returns false when this group does not own the option, or when opt_index < 0 + // matches several entries (a vector option the GUI edits index by index has no single value). + bool commit_config_value(const t_config_option_key& opt_key, int opt_index = -1); Field* get_fieldc(const t_config_option_key& opt_key, int opt_index); std::pair get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 9d67e7c0fb..36c12611d9 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -46,6 +46,7 @@ #include "slic3r/GUI/DeviceCore/DevConfigUtil.h" #include #include +#include #include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/Point.hpp" #include "slic3r/GUI/SettingsIndex.hpp" @@ -1645,6 +1646,101 @@ void Tab::load_config(const DynamicPrintConfig& config) } } +// The option keys a preset of `type` may hold. The tab's config shares the global +// print_config_def with every other preset type, so membership has to be checked against the +// preset type's own key list instead of the config definition, or a printer setting could be +// written into the process preset. +static const std::vector& preset_type_options(Preset::Type type) +{ + static const std::vector none; + switch (type) { + case Preset::TYPE_PRINT: return Preset::print_options(); + case Preset::TYPE_FILAMENT: return Preset::filament_options(); + case Preset::TYPE_PRINTER: return Preset::printer_options(); + default: return none; + } +} + +static const char* preset_type_name(Preset::Type type) +{ + switch (type) { + case Preset::TYPE_PRINT: return "process"; + case Preset::TYPE_FILAMENT: return "filament"; + case Preset::TYPE_PRINTER: return "printer"; + default: return "preset"; + } +} + +// Set one option of the currently edited preset from its serialized string form, then run the +// same update path a hand edit runs. The edited preset is not saved: it only becomes dirty, +// exactly as if the field had been edited by hand. +void Tab::apply_preset_config_value(const std::string& opt_key, const std::string& value) +{ + if (m_config == nullptr) + throw std::runtime_error("The preset configuration is not available"); + if (wxGetApp().plater() == nullptr) + throw std::runtime_error("OrcaSlicer main window is not available"); + + const std::vector& options = preset_type_options(m_type); + if (std::find(options.begin(), options.end(), opt_key) == options.end()) + throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key)); + + // Parse strictly, keeping a backup: the strict deserializer can already have mutated the option + // when it rejects a bad value, so a failed update has to restore the preset it was given. + const ConfigOption* previous_option = m_config->option(opt_key); + std::unique_ptr previous(previous_option != nullptr ? previous_option->clone() : nullptr); + try { + m_config->set_deserialize_strict(opt_key, value); + } catch (const ConfigurationError& err) { + if (previous) + m_config->set_key_value(opt_key, previous.release()); + else + m_config->erase(opt_key); + throw std::invalid_argument(err.what()); + } + + // Run the same commit path the GUI runs for this setting, so its key-specific behaviour + // (ConfigOptionsGroup::on_change_OG, including the printer_agent canonicalization, the group's + // pre-checks and Tab::on_value_change()) runs exactly as for a hand edit. A built field supplies + // the field's typed/clamped value; otherwise the owning ConfigOptionsGroup builds the value from + // the config it edits and dispatches the same on_change_OG(). The field is refreshed from the + // config just committed first, so it reports the committed value rather than a stale one. + bool committed = false; + if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) { + field_page->reload_config(); + field->field_changed(); + committed = true; + } else { + for (const PageShp& page : m_pages) { + for (const ConfigOptionsGroupShp& group : page->m_optgroups) { + if (group->commit_config_value(opt_key)) { + committed = true; + break; + } + } + if (committed) + break; + } + } + + if (committed) { + // The commit path already ran update_dirty() (dirty flag, preset combo and, on the printer + // tab, the printer-agent switch); refresh the visible page and push the config to the plater. + reload_config(); + update(); + } else { + // The GUI edits this setting through a custom widget rather than a group (e.g. + // compatible_printers), so take the same shared no-field commit path the widget uses. Saved + // mode skips the value write (the strict parse above already did it) and keeps the path's + // compatibility recompute, dirty flag, preset-combo and plater updates. + load_key_value(opt_key, boost::any(), /*saved_value=*/true); + // The widget itself normally re-reads its value after load_key_value(); with no widget to do + // that, sync the visible page so a custom widget (the compatible-printers row) reflects the + // new value. + reload_config(); + } +} + // Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields. void Tab::reload_config() { diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 44ef0704a3..4aee8fa73c 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -424,6 +424,17 @@ public: //BBS update plater presets if update_plater_presets = true void update_tab_ui(bool update_plater_presets = false); void load_config(const DynamicPrintConfig& config); + // Host/plugin API: set one option of this tab's currently edited preset from its serialized + // string value, then run the same committed-change path a field edit runs. `opt_key` must be a + // setting of this tab's preset type, so a key of another preset type (e.g. a printer setting + // passed to the process tab) is rejected instead of written silently. `value` is parsed by the + // strict config deserializer, which raises std::invalid_argument for an unknown key or a value + // the option cannot parse, and leaves the preset unchanged. The key-specific behaviour runs + // through its ConfigOptionsGroup::on_change_OG(): with the option's built field it uses the + // field's typed value, otherwise the owning group builds the value and dispatches the same call, + // so a not-yet-built field still gets it. A key the GUI edits through a custom widget instead + // takes the shared no-field commit path, Tab::load_key_value() in saved mode. + void apply_preset_config_value(const std::string& opt_key, const std::string& value); virtual void reload_config(); void update_mode(); void update_visibility(); diff --git a/src/slic3r/plugin/host/PluginHostPresets.cpp b/src/slic3r/plugin/host/PluginHostPresets.cpp index 4f032e0808..48069cdb45 100644 --- a/src/slic3r/plugin/host/PluginHostPresets.cpp +++ b/src/slic3r/plugin/host/PluginHostPresets.cpp @@ -17,6 +17,7 @@ #include #include #include +#include // TabPresetComboBox::set_filament_idx #include #include @@ -48,6 +49,110 @@ PresetCollection& printer_presets(PresetBundle& bundle) return static_cast(bundle.printers); } +// Handle to one category of currently edited preset. It is a category, not a snapshot: it always +// resolves the preset the matching tab edits now, so it stays valid across preset switches. +struct PresetEditor +{ + Preset::Type type = Preset::TYPE_INVALID; +}; + +const char* preset_tab_name(Preset::Type type) +{ + switch (type) { + case Preset::TYPE_PRINT: return "process"; + case Preset::TYPE_FILAMENT: return "filament"; + case Preset::TYPE_PRINTER: return "printer"; + default: return "preset"; + } +} + +void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value) +{ + PluginHostUi::run_on_ui_thread([&] { + GUI::GUI_App& app = GUI::wxGetApp(); + if (app.is_closing()) + throw std::runtime_error("OrcaSlicer is closing"); + if (app.preset_bundle == nullptr) + throw std::runtime_error("Preset bundle is not available"); + if (app.plater() == nullptr) + throw std::runtime_error("OrcaSlicer main window is not available"); + + GUI::Tab* tab = app.get_tab(type); + if (tab == nullptr) + throw std::runtime_error(std::string("The ") + preset_tab_name(type) + " preset tab is not available"); + // The tab for this preset type exists, but the selected printer's technology may not use it + // (the print tab no-ops under SLA); refuse instead of writing a value the UI cannot apply. + if (!tab->supports_printer_technology(app.preset_bundle->printers.get_selected_preset().printer_technology())) + throw std::runtime_error(std::string("The ") + preset_tab_name(type) + + " preset is not available for the selected printer technology"); + + tab->apply_preset_config_value(key, value); + }); +} + +// Slot-addressed filament update. `slot_number` is 1-based, matching the GUI's filament slots and +// the values PresetBundle::filament_presets holds. The slot's assigned preset is resolved to its +// internal name; when that is not the preset the filament tab edits, the tab is switched through +// Tab::select_preset() - the normal UI preselection flow, so a dirty edited preset still prompts +// the user and a declined prompt cancels the call with a clear error and no change. +void update_filament_slot(int slot_number, const std::string& key, const std::string& value) +{ + PluginHostUi::run_on_ui_thread([&] { + GUI::GUI_App& app = GUI::wxGetApp(); + if (app.is_closing()) + throw std::runtime_error("OrcaSlicer is closing"); + if (app.preset_bundle == nullptr) + throw std::runtime_error("Preset bundle is not available"); + if (app.plater() == nullptr) + throw std::runtime_error("OrcaSlicer main window is not available"); + + PresetBundle& bundle = *app.preset_bundle; + if (slot_number < 1 || static_cast(slot_number) > bundle.filament_presets.size()) + throw std::out_of_range("Filament slot " + std::to_string(slot_number) + " is out of range; slot numbers are 1-based and this printer has " + + std::to_string(bundle.filament_presets.size()) + " filament slot(s)"); + + const std::string& assigned_name = bundle.filament_presets[static_cast(slot_number) - 1]; + if (assigned_name.empty()) + throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset"); + + // Resolve the assigned name the way the app does, so a renamed/aliased entry maps to the + // internal preset it now identifies. Copy the name out before selecting anything: the + // collection can be reallocated while switching. + Preset* slot_preset = bundle.filaments.find_preset(assigned_name); + if (slot_preset == nullptr) + throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " is assigned \"" + assigned_name + + "\", which does not resolve to a filament preset"); + const std::string target_name = slot_preset->name; + + GUI::Tab* tab = app.get_tab(Preset::TYPE_FILAMENT); + if (tab == nullptr) + throw std::runtime_error("The filament preset tab is not available"); + if (!tab->supports_printer_technology(bundle.printers.get_selected_preset().printer_technology())) + throw std::runtime_error("The filament preset is not available for the selected printer technology"); + + // Switch to the slot's preset through the normal preset selection, never a forced one, so the + // user's unsaved-changes prompt is honoured. A declined prompt returns false here. + if (bundle.filaments.get_edited_preset().name != target_name) { + if (!tab->select_preset(target_name)) + throw std::runtime_error("Switching the edited filament preset to \"" + target_name + "\" for filament slot " + + std::to_string(slot_number) + " was canceled; the setting was not changed"); + } + + // select_preset() can still leave a different preset edited (e.g. the target disappeared), so + // re-check before writing anything. + if (bundle.filaments.get_edited_preset().name != target_name) + throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name + + "\", not \"" + target_name + "\"; the setting was not changed"); + + // Match PlaterPresetComboBox::switch_to_tab(): record which 0-based filament slot this tab + // combobox now edits, so the GUI shows the slot the value was written for. + if (GUI::TabPresetComboBox* combo = tab->get_combo_box()) + combo->set_filament_idx(slot_number - 1); + + tab->apply_preset_config_value(key, value); + }); +} + void reload_local_bundle(const std::string& bundle_id) { bool reloaded = false; @@ -119,6 +224,47 @@ void host_bindings::register_presets(py::module_& host) .value("Plate", Preset::TYPE_PLATE) .value("Model", Preset::TYPE_MODEL); + py::class_(preset, "PresetEditor", + "Handle to a category of currently edited preset. It is not a snapshot: it always " + "resolves the preset the matching tab edits now, so it stays usable across preset " + "switches. update() edits the preset in place; the change is not saved to disk.") + .def_readonly("type", &PresetEditor::type) + .def( + "update", + [](const PresetEditor& editor, const std::string& key, const std::string& value) { + update_edited_preset(editor.type, key, value); + }, + py::arg("key"), py::arg("value"), + "Set one setting of the currently edited preset and run the same committed-change path as " + "editing the setting in the GUI; the edited preset becomes dirty until saved/exported. " + "key must be a setting of this preset category and value its serialized string form, " + "otherwise ValueError is raised and nothing is written. A setting edited through a field " + "runs that field's validation even before its page has been opened; one the GUI edits " + "outside a field (such as compatible printers) also recomputes preset compatibility.") + .def( + "update", + [](const PresetEditor& editor, int slot_number, const std::string& key, const std::string& value) { + if (editor.type != Preset::TYPE_FILAMENT) + throw std::invalid_argument("The slot_number form of update() is only available on " + "orca.host.preset.filament; call update(key, value) on this editor"); + update_filament_slot(slot_number, key, value); + }, + py::arg("slot_number"), py::arg("key"), py::arg("value"), + "Set one setting of the filament preset assigned to a 1-based filament slot. When that preset " + "is not the one currently edited the call switches to it through the normal preset-selection " + "flow, so an unsaved edited preset still prompts the user and a declined prompt cancels the " + "call with an error and no change. An out-of-range, empty or unresolved slot is rejected with " + "a clear error. Other preset editors accept only update(key, value); this form raises " + "ValueError on them."); + + // The three preset categories edited by the main tabs. `process` is the print/process preset, + // `printer` the machine preset and `filament` the currently edited filament preset (its + // 3-argument form addresses a 1-based filament slot); each only accepts its own settings, so a + // printer setting passed to `process` is rejected instead of written silently. + preset.attr("process") = py::cast(PresetEditor{Preset::TYPE_PRINT}); + preset.attr("printer") = py::cast(PresetEditor{Preset::TYPE_PRINTER}); + preset.attr("filament") = py::cast(PresetEditor{Preset::TYPE_FILAMENT}); + py::class_>(host, "Preset") .def_readonly("type", &Preset::type) .def_readonly("name", &Preset::name) diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 301acbc7cc..5539fa28ab 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -6,8 +6,12 @@ #include #include #include +#include #include +#include +#include + #include #include #include @@ -137,6 +141,106 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast() == printer_preset.name); } +TEST_CASE("Plugin host API exposes the editable preset category handles and guards them before Orca app initialization", + "[PluginHost][Python]") +{ + py::object host = import_orca_module().attr("host"); + py::object preset = host.attr("preset"); + + REQUIRE(has_attr(preset, "PresetEditor")); + REQUIRE(has_attr(preset, "process")); + REQUIRE(has_attr(preset, "printer")); + REQUIRE(has_attr(preset, "filament")); + + py::object process = preset.attr("process"); + py::object printer = preset.attr("printer"); + py::object filament = preset.attr("filament"); + + CHECK(process.attr("type").cast() == Slic3r::Preset::TYPE_PRINT); + CHECK(printer.attr("type").cast() == Slic3r::Preset::TYPE_PRINTER); + CHECK(filament.attr("type").cast() == Slic3r::Preset::TYPE_FILAMENT); + for (const py::object& editor : { process, printer, filament }) + CHECK(has_attr(editor, "update")); + + // With no wx application the call must fail cleanly, like the rest of host.preset, and must not + // be mistaken for a missing attribute or an argument error. + for (const py::object& editor : { process, printer, filament }) { + try { + editor.attr("update")("layer_height", "0.25"); + FAIL("preset editor update unexpectedly succeeded without a wx application"); + } catch (const py::error_already_set& error) { + CHECK(error.matches(PyExc_RuntimeError)); + CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); + } + } +} + +TEST_CASE("Plugin host API exposes the filament slot form of preset update and guards it", "[PluginHost][Python]") +{ + py::object host = import_orca_module().attr("host"); + py::object preset = host.attr("preset"); + + // The filament editor accepts update(slot_number, key, value) as well as update(key, value); with + // no wx application it reaches the same clean failure as the rest of host.preset. The slot + // validation and the switch/cancel path need a running app, so they are not exercised here. + try { + preset.attr("filament").attr("update")(1, "layer_height", "0.25"); + FAIL("slot-addressed filament update unexpectedly succeeded without a wx application"); + } catch (const py::error_already_set& error) { + CHECK(error.matches(PyExc_RuntimeError)); + CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); + } + + // The process and printer editors accept only update(key, value); the slot form is rejected + // before any application access, naming the editor that does accept it. + for (const char* name : { "process", "printer" }) { + CAPTURE(name); + try { + preset.attr(name).attr("update")(1, "layer_height", "0.25"); + FAIL("slot-addressed update on a non-filament editor unexpectedly succeeded"); + } catch (const py::error_already_set& error) { + CHECK(error.matches(PyExc_ValueError)); + CHECK(std::string(error.what()).find("only available on") != std::string::npos); + } + } +} + +TEST_CASE("A settings group resolves an option's id from its option map", "[PluginHost]") +{ + using Slic3r::GUI::option_id_in_map; + using Slic3r::GUI::t_opt_map; + + // The map ConfigOptionsGroup::get_option leaves: a scalar option, and a per-extruder option + // whose entries carry their index. + t_opt_map options; + options.emplace("layer_height", std::make_pair(std::string("layer_height"), -1)); + options.emplace("nozzle_diameter#0", std::make_pair(std::string("nozzle_diameter"), 0)); + options.emplace("nozzle_diameter#1", std::make_pair(std::string("nozzle_diameter"), 1)); + + // A negative index resolves a scalar option and yields its own (negative) index. + int index = -1; + CHECK(option_id_in_map(options, "layer_height", index) == "layer_height"); + CHECK(index == -1); + + // A negative index on a vector option matches several entries, so it resolves to nothing. + index = -1; + CHECK(option_id_in_map(options, "nozzle_diameter", index).empty()); + + // An explicit index selects that entry. + index = 1; + CHECK(option_id_in_map(options, "nozzle_diameter", index) == "nozzle_diameter#1"); + CHECK(index == 1); + + // An index no entry carries, and a key the map does not own, resolve to nothing. + index = 7; + CHECK(option_id_in_map(options, "nozzle_diameter", index).empty()); + index = -1; + CHECK(option_id_in_map(options, "wall_loops", index).empty()); + + const t_opt_map empty; + CHECK(option_id_in_map(empty, "layer_height", index).empty()); +} + TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]") { py::object host = import_orca_module().attr("host");