mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-16 20:31:14 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80af50f23 | ||
|
|
3bfe40e17a | ||
|
|
62f05dad2b | ||
|
|
82aee1abdd | ||
|
|
4d3c61c02a | ||
|
|
dc9203126d | ||
|
|
8c2a1198d6 | ||
|
|
193cfc6dbf |
@@ -288,6 +288,19 @@ if (APPLE)
|
||||
# Type"); Ninja/-Wall does not, and under -Werror it fails Xcode builds on code CI accepts.
|
||||
set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION "NO")
|
||||
|
||||
# The Xcode generator ignores CMAKE_<LANG>_COMPILER_LAUNCHER, so hand the launcher to
|
||||
# Xcode's own Compiler Launcher setting, which wraps every clang compile (C, C++,
|
||||
# Objective-C++ and the precompiled header) with one program: the C++ launcher wins when
|
||||
# both are set. The setting takes a single program path, not a CMake launcher list.
|
||||
# Xcode versions without it ignore it and build uncached.
|
||||
if (CMAKE_GENERATOR STREQUAL "Xcode")
|
||||
if (CMAKE_CXX_COMPILER_LAUNCHER)
|
||||
set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_CXX_COMPILER_LAUNCHER}")
|
||||
elseif (CMAKE_C_COMPILER_LAUNCHER)
|
||||
set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_C_COMPILER_LAUNCHER}")
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}")
|
||||
elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN")
|
||||
|
||||
@@ -111,6 +111,54 @@ else
|
||||
export CMAKE_POLICY_COMPAT=""
|
||||
fi
|
||||
|
||||
# Compiler cache, as in build_linux.sh. The program comes from CMAKE_CCACHE, else from a
|
||||
# launcher already set in the environment (the CI build sets ccache there), else sccache or
|
||||
# ccache from PATH; one program serves both languages. Each run applies its own choice to the
|
||||
# build directories, replacing a launcher an earlier run or a hand-run configure recorded.
|
||||
CMAKE_CCACHE_ARGS=()
|
||||
CCACHE_VAR=""
|
||||
for var in CMAKE_CCACHE CMAKE_CXX_COMPILER_LAUNCHER CMAKE_C_COMPILER_LAUNCHER; do
|
||||
if [ -n "${!var:-}" ]; then
|
||||
CCACHE_VAR=$var
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$CCACHE_VAR" ]; then
|
||||
CCACHE_PROGRAM=${!CCACHE_VAR}
|
||||
echo "Checking ${CCACHE_VAR} environment variable for compiler cache program..."
|
||||
CMAKE_CCACHE=$(command -v "${CCACHE_PROGRAM}") || {
|
||||
echo "${CCACHE_VAR} environment variable is set to '${CCACHE_PROGRAM}' but it was not found in PATH."
|
||||
CMAKE_CCACHE=""
|
||||
}
|
||||
elif command -v sccache >/dev/null 2>&1 ; then
|
||||
CMAKE_CCACHE=$(command -v sccache)
|
||||
elif command -v ccache >/dev/null 2>&1 ; then
|
||||
CMAKE_CCACHE=$(command -v ccache)
|
||||
fi
|
||||
if [ -n "${CMAKE_CCACHE}" ] ; then
|
||||
echo "${CMAKE_CCACHE} found, enabling compiler caching..."
|
||||
CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER="${CMAKE_CCACHE}" -DCMAKE_CXX_COMPILER_LAUNCHER="${CMAKE_CCACHE}")
|
||||
# The same ccache settings as the CI build. Without this sloppiness ccache refuses every
|
||||
# compile that uses the precompiled header, which is most of OrcaSlicer, and skips headers
|
||||
# a checkout has just written. It is added to the sloppiness already configured, and
|
||||
# CCACHE_SLOPPINESS or CCACHE_(NO)DEPEND set in the environment win.
|
||||
if [[ "$(basename "${CMAKE_CCACHE}")" == ccache ]] ; then
|
||||
if [[ -z "${CCACHE_SLOPPINESS+x}" ]] ; then
|
||||
CCACHE_SLOPPINESS=$("${CMAKE_CCACHE}" --get-config sloppiness 2>/dev/null || true)
|
||||
export CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS:+${CCACHE_SLOPPINESS},}pch_defines,time_macros,include_file_mtime,include_file_ctime"
|
||||
fi
|
||||
# Hash the includes the compiler reports instead of preprocessing every miss.
|
||||
if [[ -z "${CCACHE_DEPEND+x}${CCACHE_NODEPEND+x}" ]] ; then
|
||||
export CCACHE_DEPEND=1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Note: ccache or sccache are not found. Install either of them for faster rebuilds."
|
||||
# Clear a launcher recorded by an earlier configure. The entries must be set empty rather
|
||||
# than removed: CMake fills a missing launcher entry from the environment.
|
||||
CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER:STRING= -DCMAKE_CXX_COMPILER_LAUNCHER:STRING=)
|
||||
fi
|
||||
|
||||
echo "Build params:"
|
||||
echo " - ARCH: $ARCH"
|
||||
echo " - BUILD_CONFIG: $BUILD_CONFIG"
|
||||
@@ -162,6 +210,7 @@ function build_deps() {
|
||||
-DCMAKE_OSX_ARCHITECTURES:STRING="${_ARCH}" \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||
${CMAKE_POLICY_COMPAT}
|
||||
fi
|
||||
cmake --build . --config "$BUILD_CONFIG" --target deps
|
||||
@@ -264,6 +313,7 @@ function build_slicer() {
|
||||
-DCMAKE_OSX_ARCHITECTURES="${_ARCH}" \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||
${CMAKE_POLICY_COMPAT}
|
||||
fi
|
||||
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# Preset Plugin API — High Level Design
|
||||
|
||||
## Purpose and scope
|
||||
|
||||
The preset plugin API lets Python plugins update the presets OrcaSlicer is using,
|
||||
so users do not have to perform every routine setting change by hand. It covers
|
||||
process, printer, and filament presets. All three use one staged update lifecycle;
|
||||
the way a target is selected is specific to the preset type.
|
||||
|
||||
The API edits Orca's managed edited preset, not a detached copy or a saved preset
|
||||
behind the UI's back. It does not save to disk. Saving, discarding, and transferring
|
||||
unsaved edits remain under Orca's existing preset workflow.
|
||||
|
||||
The API does not create or delete presets or provide off-tab editing. For filament,
|
||||
choosing a different `preset_name` as the update target also selects that preset for
|
||||
the requested slot, following the normal UI flow. A standalone slot-assignment or
|
||||
preset-fork operation is outside this API's contract.
|
||||
|
||||
## Preset and slot model
|
||||
|
||||
Each preset collection maintains a managed edited copy for its selected preset.
|
||||
Settings tabs apply changes to that copy and own the normal dirty-state, validation,
|
||||
save/discard/transfer, and downstream refresh behavior. Plugin updates must use the
|
||||
same path so that the settings tab, dirty indicators, and effective print
|
||||
configuration observe a consistent state.
|
||||
|
||||
Filament selection adds a slot-to-preset mapping. `PresetBundle::filaments` is the
|
||||
catalog, while `PresetBundle::filament_presets` maps each slot to a preset name.
|
||||
Several slots may refer to the same preset. A filament update edits that shared
|
||||
preset, so all slots referring to it are affected. Slot numbers exposed by the API
|
||||
are 1-based, matching the UI.
|
||||
|
||||
## API shape
|
||||
|
||||
Each preset API exposes `begin_update()`, which selects and binds one target, and
|
||||
returns a transaction. The transaction stages setting changes using `set()` and
|
||||
applies them only on explicit `commit()`.
|
||||
|
||||
```python
|
||||
# Filament is targeted by its 1-based slot. Omitting preset_name keeps the
|
||||
# preset currently assigned to the slot; providing it selects that preset for
|
||||
# the slot before staging updates.
|
||||
tx = await orca.host.preset.filament.begin_update(
|
||||
slot_number=2,
|
||||
preset_name="Generic PLA"
|
||||
)
|
||||
tx.set("filament_flow_ratio", "0.98")
|
||||
tx.set("filament_retract_length", "0.8")
|
||||
result = tx.commit()
|
||||
|
||||
# Process and printer default to their active presets. A caller can request a
|
||||
# different preset that is selectable in the corresponding UI dropdown.
|
||||
tx = await orca.host.preset.process.begin_update(
|
||||
preset_name="0.20mm Standard"
|
||||
)
|
||||
tx.set("layer_height", "0.2")
|
||||
tx.commit()
|
||||
```
|
||||
|
||||
The example is the API contract, and the Python bridge provides it. `begin_update()`
|
||||
is awaitable: it suspends the plugin operation until the target selection and any
|
||||
unsaved-edit dialog settle, without blocking Orca's UI thread. `set()` and `commit()`
|
||||
run synchronously on the background Script worker once the awaitable resolves.
|
||||
Awaiting `begin_update()` requires the Script worker's running asyncio loop; the
|
||||
mechanism is described in [python-plugin-async-bridge.md](python-plugin-async-bridge.md).
|
||||
|
||||
### Target resolution
|
||||
|
||||
- **Filament:** `slot_number` identifies the slot. If `preset_name` is omitted,
|
||||
the transaction targets the preset currently assigned to that slot. If supplied,
|
||||
it requests that preset for the slot and selects it through the normal UI flow;
|
||||
the name must be among the presets offered by that slot's dropdown, with one
|
||||
exception: the preset currently assigned to the slot stays targetable even when the
|
||||
slot's dropdown hides it (for example an incompatible-but-selected preset). Selecting
|
||||
a different preset changes the slot-to-preset mapping. In both cases the transaction
|
||||
edits the shared preset, not an isolated slot override.
|
||||
- **Process and printer:** omitting `preset_name` targets the currently active
|
||||
preset. Supplying `preset_name` requests the preset by the name shown in its
|
||||
dropdown. It must be one of the choices the dropdown currently offers, rather
|
||||
than merely any preset present in the collection. This is particularly important
|
||||
for process presets, whose selectable choices can depend on the current context.
|
||||
- A transaction targets exactly one preset. Multi-preset transactions are not
|
||||
supported: applying them would require switching among targets while the
|
||||
application has only one managed edited copy per collection.
|
||||
|
||||
Targets are resolved through Orca's normal preset-selection model. The API should
|
||||
not accept raw preset pointers or filesystem paths as target identities. A missing,
|
||||
unavailable, or ambiguous target fails explicitly instead of silently selecting a
|
||||
different preset.
|
||||
|
||||
## Selection and user interaction
|
||||
|
||||
`begin_update()` resolves its target through shared binding helpers
|
||||
(`bind_preset_target` for process/printer, `bind_filament_target` for filament) and
|
||||
performs the selection only when the requested target differs from the active target;
|
||||
otherwise it returns a transaction without switching. The helpers use Orca's normal
|
||||
preset selection flow, `Tab::select_preset()`, including updating a filament slot
|
||||
assignment when requested. If the normal selection flow requires user input, it opens
|
||||
the relevant existing dialog(s), including unsaved-edit choices such as save, discard,
|
||||
or transfer. The operation is awaitable and remains pending until those interactions
|
||||
finish; it resolves with the bound transaction, or with a cancellation, without
|
||||
applying staged settings. A canceled selection does not change the requested target or
|
||||
filament slot assignment, but it does not roll back decisions the user already accepted
|
||||
in an earlier dialog. Cancellation of the awaiting coroutine is separate from a user
|
||||
cancel: a selection the user already accepted is not reversed, while the remaining slot
|
||||
assignment and the transaction creation are skipped when the cancellation is observed
|
||||
before the slot is reassigned (see [python-plugin-async-bridge.md](python-plugin-async-bridge.md)). The helpers then confirm that the requested preset actually
|
||||
became the edited preset; a successful selection request alone is not sufficient. There
|
||||
is no separate `switch_preset` plugin API.
|
||||
|
||||
If the requested target is already active, `begin_update()` returns without a
|
||||
selection prompt, apart from any UI-thread dispatch needed by the bridge. If a
|
||||
different preset is requested, Orca selects it through the normal UI flow. For
|
||||
filament, this also updates the requested slot's assignment when `preset_name`
|
||||
differs from its current assignment. When the current preset has unsaved changes,
|
||||
the plugin operation remains pending until the user chooses among Orca's existing
|
||||
options, such as transfer, discard, or cancel.
|
||||
|
||||
The API never suppresses that warning, chooses an option for the user, or switches
|
||||
away from dirty edits silently. Cancel aborts the operation before any staged target
|
||||
changes are applied. Process and printer updates follow this rule too when the
|
||||
plugin requests a different `preset_name`; they normally return immediately when
|
||||
the active preset is the target.
|
||||
|
||||
## Staging and commit semantics
|
||||
|
||||
`set(key, value)` stages a serialized config value and does not mutate the preset.
|
||||
The transaction is the boundary for a related group of changes, avoiding
|
||||
intermediate partially-updated states. `commit()` runs synchronously on the background
|
||||
Script worker. At commit, the API validates keys and values against the target preset
|
||||
type and applies the batch through `Tab::apply_preset_config_values`, the existing tab
|
||||
and settings-group update path. Existing setting-specific validation, correction, and
|
||||
callbacks remain authoritative; plugin updates do not create a parallel setter with
|
||||
different behavior.
|
||||
|
||||
A successful commit updates dirty state and downstream configuration/Plater state
|
||||
once for the batch. The edited preset remains unsaved; persistence continues through
|
||||
Orca's normal save action. A validation or commit failure restores the edited preset's
|
||||
config to its pre-batch state and recomputes the state derived from it (compatibility
|
||||
marks, the preset selector, dependent tabs, the Plater and the printer agent), so no
|
||||
staged setting is left partially applied. That guarantee is limited to the edited
|
||||
preset and its config-derived state: an `on_change` callback is a GUI method, not a pure
|
||||
function of the config, so the transaction does not reverse a preset or slot selection
|
||||
made while resolving the target, a dialog decision the user already accepted, an undo
|
||||
snapshot, a dependent preset's rewritten config, or anything already sent to a device.
|
||||
Exact all-or-nothing across those external effects is not achievable while the
|
||||
authoritative GUI callbacks run; the boundary is documented on
|
||||
`Tab::apply_preset_config_values`. A filament slot assignment made as part of target
|
||||
selection is likewise not a staged setting: after the user accepts the selection flow it
|
||||
may remain changed even if a later commit fails, and the API does not switch back or
|
||||
reverse the user's selection or any accepted transfer/discard decision for the previous
|
||||
preset.
|
||||
|
||||
## Outcomes and consistency
|
||||
|
||||
The API distinguishes target resolution failures, invalid setting/value, user
|
||||
cancellation, stale filament-slot mapping, and commit failure. A result identifies
|
||||
the target preset, whether preset selection changed, and (for filament) whether the
|
||||
slot assignment changed and which slots share the edited preset. A canceled
|
||||
selection applies neither the requested slot assignment nor staged target changes;
|
||||
a failed commit applies none of the staged target settings.
|
||||
|
||||
Filament slot assignments can change while a plugin operation is pending. The API
|
||||
must detect that and reject a stale operation instead of silently editing the new
|
||||
slot assignment. It must not hold raw preset pointers across reloads. Conflicting
|
||||
duplicate writes to one setting in a transaction are rejected rather than resolved
|
||||
by incidental call order.
|
||||
|
||||
The existing one-setting filament call,
|
||||
`orca.host.preset.filament.update(slot_number, key, value)`, remains available as a
|
||||
compatibility shorthand. It shares the staged API's target resolution, selection,
|
||||
validation, and commit path, and it is synchronous (it blocks the background Script
|
||||
worker) rather than awaitable.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Reduce routine interaction, preserve agency.** Clean switches should be
|
||||
seamless; unsaved user work must still receive the normal warning and decision.
|
||||
- **One source of truth.** UI and plugins share the managed edited copy and setting
|
||||
update path, including validation and callbacks.
|
||||
- **The UI workflow is authoritative.** The API must enter through the same
|
||||
selection and setting-change workflows as the UI, not reproduce their logic or
|
||||
write config values directly. This keeps existing and future side effects active:
|
||||
`on_change` handling for mutually exclusive, invalid, or out-of-range settings;
|
||||
compatibility and dependent-preset updates; and printer-agent changes when the
|
||||
selected printer changes.
|
||||
- **Explicit target and explicit commit.** Plugins know which preset they edit and
|
||||
when staged changes become visible.
|
||||
- **No hidden persistence.** Plugin edits remain unsaved until the user saves them
|
||||
through Orca.
|
||||
- **Shared means shared.** Editing a filament preset through one slot affects every
|
||||
slot assigned to it. Choosing another preset for a slot is explicit in
|
||||
`preset_name`; slot-specific divergence would require a separate fork operation.
|
||||
- **Consistent lifecycle, type-specific targeting.** Process, printer, and
|
||||
filament share transaction and error semantics while respecting their existing
|
||||
selection models.
|
||||
|
||||
## Implementation in the current codebase
|
||||
|
||||
The transaction API is implemented. The current plugin surface exposes `begin_update()`,
|
||||
`PresetTransaction` with `set()`/`commit()`, and the per-setting `update()` compatibility
|
||||
shorthand. For filament, `update_filament_slot()` resolves the preset already assigned
|
||||
to a slot, selects it through `Tab::select_preset()`, and applies one setting through
|
||||
`Tab::apply_preset_config_values()`; it does not change the slot assignment.
|
||||
|
||||
`begin_update()` is awaitable through a Future-backed UI continuation
|
||||
(`begin_preset_update_async` / `begin_filament_update_async` plus `AsyncFuture`; see
|
||||
[python-plugin-async-bridge.md](python-plugin-async-bridge.md)). It posts the target
|
||||
binding to the UI thread and resolves once the selection settles, so the wait does not
|
||||
block the UI thread. The `update()` shorthand keeps the synchronous, blocking form,
|
||||
which is valid only on the background Script worker.
|
||||
|
||||
`Tab::select_preset()` is the basis for switching to process, printer, or filament
|
||||
presets and for invoking the normal unsaved-edit flow. The flow is type-specific: a
|
||||
process selection can trigger compatibility handling for filaments, and a printer
|
||||
selection can involve technology changes and dependent preset compatibility. It may
|
||||
therefore show more than one dialog. Cancellation of a later step does not necessarily
|
||||
undo decisions the user already accepted in an earlier dialog. Target resolution
|
||||
preserves those side effects and then verifies the resulting edited target.
|
||||
|
||||
Filament `preset_name` is an additional integration on top of `Tab::select_preset()`:
|
||||
selecting a different name for a slot also updates the slot-to-preset mapping and its
|
||||
dependent UI/configuration state, mirroring the filament branch of the Plater's normal
|
||||
selection path (`PresetBundle::set_filament_preset()` plus the project/selection refresh,
|
||||
the dynamic filament list, the conditional support-filament flushing recalc, the slot
|
||||
combo refresh and its AMS sync badge). A mixed-color virtual slot is not targetable.
|
||||
|
||||
Process and printer dropdown choices are constrained by visibility and compatibility,
|
||||
and compatibility may depend on the active context. Target resolution checks the tab
|
||||
dropdown's actual items before the selection flow, so a target the user cannot pick
|
||||
fails without raising that flow's unsaved-edit prompt; a name two entries carry is
|
||||
rejected as ambiguous. A filament `preset_name` resolves against the requested slot's
|
||||
own sidebar selector rather than the tab's, so that slot's compatibility filter is the
|
||||
one that applies. A name the slot's dropdown does not show is rejected unless it names
|
||||
that slot's current assignment (a hidden or incompatible-but-selected preset), which
|
||||
stays targetable.
|
||||
|
||||
`commit()` applies its batch through `Tab::apply_preset_config_values`, which parses and
|
||||
validates the whole batch before touching the edited preset and restores the edited
|
||||
preset and its config-derived state on failure (see "Staging and commit semantics" for
|
||||
the limit of that rollback). Since `begin_update()` performs target selection before the
|
||||
caller stages values, invalid staged values can still follow a user-visible selection
|
||||
prompt; that is a consequence of this API shape and remains explicit in its behavior.
|
||||
|
||||
## Integration boundary
|
||||
|
||||
The plugin API coordinates target resolution, preset selection, staged values, and
|
||||
the settings commit path. It does not own or duplicate preset dirty-state logic,
|
||||
selection prompts, setting validation, or persistence. Target changes flow through
|
||||
the same UI selection path so dependent behavior—such as selecting the printer
|
||||
agent—still runs. Setting changes flow through the same UI change path so `on_change`
|
||||
and related validation/correction behavior still runs. The integration bridges UI
|
||||
prompts to the Python operation asynchronously (the awaitable `begin_update()`), reuses
|
||||
the normal Plater slot-selection behavior for explicit filament targets, and commits a
|
||||
batch without losing existing per-setting behavior.
|
||||
@@ -0,0 +1,398 @@
|
||||
# Python plugin async bridge — High Level Design
|
||||
|
||||
## Purpose and scope
|
||||
|
||||
The embedded Python plugin runtime runs capability calls synchronously: a C++ call site acquires the
|
||||
GIL and the capability's Python method runs to completion on the calling thread. That model cannot
|
||||
express an operation that has to wait for the user — most visibly the staged preset update
|
||||
(`begin_update()` in [preset-plugin-api.md](preset-plugin-api.md)), whose target selection can open
|
||||
unsaved-edit dialogs and must not block Orca's UI thread while it waits.
|
||||
|
||||
This document describes the bridge that lets a Python plugin suspend and resume such an operation
|
||||
without blocking the UI thread. The bridge is deliberately narrow: only the Script capability gains
|
||||
an awaitable execution path. Every other capability — including the SlicingPipeline hooks — stays
|
||||
synchronous.
|
||||
|
||||
## Execution baseline
|
||||
|
||||
### How a capability call runs
|
||||
|
||||
A capability instance is a Python object owned through `Slic3r::PluginInstanceHandle`
|
||||
(`src/slic3r/plugin/PythonPluginBridge.cpp`) and handed to the rest of the program as a
|
||||
`std::shared_ptr<Slic3r::PluginCapabilityInterface>`. Its identity (`name()`, `type()`,
|
||||
`audit_plugin_key()`) is resolved once at materialization and cached in C++
|
||||
(`PluginCapabilityInterface` in `src/slic3r/plugin/PythonPluginInterface.hpp`).
|
||||
|
||||
Every C++→Python call crosses a trampoline method. `PyPluginCommonTrampoline` and
|
||||
`PyPluginInterfaceTrampoline` (`src/slic3r/plugin/PyPluginTrampoline.hpp`), and the per-type subclass
|
||||
`PyScriptPluginCapabilityTrampoline`
|
||||
(`src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp`), wrap the call in the
|
||||
`ORCA_PY_OVERRIDE_AUDITED` macro. For one synchronous call that macro, in order:
|
||||
|
||||
1. constructs a `PluginCapabilityInterface::RefCounter`, which `increment()`s the capability's
|
||||
reference count for the call's duration;
|
||||
2. constructs a `PythonGILState`, which first acquires a `PythonRuntimeLease` and then
|
||||
`PyGILState_Ensure()`;
|
||||
3. opens a `ScopedPluginAuditContext(audit_plugin_key(), name())`;
|
||||
4. dispatches into Python via `PYBIND11_OVERRIDE`/`PYBIND11_OVERRIDE_PURE`;
|
||||
|
||||
and unwinds those guards when the call returns.
|
||||
|
||||
`PythonGILState` and `PythonRuntimeLease` live in `src/slic3r/plugin/PythonInterpreter.hpp`. A lease is
|
||||
a `shared_lock` on `PythonInterpreter::m_runtime_mutex`; `PythonInterpreter::shutdown()` takes the
|
||||
exclusive side, so a thread that holds a lease can safely touch Python objects, and interpreter
|
||||
finalization cannot run underneath it. `ScopedPluginAuditContext`
|
||||
(`src/slic3r/plugin/PluginAuditManager.hpp`) stores the previous thread-local plugin/capability pair
|
||||
and scoped filesystem roots, sets this call's, and restores them on destruction; the audit hook then
|
||||
attributes every filesystem/socket/process event to the plugin currently executing on that thread.
|
||||
|
||||
### The Script capability
|
||||
|
||||
`Slic3r::ScriptPluginCapability` (`src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp`)
|
||||
declares one pure virtual method, `ExecutionResult execute()`. The async runtime invokes it through
|
||||
`invoke_script_execute` (`src/slic3r/plugin/PyPluginTrampoline.hpp`), which replicates the audited
|
||||
trampoline call (ref counter, audit scope, traceback-preserving rethrow) but returns a
|
||||
`ScriptExecuteInvocation` instead of forcing a cast:
|
||||
|
||||
- a plain `def execute` runs to completion in `invoke_script_execute` and yields
|
||||
`ScriptExecuteInvocation::result`;
|
||||
- an `async def execute` (or any override that returns a coroutine, detected with
|
||||
`inspect.iscoroutine`) yields the coroutine object in `ScriptExecuteInvocation::awaitable` without
|
||||
running it, so the runtime can drive it.
|
||||
|
||||
`ScriptExecuteInvocation` is defined in `PyPluginTrampoline.hpp`; `py_is_coroutine` is the detection
|
||||
helper.
|
||||
|
||||
### UI marshalling
|
||||
|
||||
`PluginHostUi::run_on_ui_thread` (`src/slic3r/plugin/host/PluginHostUi.cpp`) forwards to the
|
||||
file-local `run_on_ui_blocking`:
|
||||
|
||||
- if the caller is already on the wx main thread, it runs the callable inline after a
|
||||
`py::gil_scoped_release`;
|
||||
- otherwise it sets up a `std::promise`/`std::future`, releases the GIL, posts the callable with
|
||||
`GUI::wxGetApp().CallAfter`, and blocks on `future.get()`.
|
||||
|
||||
That is a blocking handoff: the caller's stack is parked on a thread while the UI thread runs the
|
||||
callable. It is the correct primitive for the synchronous `update()`/`commit()` path and for
|
||||
`reload_local_bundle`/`list_bundle_ids`, but it cannot be used by an operation that must suspend.
|
||||
|
||||
### Slicing hooks
|
||||
|
||||
`Print::set_slicing_pipeline_hook_fn` is installed by `src/slic3r/plugin/PluginHooks.cpp`. The hook
|
||||
runs on the slicing worker thread and dispatches to `SlicingPipelinePluginCapability::execute(ctx)`
|
||||
per capability, acquiring a `PythonGILState` per capability. `SlicingPipelineContext`
|
||||
(`SlicingPipelinePluginCapability.hpp`) holds raw pointers into the live slicing graph
|
||||
(`ctx.print`, `ctx.object`) that are valid only for the duration of `execute(ctx)`. The UI thread can
|
||||
be blocked waiting on the slicing worker (`BackgroundSlicingProcess::stop()` parks it), so a
|
||||
marshalled UI call from a hook can deadlock; `PluginHostSlicing.cpp`, `SlicingPipelinePluginCapability.hpp`
|
||||
and `PluginHostUi.hpp` all document this.
|
||||
|
||||
## Script-only async execution
|
||||
|
||||
### Per-invocation worker and loop
|
||||
|
||||
`ScriptAsyncRuntime` (declared in `src/slic3r/plugin/PluginManager.hpp`, implemented in
|
||||
`PluginManager.cpp`) drives every Script invocation off the caller's thread. Its `start()` creates a
|
||||
`Run` and launches a detached worker thread that runs `Impl::run_invocation`. There is one worker
|
||||
thread per invocation, so no invocation ever blocks the UI thread and no invocation is queued behind
|
||||
another plugin's.
|
||||
|
||||
`run_invocation` runs detection and execution on the worker:
|
||||
|
||||
- it re-checks `cancel_requested` before anything (a cancellation that arrived before the run
|
||||
started is honored without running the plugin);
|
||||
- it calls `invoke_script_execute`; a plain `def execute` result is used directly, and an `async def`
|
||||
coroutine is driven on a loop created for that invocation:
|
||||
`asyncio.new_event_loop()` → `asyncio.set_event_loop(loop)` → `loop.create_task(coroutine)` →
|
||||
`loop.run_until_complete(task)` → `loop.close()`, then `asyncio.set_event_loop(None)`.
|
||||
|
||||
The loop is per invocation, not global, and no loop outlives its invocation. The loop for the
|
||||
calling thread is published in the thread-local `tl_script_loop`, exposed to host awaitables through
|
||||
`ScriptAsyncRuntime::current_loop()` (GIL held).
|
||||
|
||||
`PluginManager::launch_script_capability` is the entry point: it resolves the capability, hands it to
|
||||
`ScriptAsyncRuntime::start`, and sets `ScriptLaunchResult::launched_async`. The completion callback
|
||||
runs on the GUI thread (or inline when there is no wx app), and `launch_script_capability` records a
|
||||
run error through `set_plugin_error` before invoking the caller's `ScriptCompletionFn`.
|
||||
`PluginManager::run_script_capability` is kept as a compatibility wrapper: it waits for the
|
||||
completion callback, pumping `wxYield()` when it is itself on the GUI thread, and returns the
|
||||
`ExecutionResult`.
|
||||
|
||||
### Capability, runtime, GIL and audit lifetime across a suspension
|
||||
|
||||
For the whole invocation — including an `async def` suspended at an `await` — `run_invocation` holds:
|
||||
|
||||
- a `PluginCapabilityInterface::RefCounter` per invocation and a `shared_ptr` to the capability in the
|
||||
`Run`, so the capability and its module stay alive across awaits and unload cannot drop them;
|
||||
- a `PythonRuntimeLease` (acquired before the GIL). `PythonInterpreter::shutdown()` cannot finalize
|
||||
the interpreter while any invocation worker holds a lease;
|
||||
- the GIL, acquired with `pybind11::gil_scoped_acquire` on the worker;
|
||||
- a `ScopedPluginAuditContext(audit_plugin_key(), name())`, so filesystem/socket/process events
|
||||
raised by the coroutine are attributed to the plugin.
|
||||
|
||||
The capability reference is released inside the GIL scope (`run->capability.reset()`), because
|
||||
dropping the pybind holder from a non-Python thread would `DECREF` without the GIL. The loop is
|
||||
closed and `tl_script_loop` cleared before the worker exits.
|
||||
|
||||
A consequence of running Script on a non-main thread: the audit permission prompt is fail-closed off
|
||||
the main thread. `request_permission` in `src/slic3r/plugin/PluginAuditManager.cpp` posts the dialog
|
||||
with `CallAfter` and denies the current event, persisting the grant when the user accepts, so a
|
||||
Script sees a denial on the first attempt and can succeed on a later one. This matches the existing
|
||||
behavior for the plugin-load worker and the slicing worker.
|
||||
|
||||
### Awaitable UI continuation
|
||||
|
||||
`AsyncFuture` (`src/slic3r/plugin/host/PluginHostUi.hpp`, implemented in `PluginHostUi.cpp`) is the
|
||||
suspension primitive for a host call that must show a dialog before it can resolve. It carries no GUI
|
||||
or preset state — only the loop, the Future created on it, and a Python-side resolver:
|
||||
|
||||
- `AsyncFuture::create()` requires the GIL and a running asyncio loop. It binds
|
||||
`asyncio.get_running_loop()`, creates a Future with `create_future()`, and installs the resolver
|
||||
`make_future_resolver` and the cancellation watcher `make_cancellation_watcher` on the Future.
|
||||
- `complete(value)`/`fail(exc)` marshal the outcome with `loop.call_soon_threadsafe(m_bind, …)`, so
|
||||
the Future is resolved on its own loop thread, never from the UI thread. The resolver drops an
|
||||
outcome whose Future is already cancelled (`set_result`/`set_exception` on a cancelled Future would
|
||||
raise `InvalidStateError`).
|
||||
- Cancellation is observed two ways, so the UI thread can decide without touching the loop. The
|
||||
loop-thread done-callback `make_cancellation_watcher` sets an atomic (`abandon()`); `is_cancelled()`
|
||||
additionally reads the Python Future's own `cancelled()` under the GIL, so a cancellation that
|
||||
`Future.cancel()` applied synchronously is seen even before the loop runs the done-callback.
|
||||
`is_cancelled()` never mutates the Future or the loop and is safe from the UI thread. A queued
|
||||
selection therefore never starts, and one inside a modal stops when the modal returns.
|
||||
`~AsyncFuture` releases its Python references under the GIL, or intentionally leaks them if the
|
||||
interpreter is already gone (same rule as the UI callable holders).
|
||||
|
||||
`PluginHostUi::post_to_ui_thread(fn)` posts work to the UI thread without blocking (returns false when
|
||||
the application is not initialized); `require_ui_application()` fails synchronously so an awaitable
|
||||
entry point can reject before touching asyncio.
|
||||
|
||||
### Awaitable `begin_update()`
|
||||
|
||||
`begin_update()` is a real awaitable. `begin_preset_update_async` and `begin_filament_update_async`
|
||||
(`src/slic3r/plugin/host/PluginHostPresets.cpp`) run on the plugin worker with the GIL held and a
|
||||
running loop:
|
||||
|
||||
1. validate the application synchronously (`require_ui_application()`), so a call without a wx app
|
||||
fails exactly like `update()`;
|
||||
2. create an `AsyncFuture` on the running loop and register it with
|
||||
`PluginHostUi::track_pending_async(handle)`;
|
||||
3. post the selection to the UI thread with `PluginHostUi::post_to_ui_thread`;
|
||||
4. return the Future at once.
|
||||
|
||||
The UI callback first skips the selection entirely when `handle->is_cancelled()`, then runs the
|
||||
target binding with the GIL released — `bind_preset_target` for process/printer, `bind_filament_target`
|
||||
for filament — passing a `CancelProbe` that also reads `handle->is_cancelled()`. It reuses the normal
|
||||
selection flow `Tab::select_preset()`, including the unsaved-edit prompt. On success it resolves the
|
||||
Future with the name-based `PresetTransaction`; on failure it fails the Future with the mapped Python
|
||||
exception. Nothing is staged until the caller's later `set()`/`commit()`. An unavailable target is
|
||||
rejected before selection and changes no preset. If the user declines selection, decisions already
|
||||
accepted in an earlier dialog are not rolled back. Coroutine cancellation does not undo an accepted
|
||||
preset selection; for filament, slot reassignment is skipped when cancellation is observed before the
|
||||
mapping check, but may remain if cancellation arrives after that check, even when no transaction is
|
||||
delivered. No GUI or preset pointer crosses the suspension — the awaitable holds only copied
|
||||
category/name/slot values.
|
||||
|
||||
`set()` stages a serialized value without mutating the preset, and `commit()` applies the batch
|
||||
through `Tab::apply_preset_config_values` on the UI thread; both are synchronous and, per their
|
||||
bindings, valid only on the background Script worker (they call `PluginHostUi::run_on_ui_thread`,
|
||||
which blocks the worker, or runs inline if entered from the UI thread).
|
||||
|
||||
### Cancellation and accepted-selection boundary
|
||||
|
||||
Two distinct cancellations exist:
|
||||
|
||||
- **User declined the selection** — `Tab::select_preset()` returned false. `bind_*_target` throws
|
||||
`SelectionCancelled`, which the awaitable path maps to `orca.host.preset.PresetSelectionCancelled`
|
||||
(a `RuntimeError` subclass). The synchronous `update()` path keeps raising plain `RuntimeError`.
|
||||
- **The awaiting coroutine was cancelled** — task cancellation or plugin unload. `AsyncFuture`
|
||||
observes this two ways: the loop-thread done-callback sets an atomic, and `is_cancelled()` also
|
||||
reads the Future's own `cancelled()` under the GIL (see above). `bind_*_target` checks its
|
||||
`CancelProbe` after `select_preset()` returns and after the modal-free paths, throwing
|
||||
`SelectionAbandoned`; the outcome is dropped because the handle is cancelled/abandoned, so the
|
||||
coroutine sees `asyncio.CancelledError`. A queued selection never starts.
|
||||
|
||||
`ScriptAsyncRuntime::cancel_plugin(plugin_key)` sets `cancel_requested` on every active run for the
|
||||
plugin and, when a loop is live (`has_loop`), cancels the task with
|
||||
`loop.call_soon_threadsafe(task.cancel)` while holding the GIL. A synchronous `def execute`
|
||||
invocation has no loop, so only the flag is set (checked before the run starts).
|
||||
|
||||
The accepted-selection boundary is deliberate and cannot be fully enforced once the UI flow has
|
||||
begun:
|
||||
|
||||
- A selection the user already accepted — including any unsaved-edit dialog decision and the switch
|
||||
itself — is a UI decision and is **not** reversed. A cancellation that lands after the modal has
|
||||
been answered leaves that accepted selection in place.
|
||||
- Cancellation only skips the remaining work. In the filament path, the slot assignment and the
|
||||
transaction creation are skipped only when the cancellation is observed **before** the slot mapping
|
||||
(`bind_filament_target` checks the `CancelProbe` immediately before it reassigns the slot); the
|
||||
transaction is likewise skipped when cancellation is observed before it is created.
|
||||
- A narrow window remains where a cancellation and the operation's start race: if the cancel lands
|
||||
just as the UI callback begins, the selection may still start. Whichever side wins, the coroutine
|
||||
observes `asyncio.CancelledError`, never a plugin error.
|
||||
|
||||
A finished `commit()` is unaffected by a later cancellation.
|
||||
|
||||
A plugin's own `asyncio.CancelledError` is treated as a cancellation only when `cancel_requested` was
|
||||
set; otherwise it stays a genuine error (see `run_invocation`), so a plugin that cancels its own task
|
||||
does not masquerade as an unload cancellation.
|
||||
|
||||
### Unload and shutdown drain
|
||||
|
||||
Unloading or shutting down must guarantee that no plugin Python code runs after its module is
|
||||
unloaded, and that a posted UI selection cannot mutate presets after teardown.
|
||||
|
||||
- `PluginManager::unload_plugin` and `unload_all_plugins` call
|
||||
`PluginHostUi::abandon_pending_async(plugin_key)` first, so the earliest possible UI callback
|
||||
cannot resume the plugin's coroutine into a run that is being cancelled.
|
||||
- `PluginManager::cancel_and_wait_for_capabilities` calls `on_cancelled()` first (so the plugin can
|
||||
observe state while the run is intact), then `ScriptAsyncRuntime::cancel_plugin`, then drains.
|
||||
The drain is **unbounded while a Script invocation worker or a posted host UI callback for the
|
||||
plugin is outstanding** — its loop-exit condition is `all_released && !invocation_active &&
|
||||
!ui_pending`. `has_active_run()` distinguishes a run that was cancelled before it started from one
|
||||
whose worker is still live; `pending_async_count()` counts posted UI callbacks whose weak
|
||||
registration has not expired. On the GUI thread the drain pumps `wxYield()` so a posted callback
|
||||
(possibly a modal) can settle instead of being starved by the blocking wait. Only the non-Script
|
||||
path keeps the historical bounded 2-second grace: when neither an invocation nor a UI callback is
|
||||
pending, the drain stops waiting for capability references at the grace deadline and logs any
|
||||
remaining ones.
|
||||
- `PluginManager::shutdown` detaches the libslic3r hooks (`plugin_hooks::uninstall`), stops new
|
||||
loads, waits for in-flight loads, unloads every plugin (draining the async runs above), then calls
|
||||
`ScriptAsyncRuntime::instance().shutdown()`, which refuses new runs and waits without a deadline
|
||||
for every invocation worker to finish — nothing is torn down by force. `ScriptAsyncRuntime::reset()`
|
||||
re-arms the runtime so a later `initialize()` in the same process can start runs again.
|
||||
|
||||
Cancellation triggered by unload or shutdown is a separate, expected completion, not a plugin
|
||||
failure. `run_invocation` marks a run whose `cancel_requested` was set as `completion.cancelled` and
|
||||
clears any error, whatever the coroutine body did. `launch_script_capability` records a plugin error
|
||||
only when `!completion.cancelled && !completion.error.empty()`; `run_script_capability` reports an
|
||||
empty error for a cancelled completion; and the two GUI launchers treat `cancelled` as neither error
|
||||
nor success (the speed dial stays silent, the Plugins dialog updates no status). A plugin's own
|
||||
`CancelledError` is not counted as this expected termination unless `cancel_requested` is set, so a
|
||||
self-cancelled plugin still records its failure.
|
||||
|
||||
## Supported and unsupported capabilities
|
||||
|
||||
| Capability | Async support | Reason |
|
||||
| --- | --- | --- |
|
||||
| Script | `def execute` and `async def execute` both run on the per-invocation worker | The runtime owns the worker and, for a coroutine, a per-invocation loop; nothing about a Script invocation borrows C++ state. |
|
||||
| SlicingPipeline | Synchronous only | `execute(ctx)` borrows `Print`/`PrintObject` for the callback's duration, and the UI thread can be blocked on the slicing worker. `PluginHooks.cpp` rejects an async override: `cap->execute()` casts its Python result to `ExecutionResult`, and a coroutine object cannot be cast, so it surfaces as a `SlicingError` rather than being scheduled. |
|
||||
| PrinterConnection, Pages, Analysis, Importer, Exporter, Visualization, ResourceTexture | Unchanged | Not part of this bridge; they keep their existing synchronous contract. |
|
||||
|
||||
## Deadlock constraints
|
||||
|
||||
- **Never block the UI thread on plugin work.** Every Script launch path goes through
|
||||
`launch_script_capability`; the speed-dial `ActionRegistry` action and the Plugins dialog launcher
|
||||
return immediately and report the outcome later (see Examples).
|
||||
- **Never call `orca.host.ui.*` from a slicing hook.** The slicing worker runs while the UI thread
|
||||
can be parked on it.
|
||||
- **Resolve an awaitable's Future only with `loop.call_soon_threadsafe`.** `AsyncFuture::complete`/
|
||||
`fail` do this; the UI thread never touches the loop or the Future directly.
|
||||
- **Create host awaitables only on the worker with a running loop.** `AsyncFuture::create()` binds
|
||||
`asyncio.get_running_loop()`; `begin_update()`'s binding requires this.
|
||||
- **No pointer crosses a suspension.** Awaitables carry copied values; the `SlicingPipelineContext`
|
||||
graph is valid only within one synchronous `execute(ctx)`.
|
||||
|
||||
## File and symbol map
|
||||
|
||||
Script execution runtime:
|
||||
|
||||
- `src/slic3r/plugin/PluginManager.hpp` / `.cpp` — `ScriptAsyncRuntime` (`instance`, `start`,
|
||||
`cancel_plugin`, `has_active_run`, `shutdown`, `reset`, `current_loop`), `ScriptCompletion`,
|
||||
`ScriptCompletionFn`, `ScriptLaunchResult`, `launch_script_capability`, `run_script_capability`.
|
||||
- `src/slic3r/plugin/PyPluginTrampoline.hpp` — `invoke_script_execute`, `ScriptExecuteInvocation`,
|
||||
`py_is_coroutine`.
|
||||
- `src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp` / `ScriptPluginCapabilityTrampoline.hpp`
|
||||
— `ScriptPluginCapability::execute`, `PyScriptPluginCapabilityTrampoline`.
|
||||
|
||||
Lifetime and GIL primitives:
|
||||
|
||||
- `src/slic3r/plugin/PythonInterpreter.hpp` — `PythonInterpreter`, `PythonRuntimeLease`,
|
||||
`PythonGILState`.
|
||||
- `src/slic3r/plugin/PythonPluginInterface.hpp` — `PluginCapabilityInterface`, `RefCounter`,
|
||||
`increment`/`decrement`/`ref_count`.
|
||||
- `src/slic3r/plugin/PluginAuditManager.hpp` / `.cpp` — `ScopedPluginAuditContext`,
|
||||
`request_filesystem_read_permissions`, `request_permission` (fail-closed off the main thread).
|
||||
|
||||
UI suspension and lifecycle tracking:
|
||||
|
||||
- `src/slic3r/plugin/host/PluginHostUi.hpp` / `.cpp` — `AsyncFuture` (`create`, `complete`, `fail`,
|
||||
`abandon`, `is_cancelled`), `make_future_resolver`, `make_cancellation_watcher`,
|
||||
`PluginHostUi::post_to_ui_thread`, `require_ui_application`, `track_pending_async`,
|
||||
`abandon_pending_async`, `pending_async_count`, `run_on_ui_thread`/`run_on_ui_blocking`.
|
||||
- `GUI::wxGetApp().CallAfter` — the post-to-UI-thread primitive.
|
||||
|
||||
Preset targets:
|
||||
|
||||
- `src/slic3r/plugin/host/PluginHostPresets.cpp` — `PresetEditor`, `PresetTransaction`,
|
||||
`bind_preset_target`, `bind_filament_target`, `begin_preset_update_async`,
|
||||
`begin_filament_update_async`, `PresetSelectionCancelled`, `SelectionCancelled`,
|
||||
`SelectionAbandoned`, `CancelProbe`, `resolve_combo_preset`/`resolve_preset_in_combo`.
|
||||
- `src/slic3r/GUI/Tab.hpp` / `.cpp` — `Tab::select_preset`, `Tab::apply_preset_config_values`,
|
||||
`Tab::commit_preset_config_value`.
|
||||
|
||||
Launch paths:
|
||||
|
||||
- `src/slic3r/GUI/ActionRegistry.cpp` — `PluginScriptAction::run`, `notify_script_outcome`.
|
||||
- `src/slic3r/GUI/PluginsDialog.cpp` — `PluginsDialog::run_script_plugin_capability`.
|
||||
|
||||
Slicing (synchronous only):
|
||||
|
||||
- `src/slic3r/plugin/PluginHooks.cpp` — `install_slicing_pipeline_hook`.
|
||||
- `src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp` —
|
||||
`SlicingPipelineContext`, `execute`.
|
||||
|
||||
Unload/shutdown:
|
||||
|
||||
- `src/slic3r/plugin/PluginManager.cpp` — `unload_plugin`, `unload_all_plugins`,
|
||||
`cancel_and_wait_for_capabilities`, `shutdown`.
|
||||
|
||||
## Examples
|
||||
|
||||
An `async def execute` that awaits a preset update. `execute` runs on the background Script worker
|
||||
with its own asyncio loop; `begin_update()` suspends the coroutine until any selection / unsaved-edit
|
||||
dialog is answered, without blocking the UI thread; `set()`/`commit()` run synchronously on the
|
||||
worker. This is the shape used by `sandboxes/orca_preset_transaction_demo.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
import orca
|
||||
|
||||
|
||||
class AutoTune(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Auto tune"
|
||||
|
||||
async def execute(self):
|
||||
# begin_update() is awaitable: it resolves once the user answers any
|
||||
# selection / unsaved-edit dialog. The UI thread stays responsive.
|
||||
tx = await orca.host.preset.process.begin_update(preset_name="0.20mm Standard")
|
||||
tx.set("layer_height", "0.2")
|
||||
tx.commit()
|
||||
return orca.ExecutionResult.success("process preset updated")
|
||||
```
|
||||
|
||||
The window-driven demo shows the same pattern where the plugin's own UI must hand work to the worker:
|
||||
`on_message` fires on the UI thread and only enqueues onto the worker loop with
|
||||
`loop.call_soon_threadsafe`; `execute` consumes that queue and awaits `begin_update()` per command.
|
||||
|
||||
Continuation sequence for one `await begin_update(...)`:
|
||||
|
||||
```text
|
||||
Script worker (owned, per invocation) UI thread
|
||||
loop.run_until_complete(task)
|
||||
await begin_update() ─────────────────► post_to_ui_thread
|
||||
(suspended) bind_*_target →
|
||||
Tab::select_preset() … dialog
|
||||
settles
|
||||
loop.call_soon_threadsafe(fut.set_result) ◄──── AsyncFuture::complete
|
||||
coroutine resumes with PresetTransaction
|
||||
execute() returns ExecutionResult
|
||||
```
|
||||
|
||||
A plain `def execute` needs no loop and runs to completion on the same worker; both kinds are
|
||||
launched with `launch_script_capability`, so the caller (UI thread) never blocks. A launch failure is
|
||||
reported inline; a started run reports its outcome through the completion callback — the speed dial
|
||||
shows "started" and pushes a completion notification, and the Plugins dialog updates its status when
|
||||
`finish` runs.
|
||||
@@ -0,0 +1,300 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# id = "preset-transaction-demo"
|
||||
# name = "Preset Transaction Demo"
|
||||
# description = "Manual test for the staged begin_update()/set()/commit() preset API."
|
||||
# author = "OrcaSlicer contributors"
|
||||
# version = "0.1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
"""Preset Transaction Demo — a manual test harness for the staged preset API.
|
||||
|
||||
Install this file in the local ``orca_plugins`` directory, enable the plugin, then run
|
||||
"Preset Transaction Demo" from the Plugins dialog. The window drives
|
||||
``orca.host.preset.<category>.begin_update(...)`` and shows the returned
|
||||
``PresetUpdateResult``.
|
||||
|
||||
This is an asynchronous Script capability: ``execute`` is ``async def`` and runs on the
|
||||
background Script worker's own asyncio event loop. ``begin_update()`` is awaitable — it
|
||||
suspends the coroutine until the normal preset-selection / unsaved-edit dialog is answered,
|
||||
without blocking Orca's UI thread. ``set()`` and ``commit()`` remain synchronous and are
|
||||
only valid on that worker.
|
||||
|
||||
The window's own messages arrive on the UI thread, so ``on_message`` cannot await directly.
|
||||
Instead the handler enqueues each message onto the worker loop (``call_soon_threadsafe``) and
|
||||
``execute`` consumes them, one coroutine at a time, so every command can ``await
|
||||
begin_update``. Closing the window enqueues a sentinel that ends ``execute``.
|
||||
|
||||
Categories
|
||||
* process / printer: target is the active preset, or the name typed into the target
|
||||
field, resolved through the normal preset-selection flow;
|
||||
* filament: target is the 1-based slot number, with an optional preset name that
|
||||
selects (and assigns) that preset for the slot.
|
||||
|
||||
Buttons exercise the documented outcomes:
|
||||
* "Commit" stages every key/value row and commits the batch;
|
||||
* "Duplicate key" stages the same key twice and shows the rejected conflict;
|
||||
* "Invalid key" stages a key that belongs to no preset and shows the commit error.
|
||||
|
||||
Nothing here saves to disk: an applied change leaves the edited preset dirty until the
|
||||
user saves it through Orca's normal preset workflow. Values use Orca's serialized config
|
||||
syntax (numbers plain, percentages with ``%``, booleans ``1``/``0``, lists ``;``-separated).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import orca
|
||||
|
||||
CATEGORIES = ("process", "printer", "filament")
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body { font: 13px/1.4 system-ui, sans-serif; margin: 16px; color: #222; }
|
||||
fieldset { border: 1px solid #ccc; border-radius: 6px; margin: 0 0 12px; padding: 10px 12px; }
|
||||
legend { font-weight: 600; padding: 0 4px; }
|
||||
label { display: inline-block; min-width: 90px; }
|
||||
input, select { font: inherit; padding: 3px 5px; margin: 2px 0; }
|
||||
input[type=text] { width: 260px; }
|
||||
.row { margin: 3px 0; }
|
||||
button { font: inherit; padding: 5px 10px; margin: 2px 6px 2px 0; cursor: pointer; }
|
||||
#out { white-space: pre-wrap; background: #f6f6f6; border: 1px solid #ddd; border-radius: 6px;
|
||||
padding: 10px; min-height: 70px; }
|
||||
.err { color: #b00020; }
|
||||
.ok { color: #0a7a2f; }
|
||||
</style></head>
|
||||
<body>
|
||||
<fieldset><legend>Target</legend>
|
||||
<div class="row">
|
||||
<label>Category</label>
|
||||
<select id="category">
|
||||
<option value="process">process</option>
|
||||
<option value="printer">printer</option>
|
||||
<option value="filament">filament</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" id="slotRow" style="display:none">
|
||||
<label>Slot (1-based)</label><input id="slot" type="text" value="1" style="width:80px">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Preset name</label>
|
||||
<input id="preset" type="text" placeholder="(blank = current / slot assignment)">
|
||||
</div>
|
||||
<div class="row" style="color:#666">Edited now: <span id="current">?</span></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset><legend>Staged settings (serialized values)</legend>
|
||||
<div class="row"><input id="k1" type="text" placeholder="key, e.g. layer_height"> =
|
||||
<input id="v1" type="text" placeholder="value, e.g. 0.25"></div>
|
||||
<div class="row"><input id="k2" type="text" placeholder="key (optional)"> =
|
||||
<input id="v2" type="text" placeholder="value"></div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<button onclick="send('commit')">Commit</button>
|
||||
<button onclick="send('duplicate')">Duplicate key</button>
|
||||
<button onclick="send('invalid')">Invalid key</button>
|
||||
<button onclick="send('refresh')">Refresh</button>
|
||||
</div>
|
||||
<p id="out">Ready.</p>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
function pairs() {
|
||||
const out = [];
|
||||
for (const n of [1, 2]) {
|
||||
const k = $('k' + n).value.trim();
|
||||
if (k) out.push({ key: k, value: $('v' + n).value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function send(command) {
|
||||
const category = $('category').value;
|
||||
$('slotRow').style.display = category === 'filament' ? '' : 'none';
|
||||
const message = {
|
||||
command,
|
||||
category,
|
||||
preset_name: $('preset').value.trim(),
|
||||
slot: parseInt($('slot').value, 10) || 1,
|
||||
pairs: pairs(),
|
||||
};
|
||||
orca.postMessage(message);
|
||||
}
|
||||
function show(payload) {
|
||||
if (payload.error) { $('out').className = 'err'; $('out').textContent = payload.error; return; }
|
||||
if (payload.message) { $('out').className = 'ok'; $('out').textContent = payload.message; }
|
||||
if (payload.current !== undefined) $('current').textContent = payload.current;
|
||||
}
|
||||
orca.onMessage(show);
|
||||
send('refresh');
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
async def _begin(category, preset_name, slot):
|
||||
"""Create a transaction for one category, following the two begin_update arities.
|
||||
|
||||
begin_update() is awaitable: it suspends until any selection / unsaved-edit dialog is
|
||||
answered, without blocking Orca's UI thread.
|
||||
"""
|
||||
editor = getattr(orca.host.preset, category)
|
||||
# The binding takes a plain string, so an empty field means "" (keep the current / slot target).
|
||||
if category == "filament":
|
||||
return await editor.begin_update(slot, preset_name)
|
||||
return await editor.begin_update(preset_name)
|
||||
|
||||
|
||||
def _result(payload, category):
|
||||
return {
|
||||
"category": category,
|
||||
"target_name": payload.target_name,
|
||||
"selection_changed": payload.selection_changed,
|
||||
"slot_number": payload.slot_number,
|
||||
"slot_assignment_changed": payload.slot_assignment_changed,
|
||||
"shared_slots": list(payload.shared_slots),
|
||||
"keys": list(payload.keys),
|
||||
"dirty": payload.dirty,
|
||||
}
|
||||
|
||||
|
||||
def _current(bundle, category, slot):
|
||||
if category == "filament":
|
||||
presets = list(bundle.current_filament_presets())
|
||||
if 1 <= slot <= len(presets) and presets[slot - 1] is not None:
|
||||
return "%s (slot %d)" % (presets[slot - 1].name, slot)
|
||||
return "<slot %d has no preset>" % slot
|
||||
if category == "printer":
|
||||
return bundle.current_printer_preset().name
|
||||
return bundle.current_process_preset().name
|
||||
|
||||
|
||||
class PresetTransactionDemo(orca.script.ScriptPluginCapabilityBase):
|
||||
window = None
|
||||
category = "process"
|
||||
slot = 1
|
||||
loop = None
|
||||
|
||||
def get_name(self):
|
||||
return "Preset transaction demo"
|
||||
|
||||
async def execute(self):
|
||||
"""Open the window and drive its messages, awaiting begin_update per command.
|
||||
|
||||
Runs on the background Script worker's asyncio loop. The window's on_message fires on
|
||||
the UI thread, so it only enqueues; execute consumes the queue here, where awaits are
|
||||
legal, and keeps the loop alive until the window closes.
|
||||
"""
|
||||
try:
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self._messages = asyncio.Queue()
|
||||
self.category = "process"
|
||||
self.slot = 1
|
||||
self.window = orca.host.ui.create_window(
|
||||
title="Preset transaction demo", html=PAGE, width=760, height=520,
|
||||
on_message=self._enqueue, on_close=self._on_close,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
message = await self._messages.get()
|
||||
if message is None: # sentinel: the window closed
|
||||
break
|
||||
await self._handle(message)
|
||||
finally:
|
||||
self.window = None
|
||||
return orca.ExecutionResult.success("Preset transaction demo closed.")
|
||||
except Exception as error:
|
||||
text = "Preset transaction demo failed: %s" % error
|
||||
orca.host.ui.message(text, title="Preset transaction demo", buttons="ok", icon="error")
|
||||
return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, text)
|
||||
|
||||
# --- UI thread -> worker loop hand-off ------------------------------------------------
|
||||
def _enqueue(self, message):
|
||||
"""on_message: runs on the UI thread. Hand the message to the worker loop."""
|
||||
try:
|
||||
self.loop.call_soon_threadsafe(self._messages.put_nowait, message)
|
||||
except Exception:
|
||||
# The loop is already gone (plugin unloading); drop the message.
|
||||
pass
|
||||
|
||||
def _on_close(self):
|
||||
"""on_close: runs on the UI thread. Wake execute so it returns and the loop closes."""
|
||||
try:
|
||||
self.loop.call_soon_threadsafe(self._messages.put_nowait, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- worker loop ----------------------------------------------------------------------
|
||||
async def _handle(self, message):
|
||||
message = message or {}
|
||||
category = message.get("category", "process")
|
||||
if category not in CATEGORIES:
|
||||
self._post(error="Unknown category %r" % (category,))
|
||||
return
|
||||
self.category = category
|
||||
self.slot = message.get("slot", 1) if isinstance(message.get("slot"), int) else 1
|
||||
preset_name = message.get("preset_name") or ""
|
||||
|
||||
try:
|
||||
command = message.get("command")
|
||||
if command == "refresh":
|
||||
self._post()
|
||||
return
|
||||
|
||||
if command == "duplicate":
|
||||
transaction = await _begin(category, preset_name, self.slot)
|
||||
transaction.set("layer_height", "0.2")
|
||||
transaction.set("layer_height", "0.25")
|
||||
self._post(error="Duplicate key was not rejected (unexpected)")
|
||||
return
|
||||
|
||||
if command == "invalid":
|
||||
transaction = await _begin(category, preset_name, self.slot)
|
||||
transaction.set("this_is_not_a_preset_setting", "1")
|
||||
transaction.commit()
|
||||
self._post(error="Invalid key was not rejected (unexpected)")
|
||||
return
|
||||
|
||||
if command == "commit":
|
||||
pairs = message.get("pairs") or []
|
||||
if not pairs:
|
||||
self._post(error="Stage at least one key/value row.")
|
||||
return
|
||||
transaction = await _begin(category, preset_name, self.slot)
|
||||
for entry in pairs:
|
||||
key = entry.get("key")
|
||||
if not isinstance(key, str) or not key:
|
||||
raise RuntimeError("A setting key is required in every row")
|
||||
value = entry.get("value")
|
||||
transaction.set(key, value if isinstance(value, str) else str(value))
|
||||
result = transaction.commit()
|
||||
self._post(
|
||||
payload=_result(result, category),
|
||||
message="Committed %d setting(s) to %s \"%s\"."
|
||||
% (len(result.keys), category, result.target_name),
|
||||
)
|
||||
return
|
||||
|
||||
self._post(error="Unknown command %r" % (command,))
|
||||
except Exception as error:
|
||||
self._post(error="%s: %s" % (type(error).__name__, error))
|
||||
|
||||
def _post(self, payload=None, message="", error=""):
|
||||
payload = dict(payload or {})
|
||||
if message:
|
||||
payload["message"] = message
|
||||
if error:
|
||||
payload["error"] = error
|
||||
try:
|
||||
payload["current"] = _current(orca.host.preset_bundle(), self.category, self.slot)
|
||||
except Exception as state_error:
|
||||
payload["current"] = "unavailable: %s" % state_error
|
||||
if self.window is not None and self.window.is_open():
|
||||
self.window.post(payload)
|
||||
|
||||
|
||||
@orca.plugin
|
||||
class PresetTransactionDemoPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(PresetTransactionDemo)
|
||||
@@ -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.<type>.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"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
:root {
|
||||
--bg: var(--orca-bg, #fafafa);
|
||||
--fg: var(--orca-fg, #1b1e23);
|
||||
--muted: var(--orca-muted, #6b7280);
|
||||
--border: var(--orca-border, #dadee3);
|
||||
--accent: var(--orca-accent, #0e7c86);
|
||||
--accent-fg: var(--orca-accent-fg, #ffffff);
|
||||
--ui: var(--orca-font, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--surface: rgba(127, 127, 127, .09);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--orca-bg, #23262a);
|
||||
--fg: var(--orca-fg, #e6e8ea);
|
||||
--muted: var(--orca-muted, #9aa1a9);
|
||||
--border: var(--orca-border, #383c42);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 13px/1.45 var(--ui);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
button { font: inherit; cursor: pointer; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
/* header ------------------------------------------------------------- */
|
||||
header {
|
||||
flex: none; display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo { color: var(--accent); font-size: 15px; line-height: 1; }
|
||||
h1 { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: .01em; }
|
||||
|
||||
.seg {
|
||||
display: flex; gap: 2px; margin-left: auto; padding: 2px;
|
||||
border: 1px solid var(--border); border-radius: 9px; background: var(--surface);
|
||||
}
|
||||
.seg button {
|
||||
border: 0; background: transparent; color: var(--muted);
|
||||
padding: 4px 13px; border-radius: 7px;
|
||||
transition: background .15s ease, color .15s ease;
|
||||
}
|
||||
.seg button:hover { color: var(--fg); }
|
||||
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
.iconbtn {
|
||||
width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 7px;
|
||||
background: transparent; color: var(--fg); display: grid; place-items: center;
|
||||
font-size: 13px; transition: background .15s ease;
|
||||
}
|
||||
.iconbtn:hover:enabled { background: var(--surface); }
|
||||
.iconbtn:disabled { opacity: .5; }
|
||||
|
||||
/* preset bar --------------------------------------------------------- */
|
||||
.presetbar {
|
||||
flex: none; display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 14px; border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
}
|
||||
.presetbar .name {
|
||||
font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.tag {
|
||||
border: 1px solid var(--border); color: var(--muted); border-radius: 999px;
|
||||
padding: 1px 8px; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.tag.on { border-color: transparent; background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
/* filament slots ----------------------------------------------------- */
|
||||
.slotbar { flex: none; padding: 7px 14px 8px; border-bottom: 1px solid var(--border); }
|
||||
.slotrow { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.slotlabel {
|
||||
flex: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.slots { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; gap: 6px;
|
||||
max-height: 84px; overflow-y: auto; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex: none; max-width: 190px;
|
||||
padding: 2px 10px 2px 3px; border: 1px solid var(--border); border-radius: 999px;
|
||||
background: transparent; color: var(--fg);
|
||||
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.chip:hover { background: var(--surface); }
|
||||
.chip .n {
|
||||
flex: none; min-width: 19px; height: 19px; padding: 0 4px; border-radius: 999px;
|
||||
display: grid; place-items: center; background: var(--surface); color: var(--muted);
|
||||
font-size: 11px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* the slot whose preset the filament settings already have open, so applying switches nothing */
|
||||
.chip.open .n { box-shadow: inset 0 0 0 1px var(--accent); color: var(--fg); }
|
||||
.chip .s {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
.chip[aria-pressed="true"] .n {
|
||||
background: rgba(255, 255, 255, .22); color: var(--accent-fg); box-shadow: none;
|
||||
}
|
||||
.chip[aria-pressed="true"] .s { color: var(--accent-fg); }
|
||||
/* a slot that mounts the preset the page is showing: editing either changes both */
|
||||
.chip.linked::after {
|
||||
content: ""; flex: none; width: 5px; height: 5px;
|
||||
border-radius: 50%; background: var(--accent);
|
||||
}
|
||||
.slotnote {
|
||||
margin-top: 7px; padding-left: 8px; border-left: 2px solid var(--accent);
|
||||
font-size: 11px; line-height: 1.5; color: var(--muted);
|
||||
}
|
||||
.slotnote:empty { display: none; }
|
||||
|
||||
/* panes -------------------------------------------------------------- */
|
||||
main { flex: 1; display: flex; min-height: 0; }
|
||||
|
||||
.list-pane {
|
||||
flex: none; width: 45%; min-width: 250px; min-height: 0;
|
||||
display: flex; flex-direction: column; border-right: 1px solid var(--border);
|
||||
}
|
||||
.search {
|
||||
flex: none; display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search input {
|
||||
flex: 1; min-width: 0; font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 7px; padding: 4px 9px;
|
||||
}
|
||||
.rows { flex: 1; min-height: 0; overflow-y: auto; padding: 3px 0; }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: baseline; gap: 8px; width: 100%;
|
||||
padding: 5px 10px; text-align: left; border: 0; border-left: 3px solid transparent;
|
||||
background: transparent; color: var(--fg);
|
||||
}
|
||||
.row:hover { background: var(--surface); }
|
||||
.row[aria-selected="true"] { background: var(--surface); border-left-color: var(--accent); }
|
||||
.row .k {
|
||||
flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.row .v {
|
||||
flex: none; max-width: 40%; font-family: var(--mono); font-size: 11px;
|
||||
color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.row .dot {
|
||||
flex: none; align-self: center; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent); visibility: hidden;
|
||||
}
|
||||
.row.changed .dot { visibility: visible; }
|
||||
@keyframes flash {
|
||||
0% { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
100% { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.row.flash { animation: flash .7s ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row.flash { animation: none; }
|
||||
}
|
||||
|
||||
.empty { color: var(--muted); padding: 14px 12px; }
|
||||
.nil { font-style: italic; opacity: .75; }
|
||||
|
||||
/* editor ------------------------------------------------------------- */
|
||||
.edit-pane {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 9px;
|
||||
padding: 12px 14px; overflow-y: auto;
|
||||
}
|
||||
.edit-key { font-family: var(--mono); font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||
.current {
|
||||
margin: 0; max-height: 108px; overflow: auto;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 7px;
|
||||
padding: 7px 10px; white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
textarea {
|
||||
width: 100%; min-height: 74px; resize: vertical;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 7px; padding: 7px 10px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; align-items: center; }
|
||||
button.primary {
|
||||
padding: 5px 14px; border-radius: 7px; border: 1px solid var(--accent);
|
||||
background: var(--accent); color: var(--accent-fg);
|
||||
}
|
||||
button.ghost {
|
||||
padding: 5px 12px; border-radius: 7px; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--fg);
|
||||
}
|
||||
button.primary:disabled, button.ghost:disabled { opacity: .5; cursor: default; }
|
||||
button.primary:hover:enabled { filter: brightness(1.08); }
|
||||
button.ghost:hover:enabled { background: var(--surface); }
|
||||
|
||||
.notice {
|
||||
padding: 8px 11px; border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||
border-radius: 7px; background: var(--surface);
|
||||
font-size: 12px; line-height: 1.5; color: var(--muted);
|
||||
}
|
||||
.notice b { color: var(--fg); font-weight: 600; }
|
||||
.notice:empty { display: none; }
|
||||
|
||||
.syntax {
|
||||
margin-top: 2px; padding: 9px 11px; border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent); border-radius: 7px;
|
||||
font-size: 12px; line-height: 1.55; color: var(--muted);
|
||||
}
|
||||
.syntax b { color: var(--fg); font-weight: 600; }
|
||||
.syntax code { font-family: var(--mono); font-size: 11.5px; color: var(--fg); }
|
||||
|
||||
/* status ------------------------------------------------------------- */
|
||||
footer {
|
||||
flex: none; min-height: 30px; padding: 7px 14px;
|
||||
border-top: 1px solid var(--border); font-size: 12px; color: var(--muted);
|
||||
}
|
||||
footer.ok { color: #0f8a5f; }
|
||||
footer.err { color: #c8382c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">◈</span>
|
||||
<h1>Preset update</h1>
|
||||
<div class="seg" id="seg"></div>
|
||||
<button class="iconbtn" id="refresh" title="Reload the current values">⟳</button>
|
||||
<button class="iconbtn" id="close" title="Close">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="presetbar">
|
||||
<span class="name" id="pname">Loading…</span>
|
||||
<span id="ptag"></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="tag" id="pcount">0 settings</span>
|
||||
</div>
|
||||
|
||||
<div class="slotbar" id="slotbar" hidden>
|
||||
<div class="slotrow">
|
||||
<span class="slotlabel">Filament slots</span>
|
||||
<div class="slots" id="slots"></div>
|
||||
</div>
|
||||
<div class="slotnote" id="slotnote"></div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="list-pane">
|
||||
<div class="search">
|
||||
<input id="q" type="search" placeholder="Search settings…" autocomplete="off" spellcheck="false">
|
||||
<span class="tag" id="qcount">0</span>
|
||||
</div>
|
||||
<div class="rows" id="rows"></div>
|
||||
</section>
|
||||
|
||||
<section class="edit-pane">
|
||||
<div class="edit-key" id="ekey">Select a setting</div>
|
||||
<div class="lbl">Current value</div>
|
||||
<pre class="current" id="ecur">—</pre>
|
||||
<label class="lbl" for="evalue">New value</label>
|
||||
<textarea id="evalue" spellcheck="false" autocomplete="off"
|
||||
placeholder="Serialized value" disabled></textarea>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="actions">
|
||||
<button class="primary" id="apply" disabled>Apply</button>
|
||||
<button class="ghost" id="reset" disabled>Reset to current</button>
|
||||
</div>
|
||||
<div class="syntax">
|
||||
<b>Values use OrcaSlicer's serialized config syntax</b> — the same text a preset
|
||||
file stores. Numbers are plain (<code>0.2</code>, <code>3</code>) and percentages keep
|
||||
their sign (<code>15%</code>). On/off is <code>1</code> or <code>0</code>. A choice uses
|
||||
the setting's own key, e.g. <code>grid</code>. Lists are semicolon-separated
|
||||
(<code>0.4;0.4;0.4</code>, or <code>"#FF0000";"#00FF00"</code>). Text keeps its
|
||||
escapes, so a line break stays <code>\n</code>. 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.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer id="status"></footer>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
const SEG = [['process', 'Process'], ['printer', 'Printer'], ['filament', 'Filament']];
|
||||
const IDLE = 'Edits apply to the preset shown here and stay unsaved until you save '
|
||||
+ 'or export it.';
|
||||
|
||||
const S = {
|
||||
kind: 'process',
|
||||
slot: 1, // selected filament slot (one-based); ignored by the other kinds
|
||||
slots: [], // [{slot, name, open}] from current_filament_presets()
|
||||
preset: null,
|
||||
rows: [],
|
||||
selected: null,
|
||||
changed: {}, // "kind/preset/key" -> true, for edits made in this window
|
||||
pending: null, // {kind, slot, preset, key} of an apply awaiting its reply
|
||||
error: '', // the last error the plugin reported, if any
|
||||
assigned: true, // filament only: the shown slot mounts a preset, so it is editable
|
||||
switches: false, // filament only: applying will select that preset in the filament tab
|
||||
editedName: null, // filament only: the preset the filament settings have open
|
||||
busy: true,
|
||||
filter: '',
|
||||
statusText: '',
|
||||
statusCls: '',
|
||||
};
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s == null ? '' : s)
|
||||
.replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
|
||||
/* ------------------------------------------------------------------ paint */
|
||||
function paint() {
|
||||
paintSeg();
|
||||
const p = S.preset;
|
||||
const slotTag = S.kind === 'filament' && S.slot
|
||||
? '<span class="tag">slot ' + S.slot + '</span>' : '';
|
||||
$('pname').textContent = p ? p.name : (S.busy ? 'Loading…' : 'No preset');
|
||||
$('pname').title = p ? p.name : '';
|
||||
$('ptag').innerHTML = slotTag + (p
|
||||
? '<span class="tag">' + (p.is_system ? 'system' : 'user') + '</span>'
|
||||
+ (p.is_dirty ? '<span class="tag on">modified</span>' : '<span class="tag">saved</span>')
|
||||
: '');
|
||||
$('pcount').textContent = S.rows.length + (S.rows.length === 1 ? ' setting' : ' settings');
|
||||
$('refresh').disabled = S.busy;
|
||||
paintSlots();
|
||||
paintNotice();
|
||||
renderList();
|
||||
paintEditor();
|
||||
paintStatus();
|
||||
}
|
||||
|
||||
function paintSeg() {
|
||||
// Built once (see buildSeg) so a click never loses focus; only the state is repainted.
|
||||
document.querySelectorAll('#seg button').forEach(b =>
|
||||
b.setAttribute('aria-pressed', String(b.dataset.kind === S.kind)));
|
||||
}
|
||||
|
||||
/* the filament slot strip: one chip per mounted slot, shown for filament only */
|
||||
function paintSlots() {
|
||||
const show = S.kind === 'filament' && (S.slots.length > 0 || S.busy);
|
||||
$('slotbar').hidden = !show;
|
||||
if (!show) { $('slots').innerHTML = ''; $('slotnote').innerHTML = ''; return; }
|
||||
// Rebuilding the chips drops keyboard focus; put it back on the same slot's chip.
|
||||
const focused = document.activeElement && document.activeElement.dataset
|
||||
? document.activeElement.dataset.slot : null;
|
||||
$('slots').innerHTML = S.slots.map(s => {
|
||||
const selected = s.slot === S.slot;
|
||||
const linked = !selected && sharesSelected(s.name);
|
||||
const label = s.name == null ? '<span class="nil">empty slot</span>' : esc(s.name);
|
||||
const hint = s.name ? ': ' + s.name : ': no preset';
|
||||
return '<button type="button" class="chip' + (linked ? ' linked' : '')
|
||||
+ (s.open ? ' open' : '') + '"'
|
||||
+ ' data-slot="' + s.slot + '" aria-pressed="' + selected + '"'
|
||||
+ ' title="' + esc('Filament slot ' + s.slot + hint
|
||||
+ (s.open ? ' — open in the filament settings' : '')) + '">'
|
||||
+ '<span class="n">' + s.slot + '</span><span class="s">' + label + '</span></button>';
|
||||
}).join('');
|
||||
$('slotnote').innerHTML = shareNote();
|
||||
if (focused) {
|
||||
const chip = document.querySelector('#slots .chip[data-slot="' + focused + '"]');
|
||||
if (chip) chip.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function slotName(slot) {
|
||||
const found = S.slots.find(s => s.slot === slot);
|
||||
return found && found.name ? found.name : null;
|
||||
}
|
||||
|
||||
// Applying selects the slot's preset first, so say when that switches the filament tab. An
|
||||
// empty slot is the only unavailable one, and it says so instead of offering an edit.
|
||||
function paintNotice() {
|
||||
const el = $('notice');
|
||||
if (S.kind !== 'filament' || S.busy) { el.innerHTML = ''; return; }
|
||||
// With nothing mounted the list already says so; avoid naming a slot number here.
|
||||
if (!S.slots.length) { el.innerHTML = ''; return; }
|
||||
const name = S.preset ? S.preset.name : null;
|
||||
if (!name) {
|
||||
el.innerHTML = 'Slot ' + S.slot + ' holds no filament preset. Assign one in the '
|
||||
+ 'OrcaSlicer filament settings first.';
|
||||
return;
|
||||
}
|
||||
if (!S.switches) { el.innerHTML = ''; return; }
|
||||
const open = S.editedName
|
||||
? ', while the filament settings have <b>' + esc(S.editedName) + '</b> open'
|
||||
: '';
|
||||
el.innerHTML = 'Slot ' + S.slot + ' uses <b>' + esc(name) + '</b>' + open
|
||||
+ '. Applying switches the filament tab to <b>' + esc(name) + '</b> first — Orca may '
|
||||
+ 'ask about unsaved changes there — then sets the value.';
|
||||
}
|
||||
|
||||
function sharesSelected(name) {
|
||||
const current = slotName(S.slot);
|
||||
return !!name && !!current && name === current;
|
||||
}
|
||||
|
||||
function listSlots(numbers) {
|
||||
if (numbers.length === 1) return String(numbers[0]);
|
||||
return numbers.slice(0, -1).join(', ') + ' and ' + numbers[numbers.length - 1];
|
||||
}
|
||||
|
||||
// Slots mounting the same preset are one preset, so say which slots an edit reaches.
|
||||
function shareNote() {
|
||||
const name = slotName(S.slot);
|
||||
if (!name) return '';
|
||||
const sharing = S.slots.filter(s => s.name === name).map(s => s.slot);
|
||||
if (sharing.length < 2) return '';
|
||||
return 'Slots ' + listSlots(sharing) + ' mount the same filament preset, so an edit '
|
||||
+ 'here changes every one of them.';
|
||||
}
|
||||
|
||||
function changedKey(presetName, key) {
|
||||
return S.kind + '/' + (presetName || '?') + '/' + key;
|
||||
}
|
||||
|
||||
function paintStatus() {
|
||||
const el = $('status');
|
||||
if (S.statusText) {
|
||||
el.textContent = S.statusText;
|
||||
el.className = S.statusCls;
|
||||
} else if (S.busy) {
|
||||
el.textContent = 'Working…';
|
||||
el.className = '';
|
||||
} else {
|
||||
el.textContent = IDLE;
|
||||
el.className = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
S.statusText = text || '';
|
||||
S.statusCls = cls || '';
|
||||
paintStatus();
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
const q = S.filter.trim().toLowerCase();
|
||||
if (!q) return S.rows;
|
||||
return S.rows.filter(r => r.k.toLowerCase().includes(q) || r.v.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const scroller = $('rows');
|
||||
const top = scroller.scrollTop;
|
||||
$('qcount').textContent = String(visibleRows().length);
|
||||
|
||||
if (!S.rows.length) {
|
||||
let text = 'This preset reports no settings.';
|
||||
if (S.busy) text = 'Loading settings…';
|
||||
else if (S.error) text = 'These values could not be read — see the message below.';
|
||||
else if (S.kind === 'filament' && !S.slots.length) text = 'No filament slots are mounted.';
|
||||
else if (S.kind === 'filament' && !S.preset) text = 'This filament slot has no preset.';
|
||||
scroller.innerHTML = '<div class="empty">' + text + '</div>';
|
||||
return;
|
||||
}
|
||||
const rows = visibleRows();
|
||||
if (!rows.length) {
|
||||
scroller.innerHTML = '<div class="empty">No setting matches “' + esc(S.filter) + '”.</div>';
|
||||
return;
|
||||
}
|
||||
scroller.innerHTML = rows.map(r => {
|
||||
const value = r.v === '' ? '<span class="nil">empty</span>' : esc(r.v);
|
||||
return '<button type="button" class="row'
|
||||
+ (S.changed[changedKey(S.preset && S.preset.name, r.k)] ? ' changed' : '') + '"'
|
||||
+ ' data-key="' + esc(r.k) + '"'
|
||||
+ ' aria-selected="' + (r.k === S.selected) + '"'
|
||||
+ ' title="' + esc(r.k + ' = ' + r.v) + '">'
|
||||
+ '<span class="k">' + esc(r.k) + '</span>'
|
||||
+ '<span class="v">' + value + '</span>'
|
||||
+ '<span class="dot"></span></button>';
|
||||
}).join('');
|
||||
scroller.scrollTop = top;
|
||||
}
|
||||
|
||||
function paintEditor() {
|
||||
const row = S.rows.find(r => r.k === S.selected) || null;
|
||||
// Every assigned slot is editable now that applying selects the slot's preset first, so
|
||||
// Apply is off only for an empty slot, a missing selection, or a request in flight.
|
||||
const selected = !!row && !S.busy;
|
||||
$('ekey').textContent = row ? row.k : 'Select a setting';
|
||||
$('ecur').textContent = row ? (row.v === '' ? '(empty)' : row.v) : '—';
|
||||
$('evalue').disabled = !selected;
|
||||
$('apply').disabled = !selected || !S.assigned;
|
||||
$('reset').disabled = !selected;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- actions */
|
||||
function buildSeg() {
|
||||
$('seg').innerHTML = SEG.map(([kind, label]) =>
|
||||
'<button type="button" data-kind="' + kind + '">' + label + '</button>').join('');
|
||||
}
|
||||
|
||||
function selectKey(key) {
|
||||
S.selected = key;
|
||||
const row = S.rows.find(r => r.k === key);
|
||||
$('evalue').value = row ? row.v : '';
|
||||
// Repaint the rows in place so the scroll position of a long list is kept.
|
||||
document.querySelectorAll('#rows .row').forEach(el =>
|
||||
el.setAttribute('aria-selected', String(el.dataset.key === key)));
|
||||
paintEditor();
|
||||
}
|
||||
|
||||
function switchKind(kind) {
|
||||
S.kind = kind;
|
||||
S.selected = null;
|
||||
S.pending = null;
|
||||
S.preset = null;
|
||||
S.rows = [];
|
||||
S.busy = true;
|
||||
$('evalue').value = '';
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: kind, slot: S.slot});
|
||||
}
|
||||
|
||||
// Two slots that mount the same preset show the same settings, so only a switch to a
|
||||
// different preset drops the current selection and the value being typed.
|
||||
function selectSlot(slot) {
|
||||
if (S.busy || slot === S.slot) return;
|
||||
const same = !!slotName(slot) && slotName(slot) === slotName(S.slot);
|
||||
S.slot = slot;
|
||||
S.pending = null;
|
||||
S.busy = true;
|
||||
if (!same) {
|
||||
S.preset = null;
|
||||
S.rows = [];
|
||||
S.selected = null;
|
||||
$('evalue').value = '';
|
||||
}
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: S.kind, slot: slot});
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (S.busy) return;
|
||||
S.busy = true;
|
||||
S.pending = null;
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: S.kind, slot: S.slot});
|
||||
}
|
||||
|
||||
function applyValue() {
|
||||
const row = S.rows.find(r => r.k === S.selected);
|
||||
// Mirror the Apply button: only a missing selection, a request in flight, or an empty
|
||||
// slot blocks an edit, so Enter cannot send one the button itself would refuse.
|
||||
if (!row || S.busy || !S.assigned) return;
|
||||
S.pending = {
|
||||
kind: S.kind, slot: S.slot,
|
||||
preset: S.preset ? S.preset.name : null, key: row.k,
|
||||
};
|
||||
S.busy = true;
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'apply', kind: S.kind, slot: S.slot, key: row.k,
|
||||
value: $('evalue').value});
|
||||
}
|
||||
|
||||
function flashKey(key) {
|
||||
document.querySelectorAll('#rows .row').forEach(el => {
|
||||
if (el.dataset.key !== key) return;
|
||||
el.classList.remove('flash');
|
||||
void el.offsetWidth; // restart the animation if it ran on this row already
|
||||
el.classList.add('flash');
|
||||
el.addEventListener('animationend', () => el.classList.remove('flash'), {once: true});
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- wiring */
|
||||
buildSeg();
|
||||
|
||||
$('seg').addEventListener('click', e => {
|
||||
const button = e.target.closest('[data-kind]');
|
||||
if (!button || S.busy || button.dataset.kind === S.kind) return;
|
||||
switchKind(button.dataset.kind);
|
||||
});
|
||||
|
||||
$('rows').addEventListener('click', e => {
|
||||
const row = e.target.closest('.row');
|
||||
if (row && !S.busy) selectKey(row.dataset.key);
|
||||
});
|
||||
|
||||
$('slots').addEventListener('click', e => {
|
||||
const chip = e.target.closest('[data-slot]');
|
||||
if (chip && !S.busy) selectSlot(Number(chip.dataset.slot));
|
||||
});
|
||||
|
||||
$('q').addEventListener('input', e => {
|
||||
S.filter = e.target.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
$('apply').addEventListener('click', applyValue);
|
||||
|
||||
$('reset').addEventListener('click', () => {
|
||||
const row = S.rows.find(r => r.k === S.selected);
|
||||
if (row) $('evalue').value = row.v;
|
||||
});
|
||||
|
||||
$('evalue').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); applyValue(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); $('reset').click(); }
|
||||
});
|
||||
|
||||
$('refresh').addEventListener('click', reload);
|
||||
$('close').addEventListener('click', () => orca.close());
|
||||
|
||||
/* -------------------------------------------------------------- messages */
|
||||
orca.onMessage(msg => {
|
||||
if (!msg || msg.command !== 'state') return;
|
||||
if (msg.kind !== S.kind) return; // a reply for a category we already left
|
||||
|
||||
const pending = S.pending;
|
||||
S.busy = false;
|
||||
S.pending = null;
|
||||
S.slots = msg.slots || [];
|
||||
// Adopt the slot the plugin actually used (it clamps a slot that no longer exists).
|
||||
if (S.kind === 'filament' && typeof msg.slot === 'number') S.slot = msg.slot;
|
||||
S.preset = msg.preset || null;
|
||||
S.rows = msg.rows || [];
|
||||
S.error = msg.error || '';
|
||||
// Filament editability is a slot concern; process and printer have a single edited preset.
|
||||
S.assigned = S.kind === 'filament' ? !!msg.assigned : true;
|
||||
S.switches = S.kind === 'filament' ? !!msg.switches : false;
|
||||
S.editedName = S.kind === 'filament' ? (msg.edited_name || null) : null;
|
||||
if (S.selected && !S.rows.some(r => r.k === S.selected)) S.selected = null;
|
||||
|
||||
if (msg.error) {
|
||||
// Keep the typed value so it can be corrected, and keep the current selection.
|
||||
setStatus('⚠ ' + msg.error, 'err');
|
||||
} else {
|
||||
if (pending) S.changed[changedKey(pending.preset, pending.key)] = true;
|
||||
setStatus(msg.message || '', msg.message ? 'ok' : '');
|
||||
}
|
||||
|
||||
paint();
|
||||
|
||||
if (!msg.error && pending) {
|
||||
const row = S.rows.find(r => r.k === pending.key);
|
||||
if (row) { // show the value as the preset now stores it
|
||||
$('evalue').value = row.v;
|
||||
flashKey(row.k);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: S.kind, slot: S.slot});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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)
|
||||
@@ -45,28 +45,10 @@
|
||||
<color type="primary" scheme_preference="dark">#00695C</color>
|
||||
</branding>
|
||||
<releases>
|
||||
<release version="2.5.0-alpha" date="2026-10-14" type="development">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_5_0_alpha</url>
|
||||
<release version="2.5.0-dev" date="2026-06-22" type="development">
|
||||
<url type="details">https://github.com/OrcaSlicer/OrcaSlicer/releases/tag/nightly-builds</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.5.0-alpha release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.4.2" date="2026-07-26">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_4_2</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.4.2 release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.4.1" date="2026-06-26">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_4_1</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.4.1 release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.4.0" date="2026-06-20">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_4_0</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.4.0 release.</p>
|
||||
<p>See the release page for detailed changelog.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.3.2" date="2025-03-23">
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#include "GUI.hpp"
|
||||
#include "GUI_App.hpp"
|
||||
#include "I18N.hpp"
|
||||
#include "ImGuiWrapper.hpp"
|
||||
#include "MainFrame.hpp"
|
||||
#include "NativeCommands.hpp"
|
||||
#include "Notebook.hpp"
|
||||
#include "NotificationManager.hpp"
|
||||
#include "OptionsGroup.hpp"
|
||||
#include "Plater.hpp"
|
||||
#include "SettingsIndex.hpp"
|
||||
@@ -99,6 +101,45 @@ std::string find_loaded_source_name(PluginManager& manager, const std::string& p
|
||||
return plugin_key;
|
||||
}
|
||||
|
||||
// Translates a Script capability outcome into the action layer's result vocabulary. Shared by
|
||||
// the synchronous run path and the async completion notification.
|
||||
AppActionRunResult script_outcome_to_action_result(const ScriptCompletion& completion)
|
||||
{
|
||||
// Cancelled by PluginManager (unload/shutdown): expected termination, nothing to show.
|
||||
if (completion.cancelled)
|
||||
return {};
|
||||
|
||||
if (!completion.error.empty())
|
||||
return {AppActionRunResult::Level::Error, from_u8(completion.error)};
|
||||
|
||||
const ExecutionResult& result = completion.result;
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
|
||||
result.message.empty() ? fallback : from_u8(result.message)};
|
||||
}
|
||||
|
||||
// Surfaces the eventual outcome of an async script launched from the speed dial. That launch
|
||||
// already returned (the UI was not blocked), so the finish is reported as a notification. A
|
||||
// cancellation is silent.
|
||||
void notify_script_outcome(const ScriptCompletion& completion)
|
||||
{
|
||||
if (completion.cancelled)
|
||||
return;
|
||||
if (wxTheApp == nullptr || wxGetApp().is_closing())
|
||||
return;
|
||||
|
||||
Plater* plater = wxGetApp().plater();
|
||||
if (plater == nullptr || wxGetApp().imgui() == nullptr || !wxGetApp().imgui()->display_initialized())
|
||||
return;
|
||||
|
||||
const AppActionRunResult action_result = script_outcome_to_action_result(completion);
|
||||
const auto level = action_result.level == AppActionRunResult::Level::Error
|
||||
? NotificationManager::NotificationLevel::ErrorNotificationLevel
|
||||
: NotificationManager::NotificationLevel::RegularNotificationLevel;
|
||||
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, level, into_u8(action_result.message));
|
||||
}
|
||||
|
||||
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
|
||||
// (identity), so a plugin display-name change does not re-key the action.
|
||||
struct PluginScriptAction : AppAction
|
||||
@@ -124,15 +165,36 @@ struct PluginScriptAction : AppAction
|
||||
|
||||
AppActionRunResult run(const std::string& /*param*/) const override
|
||||
{
|
||||
std::string error;
|
||||
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
|
||||
if (!error.empty())
|
||||
return {AppActionRunResult::Level::Error, from_u8(error)};
|
||||
struct Outcome
|
||||
{
|
||||
bool async = false;
|
||||
bool completed = false;
|
||||
AppActionRunResult result;
|
||||
};
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
|
||||
result.message.empty() ? fallback : from_u8(result.message)};
|
||||
auto on_done = [outcome](const ScriptCompletion& completion) {
|
||||
outcome->result = script_outcome_to_action_result(completion);
|
||||
outcome->completed = true;
|
||||
if (outcome->async)
|
||||
notify_script_outcome(completion);
|
||||
};
|
||||
|
||||
const PluginManager::ScriptLaunchResult launch = PluginManager::instance().launch_script_capability(plugin_key, capability, on_done);
|
||||
if (launch.launched_async) {
|
||||
// The completion is delivered on this (GUI) thread once the (sync or async) invocation
|
||||
// finishes, so marking `async` here cannot race with on_done. The speed dial has already
|
||||
// shown a result by then; the final outcome arrives as a notification (a cancellation is
|
||||
// silent).
|
||||
outcome->async = true;
|
||||
if (outcome->completed)
|
||||
return outcome->result;
|
||||
return {AppActionRunResult::Level::Info, _L("Script plugin started.")};
|
||||
}
|
||||
|
||||
// The launch failed inline; on_done produced the (error) result.
|
||||
on_done(launch.outcome);
|
||||
return outcome->result;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -119,6 +119,37 @@ using column_t = std::function<wxWindow*(wxWindow* parent, const Line&)>;
|
||||
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
||||
using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
|
||||
|
||||
// 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<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
|
||||
|
||||
|
||||
@@ -1158,10 +1158,7 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
||||
{
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
|
||||
std::string error;
|
||||
ExecutionResult result;
|
||||
|
||||
auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
||||
auto complete_with_error = [this, &manager, plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
||||
const std::string normalized_error = plugin_error.empty() ? _u8L("Script plugin failed.") : plugin_error;
|
||||
if (!manager.set_plugin_error(plugin_key, normalized_error))
|
||||
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
||||
@@ -1177,32 +1174,53 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
||||
show_status(message, "error");
|
||||
};
|
||||
|
||||
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
|
||||
// the runtime delivers it later; it is called inline here only when the launch itself failed.
|
||||
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
|
||||
if (!alive->load())
|
||||
return;
|
||||
|
||||
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
|
||||
// error nor a normal success, and do not touch the plugin's error/state.
|
||||
if (completion.cancelled)
|
||||
return;
|
||||
|
||||
if (!completion.error.empty()) {
|
||||
complete_with_error(completion.error, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
const ExecutionResult& result = completion.result;
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
||||
<< " status=" << static_cast<int>(result.status) << " message=" << result.message
|
||||
<< " data=" << result.data;
|
||||
|
||||
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
|
||||
if (failed) {
|
||||
complete_with_error(result.message, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
manager.clear_plugin_error(plugin_key);
|
||||
send_plugins();
|
||||
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
||||
show_status(message, skipped ? "info" : "success");
|
||||
};
|
||||
|
||||
PluginManager::ScriptLaunchResult launch;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
result = manager.run_script_capability(plugin_key, capability_name, error);
|
||||
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
|
||||
}
|
||||
|
||||
if (!error.empty()) {
|
||||
complete_with_error(error, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
||||
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
|
||||
|
||||
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
|
||||
if (failed) {
|
||||
complete_with_error(result.message, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
manager.clear_plugin_error(plugin_key);
|
||||
send_plugins();
|
||||
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
||||
show_status(message, skipped ? "info" : "success");
|
||||
// Every started invocation — a synchronous `def execute` included — completes later through
|
||||
// `finish`; only a launch failure is reported inline. Return without blocking.
|
||||
if (!launch.launched_async)
|
||||
finish(launch.outcome);
|
||||
}
|
||||
|
||||
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include "libslic3r/LifecycleEvents.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "slic3r/GUI/SettingsIndex.hpp"
|
||||
@@ -1645,6 +1646,138 @@ 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<std::string>& preset_type_options(Preset::Type type)
|
||||
{
|
||||
static const std::vector<std::string> 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";
|
||||
}
|
||||
}
|
||||
|
||||
// Commit one option whose new value already sits in m_config through the same path a hand edit
|
||||
// runs. The 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 option's page is reloaded from the config just committed first, so a built
|
||||
// field reports the committed value rather than a stale one. No tab-wide refresh happens here: the
|
||||
// caller does it once for the whole batch.
|
||||
bool Tab::commit_preset_config_value(const std::string& opt_key)
|
||||
{
|
||||
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
|
||||
field_page->reload_config();
|
||||
field->field_changed();
|
||||
return true;
|
||||
}
|
||||
for (const PageShp& page : m_pages)
|
||||
for (const ConfigOptionsGroupShp& group : page->m_optgroups)
|
||||
if (group->commit_config_value(opt_key))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
apply_preset_config_values({{opt_key, value}});
|
||||
}
|
||||
|
||||
// Apply a batch of settings as one transaction: parse and validate the whole batch first, then
|
||||
// commit it through the same per-setting path apply_preset_config_value() uses, restoring the edited
|
||||
// preset and the state derived from it if a setting fails. See the header for the contract and its
|
||||
// limits.
|
||||
void Tab::apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values)
|
||||
{
|
||||
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");
|
||||
|
||||
if (values.empty())
|
||||
return;
|
||||
|
||||
const std::vector<std::string>& options = preset_type_options(m_type);
|
||||
|
||||
// Preparation: reject every batch that cannot be applied before touching the edited preset, so a
|
||||
// bad key or value fails without a partial write. The key must belong to this preset type, it must
|
||||
// not repeat, and each value is strict-parsed into a scratch copy (the strict deserializer can
|
||||
// already have mutated an option when it rejects a bad value, so the check has to run somewhere
|
||||
// the edited preset is not affected). Callback failures cannot be prepared this way: a setting's
|
||||
// on_change callback is a GUI method, not a pure function of the config, so whether it throws is
|
||||
// only known by running it. That residual case is handled by the rollback below.
|
||||
std::set<std::string> seen;
|
||||
DynamicPrintConfig scratch(*m_config);
|
||||
for (const auto& kv : values) {
|
||||
const std::string& opt_key = kv.first;
|
||||
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));
|
||||
if (!seen.insert(opt_key).second)
|
||||
throw std::invalid_argument(format("%1% preset setting \"%2%\" is set more than once in the same update",
|
||||
preset_type_name(m_type), opt_key));
|
||||
try {
|
||||
scratch.set_deserialize_strict(opt_key, kv.second);
|
||||
} catch (const ConfigurationError& err) {
|
||||
throw std::invalid_argument(err.what());
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback boundary: snapshot the whole edited config, not just the keys the batch names, because a
|
||||
// key-specific callback can adjust related settings of the edited preset (mutually exclusive
|
||||
// options, compatibility, and so on). A failure restores this config and then recomputes the state
|
||||
// derived from it.
|
||||
DynamicPrintConfig before(*m_config);
|
||||
try {
|
||||
for (const auto& kv : values) {
|
||||
m_config->set_deserialize_strict(kv.first, kv.second);
|
||||
if (!commit_preset_config_value(kv.first))
|
||||
// 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.
|
||||
// The value was written above, so saved mode skips the write and keeps the path's
|
||||
// compatibility recompute, dirty flag, preset-combo and plater updates.
|
||||
load_key_value(kv.first, boost::any(), /*saved_value=*/true);
|
||||
}
|
||||
} catch (...) {
|
||||
*m_config = before;
|
||||
// A callback can rewrite state derived from the config (compatibility marks, the preset
|
||||
// selector, dependent tabs, the Plater and, on the printer tab, the printer agent) before it
|
||||
// throws. Recompute that derived state from the restored config so none of it is left
|
||||
// reflecting the failed batch. Effects that are not a function of the config (a dialog the
|
||||
// user already accepted, an undo snapshot, a command already sent to a device, the target
|
||||
// selection) cannot be reversed here; see the header.
|
||||
m_preset_bundle->update_compatible(PresetSelectCompatibleType::Never);
|
||||
reload_config();
|
||||
update_dirty();
|
||||
update();
|
||||
throw;
|
||||
}
|
||||
|
||||
// Apply the batch's final state once. The commit paths above already maintained the dirty flag,
|
||||
// preset combo and, on the printer tab, the printer-agent switch; this reloads the visible page
|
||||
// and pushes the config to the Plater.
|
||||
reload_config();
|
||||
update_dirty();
|
||||
update();
|
||||
}
|
||||
|
||||
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
|
||||
void Tab::reload_config()
|
||||
{
|
||||
|
||||
@@ -424,6 +424,40 @@ 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);
|
||||
|
||||
// Host/plugin API: apply a batch of settings to this tab's currently edited preset as one
|
||||
// transaction. Each pair is a setting of this tab's preset type and its serialized string value.
|
||||
// The whole batch is parsed and validated before the edited preset is touched; each setting then
|
||||
// runs the same committed-change path apply_preset_config_value() uses, so setting-specific
|
||||
// validation, correction and on_change callbacks stay authoritative. Duplicate keys are rejected,
|
||||
// because the transaction must not resolve an incidental write order, and the batch order is
|
||||
// preserved. If applying the batch fails, the edited preset's config is restored to its pre-batch
|
||||
// state and the state derived from it (compatibility marks, the preset selector, dependent tabs,
|
||||
// the Plater and the printer agent) is recomputed, so the edited preset never keeps a subset of
|
||||
// the staged settings.
|
||||
//
|
||||
// Only the edited preset is restored. A setting's on_change callback is a GUI method, not a pure
|
||||
// function of the config, so effects outside the edited preset cannot be prepared or undone: the
|
||||
// transaction does not reverse preset/slot selection made while resolving the target, a dialog
|
||||
// decision the user already accepted, an undo snapshot, a dependent preset's rewritten config, or
|
||||
// anything already sent to a device. Exact all-or-nothing across those is not achievable while the
|
||||
// authoritative GUI callbacks run; this method guarantees the edited preset's own settings, not
|
||||
// those external effects.
|
||||
//
|
||||
// Throws std::invalid_argument for an unknown key, a value the option cannot parse, or a duplicate
|
||||
// key; std::runtime_error when the preset or main window is unavailable.
|
||||
void apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values);
|
||||
virtual void reload_config();
|
||||
void update_mode();
|
||||
void update_visibility();
|
||||
@@ -507,6 +541,12 @@ protected:
|
||||
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
|
||||
void compatible_widget_reload(PresetDependencies &deps);
|
||||
void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false);
|
||||
// Commit one option whose new value already sits in m_config through the same path a field edit
|
||||
// runs (throwing field, otherwise the owning group's on_change_OG()). Reloads the option's page
|
||||
// so a built field reports the committed value, but performs no tab-wide refresh. Returns true
|
||||
// when a built field or a group owns the option, false when the GUI edits it through a custom
|
||||
// widget with no group entry.
|
||||
bool commit_preset_config_value(const std::string& opt_key);
|
||||
|
||||
//BBS: GUI refactor
|
||||
// return true if cancelled
|
||||
|
||||
@@ -102,6 +102,12 @@ void install_slicing_pipeline_hook()
|
||||
ctx.step = step;
|
||||
ctx.print = &print;
|
||||
ctx.object = object;
|
||||
// Slicing hooks are synchronous by design and never await: the context
|
||||
// borrows Print/PrintObject that are only valid during this call, and the
|
||||
// UI thread may itself be blocked waiting on this slicing worker. An async
|
||||
// override is rejected here rather than scheduled -- cap->execute() casts
|
||||
// its Python result to ExecutionResult and a coroutine object cannot be
|
||||
// cast, so it surfaces as the SlicingError below.
|
||||
r = cap->execute(ctx);
|
||||
} catch (const CanceledException&) {
|
||||
throw; // cancellation must reach process(), never become a slicing error
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
|
||||
#include "PluginFsUtils.hpp"
|
||||
#include "PluginHooks.hpp"
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PyPluginTrampoline.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PythonPluginBridge.hpp"
|
||||
#include "host/PluginHostUi.hpp"
|
||||
|
||||
#include "OrcaCloudServiceAgent.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
@@ -41,6 +44,7 @@
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/thread.h>
|
||||
#include <wx/utils.h>
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
#include "slic3r/GUI/Plater.hpp"
|
||||
|
||||
@@ -68,8 +72,10 @@ void Plugin::release_module()
|
||||
PluginManager& PluginManager::instance()
|
||||
{
|
||||
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
||||
// release module, sys.modules, and sys.path ownership safely.
|
||||
// release module, sys.modules, and sys.path ownership safely. Touch the async Script runtime
|
||||
// next, so ~PluginManager can still drain it during its own shutdown().
|
||||
PythonInterpreter::instance();
|
||||
ScriptAsyncRuntime::instance();
|
||||
static PluginManager inst;
|
||||
return inst;
|
||||
}
|
||||
@@ -101,6 +107,9 @@ bool PluginManager::initialize()
|
||||
m_initialized = true;
|
||||
}
|
||||
|
||||
// Re-arm the async Script runtime in case shutdown() ran earlier in this process.
|
||||
ScriptAsyncRuntime::instance().reset();
|
||||
|
||||
// Bring every capability's stored config into memory. Deliberately unconditional and
|
||||
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
||||
// plugin that comes back later finds its settings intact. A missing or malformed file just
|
||||
@@ -167,6 +176,10 @@ void PluginManager::shutdown()
|
||||
unload_all_plugins();
|
||||
PythonPluginBridge::instance().clear_pending_captures();
|
||||
|
||||
// Every async Script run has been drained by unload_all_plugins above; wait for every
|
||||
// invocation worker before the interpreter is allowed to finalize.
|
||||
ScriptAsyncRuntime::instance().shutdown();
|
||||
|
||||
// Every config write already goes to disk as it happens (store_capability_config), so this
|
||||
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
||||
// plugins above must never discard their stored config.
|
||||
@@ -754,6 +767,10 @@ void PluginManager::release_load_slot(const std::string& plugin_key)
|
||||
void PluginManager::cancel_and_wait_for_capabilities(
|
||||
const std::vector<std::shared_ptr<PluginCapabilityInterface>>& capabilities)
|
||||
{
|
||||
// Ask the plugin to cancel first, then schedule the async cancellation: on_cancelled() can
|
||||
// observe/record state while the run is still intact, and only afterwards is its asyncio task
|
||||
// cancelled. Draining of capability references and posted host UI callbacks still follows, so
|
||||
// the module is not unloaded until both have settled.
|
||||
for (const auto& capability : capabilities) {
|
||||
if (!capability || !capability->is_enabled())
|
||||
continue;
|
||||
@@ -769,15 +786,55 @@ void PluginManager::cancel_and_wait_for_capabilities(
|
||||
}
|
||||
}
|
||||
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
for (const auto& capability : capabilities) {
|
||||
if (capability)
|
||||
ScriptAsyncRuntime::instance().cancel_plugin(capability->audit_plugin_key());
|
||||
}
|
||||
|
||||
// Distinct plugins whose posted host UI work must settle before module unload. A Tab::select_preset
|
||||
// dialog accepted while the plugin is being unloaded would otherwise mutate presets after unload,
|
||||
// so the drain below waits for every posted UI callback to return, not just for the run's ref.
|
||||
std::vector<std::string> plugin_keys;
|
||||
for (const auto& capability : capabilities) {
|
||||
if (!capability)
|
||||
continue;
|
||||
const std::string& key = capability->audit_plugin_key();
|
||||
if (!key.empty() && std::find(plugin_keys.begin(), plugin_keys.end(), key) == plugin_keys.end())
|
||||
plugin_keys.push_back(key);
|
||||
}
|
||||
|
||||
const auto host_pending = [&plugin_keys]() {
|
||||
return std::any_of(plugin_keys.begin(), plugin_keys.end(),
|
||||
[](const std::string& key) { return PluginHostUi::pending_async_count(key) > 0; });
|
||||
};
|
||||
|
||||
// Script rows are bounded only by their own worker: a cancelled or stalled invocation of another
|
||||
// plugin can no longer couple to this one (each invocation owns a thread). A Script worker or a
|
||||
// posted host UI callback is drained without a deadline — forcing module unload while either is
|
||||
// outstanding would leave Python executing against a freed module, or apply a preset selection
|
||||
// after the plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On
|
||||
// the GUI thread the event loop is pumped so the UI callback (possibly a modal) can settle
|
||||
// instead of being starved by a blocking wait.
|
||||
const auto grace_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
for (;;) {
|
||||
const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
||||
return !capability || capability->ref_count() == 0;
|
||||
});
|
||||
if (all_released)
|
||||
// A cancellation that arrived before the invocation ran skips the ref counter, so an active
|
||||
// worker must also be gone: never unload the module while any Script thread is still live.
|
||||
const bool invocation_active = std::any_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
||||
return capability != nullptr && ScriptAsyncRuntime::instance().has_active_run(capability.get());
|
||||
});
|
||||
const bool ui_pending = host_pending();
|
||||
if (all_released && !invocation_active && !ui_pending)
|
||||
return;
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
if (!invocation_active && !ui_pending && std::chrono::steady_clock::now() >= grace_deadline)
|
||||
break;
|
||||
|
||||
if (wxTheApp != nullptr && wxIsMainThread())
|
||||
wxYield();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
|
||||
for (const auto& capability : capabilities) {
|
||||
@@ -1006,6 +1063,12 @@ void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_de
|
||||
|
||||
bool PluginManager::unload_plugin(const std::string& plugin_key)
|
||||
{
|
||||
// Stop delivery from any posted host UI awaitable for this plugin before anything is torn down,
|
||||
// so the earliest possible UI callback cannot resume the plugin's coroutine into a run that is
|
||||
// already being cancelled. Registrations stay live so cancel_and_wait_for_capabilities() drains
|
||||
// them; a Tab::select_preset dialog accepted after unload must not mutate presets post-teardown.
|
||||
PluginHostUi::abandon_pending_async(plugin_key);
|
||||
|
||||
auto notify_unload_complete = [this, &plugin_key]() {
|
||||
if (!m_shutting_down.load(std::memory_order_acquire))
|
||||
run_on_unload_callbacks(plugin_key);
|
||||
@@ -1080,6 +1143,11 @@ void PluginManager::unload_all_plugins()
|
||||
|
||||
// Bulk shutdown intentionally skips the normal unload notification path, but resources owned
|
||||
// by GUI subscribers still need to be torn down before the interpreter is finalized.
|
||||
// Abandon posted host awaitables first, so a late UI callback cannot resolve into a plugin
|
||||
// whose module is about to go; they stay tracked for cancel_and_wait_for_capabilities() below.
|
||||
for (const std::string& key : removed_keys)
|
||||
PluginHostUi::abandon_pending_async(key);
|
||||
|
||||
for (const std::string& key : removed_keys)
|
||||
run_on_unload_callbacks(key);
|
||||
|
||||
@@ -2070,41 +2138,385 @@ bool PluginManager::delete_and_unsubscribe_cloud_plugin(const std::string& plugi
|
||||
return finalize_cloud_plugin_removal(descriptor, false, error);
|
||||
}
|
||||
|
||||
// ── Async Script runtime ────────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
// The asyncio loop of the Script invocation running on the calling thread. Owned +1 while set;
|
||||
// always cleared under the GIL before the worker thread exits.
|
||||
thread_local PyObject* tl_script_loop = nullptr;
|
||||
|
||||
} // namespace
|
||||
|
||||
struct ScriptAsyncRuntime::Impl
|
||||
{
|
||||
struct Run
|
||||
{
|
||||
std::string plugin_key;
|
||||
std::string capability_name;
|
||||
std::shared_ptr<PluginCapabilityInterface> capability;
|
||||
std::function<void(const ScriptCompletion&)> on_done;
|
||||
std::atomic<bool> cancel_requested{false};
|
||||
// True while an asyncio loop/task is live for this run. Read without the GIL by
|
||||
// cancel_plugin() to avoid taking the GIL for a purely synchronous invocation.
|
||||
std::atomic<bool> has_loop{false};
|
||||
|
||||
// Live asyncio loop/task for cancellation. Guarded by handle_mutex and only touched
|
||||
// with the GIL held.
|
||||
std::mutex handle_mutex;
|
||||
pybind11::object loop;
|
||||
pybind11::object task;
|
||||
};
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::vector<std::shared_ptr<Run>> active;
|
||||
bool stopping = false;
|
||||
|
||||
// Runs one invocation to completion on the calling (worker) thread.
|
||||
void run_invocation(const std::shared_ptr<Run>& run);
|
||||
};
|
||||
|
||||
void ScriptAsyncRuntime::Impl::run_invocation(const std::shared_ptr<Run>& run)
|
||||
{
|
||||
ScriptCompletion completion;
|
||||
|
||||
{
|
||||
PythonRuntimeLease lease = PythonInterpreter::instance().acquire_runtime_lease();
|
||||
if (!lease) {
|
||||
completion.error = _u8L("Python interpreter is shutting down");
|
||||
} else {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
|
||||
if (run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
// Cancelled before it ran: expected termination, never an error.
|
||||
completion.cancelled = true;
|
||||
} else {
|
||||
// Hold the capability reference, the audit scope and the GIL for the whole
|
||||
// invocation, so unload/shutdown cannot tear the module out from under it.
|
||||
PluginCapabilityInterface::RefCounter ref_counter(*run->capability);
|
||||
|
||||
std::optional<ScopedPluginAuditContext> audit_scope;
|
||||
if (!run->capability->audit_plugin_key().empty())
|
||||
audit_scope.emplace(run->capability->audit_plugin_key(), run->capability->name());
|
||||
|
||||
// Detection and execution both happen here, on the worker: a synchronous
|
||||
// `def execute` runs to completion off the UI thread, exactly like an `async def`.
|
||||
ScriptExecuteInvocation invocation = invoke_script_execute(run->capability);
|
||||
if (!invocation.error.empty()) {
|
||||
completion.error = invocation.error;
|
||||
} else if (!invocation.is_async) {
|
||||
completion.result = invocation.result;
|
||||
} else if (run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
completion.cancelled = true;
|
||||
} else {
|
||||
try {
|
||||
pybind11::module_ asyncio = pybind11::module_::import("asyncio");
|
||||
pybind11::object loop = asyncio.attr("new_event_loop")();
|
||||
asyncio.attr("set_event_loop")(loop);
|
||||
|
||||
tl_script_loop = loop.ptr();
|
||||
Py_INCREF(tl_script_loop);
|
||||
|
||||
pybind11::object task = loop.attr("create_task")(std::move(invocation.awaitable));
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
run->loop = loop;
|
||||
run->task = task;
|
||||
}
|
||||
run->has_loop.store(true, std::memory_order_release);
|
||||
// A cancellation that arrived before the loop came up must still land.
|
||||
if (run->cancel_requested.load(std::memory_order_acquire))
|
||||
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
|
||||
|
||||
try {
|
||||
pybind11::object returned = loop.attr("run_until_complete")(task);
|
||||
completion.result = returned.cast<ExecutionResult>();
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
// Only our own cancel_plugin() cancellation is expected termination; a
|
||||
// CancelledError the plugin raised on its own stays a real error.
|
||||
if (err.matches(asyncio.attr("CancelledError")) &&
|
||||
run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
completion.cancelled = true;
|
||||
} else {
|
||||
log_python_exception_keep(err);
|
||||
completion.error = err.what();
|
||||
}
|
||||
}
|
||||
|
||||
run->has_loop.store(false, std::memory_order_release);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
run->task = pybind11::object();
|
||||
run->loop = pybind11::object();
|
||||
}
|
||||
Py_CLEAR(tl_script_loop);
|
||||
loop.attr("close")();
|
||||
asyncio.attr("set_event_loop")(pybind11::none());
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
log_python_exception_keep(err);
|
||||
completion.error = err.what();
|
||||
} catch (const std::exception& ex) {
|
||||
completion.error = ex.what();
|
||||
}
|
||||
|
||||
// Covers an exception thrown after the loop came up but before the clear above.
|
||||
if (run->has_loop.load(std::memory_order_acquire)) {
|
||||
run->has_loop.store(false, std::memory_order_release);
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
run->task = pybind11::object();
|
||||
run->loop = pybind11::object();
|
||||
}
|
||||
|
||||
if (tl_script_loop != nullptr)
|
||||
Py_CLEAR(tl_script_loop);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the capability reference while the GIL is held: releasing a pybind holder from
|
||||
// a non-Python thread would DECREF the capability without the GIL. Runs on every
|
||||
// lease-valid path, including an invocation cancelled before it ran.
|
||||
run->capability.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination and is
|
||||
// authoritative: whatever the body did — finished, swallowed CancelledError, or failed — nothing
|
||||
// is surfaced as an error or as a normal success.
|
||||
if (run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
completion.cancelled = true;
|
||||
completion.error.clear();
|
||||
}
|
||||
|
||||
auto on_done = std::move(run->on_done);
|
||||
auto deliver = [on_done, completion]() {
|
||||
if (on_done)
|
||||
on_done(completion);
|
||||
};
|
||||
|
||||
if (wxTheApp == nullptr || wxIsMainThread())
|
||||
deliver();
|
||||
else
|
||||
GUI::wxGetApp().CallAfter(deliver);
|
||||
|
||||
// Leave `active` last, so a drain cannot observe the run as finished before its completion was
|
||||
// queued/delivered and its capability reference released.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
active.erase(std::remove(active.begin(), active.end(), run), active.end());
|
||||
}
|
||||
cv.notify_all();
|
||||
}
|
||||
|
||||
ScriptAsyncRuntime& ScriptAsyncRuntime::instance()
|
||||
{
|
||||
static ScriptAsyncRuntime inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
ScriptAsyncRuntime::ScriptAsyncRuntime() : m_impl(std::make_unique<Impl>()) {}
|
||||
|
||||
ScriptAsyncRuntime::~ScriptAsyncRuntime() { shutdown(); }
|
||||
|
||||
bool ScriptAsyncRuntime::start(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
std::shared_ptr<PluginCapabilityInterface> capability,
|
||||
std::function<void(const ScriptCompletion&)> on_done)
|
||||
{
|
||||
if (!PythonInterpreter::instance().is_initialized())
|
||||
return false;
|
||||
|
||||
auto run = std::make_shared<Impl::Run>();
|
||||
run->plugin_key = plugin_key;
|
||||
run->capability_name = capability_name;
|
||||
run->capability = std::move(capability);
|
||||
run->on_done = std::move(on_done);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
if (m_impl->stopping)
|
||||
return false;
|
||||
m_impl->active.push_back(run);
|
||||
}
|
||||
|
||||
// One worker thread per invocation. A stalled invocation of one plugin can never delay another
|
||||
// plugin's unload or another invocation, and no invocation body ever runs on the caller (UI)
|
||||
// thread. The thread holds `run` (and so the capability) until it finishes; the runtime tracks
|
||||
// it in `active` and waits for it on shutdown.
|
||||
Impl* impl = m_impl.get();
|
||||
try {
|
||||
std::thread([impl, run] { impl->run_invocation(run); }).detach();
|
||||
} catch (...) {
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->active.erase(std::remove(m_impl->active.begin(), m_impl->active.end(), run), m_impl->active.end());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScriptAsyncRuntime::cancel_plugin(const std::string& plugin_key)
|
||||
{
|
||||
std::vector<std::shared_ptr<Impl::Run>> runs;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
for (const auto& run : m_impl->active)
|
||||
if (run->plugin_key == plugin_key)
|
||||
runs.push_back(run);
|
||||
}
|
||||
|
||||
for (const auto& run : runs)
|
||||
run->cancel_requested.store(true, std::memory_order_release);
|
||||
|
||||
for (const auto& run : runs) {
|
||||
// A synchronous invocation has no loop to cancel; skip it so the caller never blocks on
|
||||
// the GIL while that invocation is (uninterruptibly) running.
|
||||
if (!run->has_loop.load(std::memory_order_acquire))
|
||||
continue;
|
||||
|
||||
PythonGILState gil;
|
||||
if (!gil)
|
||||
continue;
|
||||
|
||||
pybind11::object loop;
|
||||
pybind11::object task;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
loop = run->loop;
|
||||
task = run->task;
|
||||
}
|
||||
if (!loop || !task)
|
||||
continue;
|
||||
|
||||
try {
|
||||
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
|
||||
} catch (const std::exception& ex) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Script async cancel failed for plugin '" << plugin_key << "': " << ex.what();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ScriptAsyncRuntime::has_active_run(const PluginCapabilityInterface* capability) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
for (const auto& run : m_impl->active)
|
||||
if (run->capability.get() == capability)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void ScriptAsyncRuntime::shutdown()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->stopping = true;
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->cv.wait(lock, [this] { return m_impl->active.empty(); });
|
||||
}
|
||||
|
||||
void ScriptAsyncRuntime::reset()
|
||||
{
|
||||
shutdown();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->stopping = false;
|
||||
}
|
||||
|
||||
pybind11::object ScriptAsyncRuntime::current_loop()
|
||||
{
|
||||
if (tl_script_loop == nullptr)
|
||||
return pybind11::object();
|
||||
return pybind11::reinterpret_borrow<pybind11::object>(pybind11::handle(tl_script_loop));
|
||||
}
|
||||
|
||||
ExecutionResult PluginManager::run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error)
|
||||
{
|
||||
if (plugin_key.empty() || capability_name.empty()) {
|
||||
return {};
|
||||
ExecutionResult result;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool done = false;
|
||||
|
||||
ScriptLaunchResult launch = launch_script_capability(
|
||||
plugin_key, capability_name, [&](const ScriptCompletion& completion) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
result = completion.result;
|
||||
// A cancel_plugin() cancellation is expected termination, not an error to report.
|
||||
error = completion.cancelled ? std::string() : completion.error;
|
||||
done = true;
|
||||
cv.notify_all();
|
||||
});
|
||||
|
||||
if (launch.launched_async) {
|
||||
// Async run: the completion is delivered on the GUI thread. When this call is itself on
|
||||
// the GUI thread it must pump the event loop, or the completion can never be delivered.
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
if (wxTheApp != nullptr && wxIsMainThread()) {
|
||||
while (!done) {
|
||||
cv.wait_for(lock, std::chrono::milliseconds(10));
|
||||
lock.unlock();
|
||||
wxYield();
|
||||
lock.lock();
|
||||
}
|
||||
} else {
|
||||
cv.wait(lock, [&] { return done; });
|
||||
}
|
||||
} else {
|
||||
result = launch.outcome.result;
|
||||
error = launch.outcome.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
PluginManager::ScriptLaunchResult PluginManager::launch_script_capability(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
ScriptCompletionFn on_done)
|
||||
{
|
||||
ScriptLaunchResult launch;
|
||||
|
||||
// Mirror the historical synchronous behavior for a dangling reference: nothing to run and
|
||||
// no error (the caller's guarded UI normally prevents this).
|
||||
if (plugin_key.empty() || capability_name.empty())
|
||||
return launch;
|
||||
|
||||
if (m_shutting_down.load(std::memory_order_acquire)) {
|
||||
launch.outcome.error = _u8L("Plugin system is shutting down.");
|
||||
return launch;
|
||||
}
|
||||
|
||||
auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
|
||||
if (!cap)
|
||||
return {};
|
||||
return launch;
|
||||
|
||||
auto cap_interface = std::dynamic_pointer_cast<ScriptPluginCapability>(cap);
|
||||
if (!cap_interface)
|
||||
return {};
|
||||
if (!std::dynamic_pointer_cast<ScriptPluginCapability>(cap))
|
||||
return launch;
|
||||
|
||||
ExecutionResult result;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
if (!gil) {
|
||||
error = "Python interpreter is shutting down";
|
||||
return {};
|
||||
}
|
||||
result = cap_interface->execute();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
|
||||
} catch (...) {
|
||||
error = _u8L("Unknown error");
|
||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
|
||||
// Every Script body runs off the caller's thread now: the invocation — including detection and
|
||||
// a synchronous `def execute` — is handed to the runtime, which runs it on its own worker and
|
||||
// reports the outcome through the completion callback. Nothing executes before returning here,
|
||||
// so the UI thread is never blocked by a Script invocation.
|
||||
const bool started = ScriptAsyncRuntime::instance().start(
|
||||
plugin_key, capability_name, cap,
|
||||
[this, plugin_key, on_done](const ScriptCompletion& completion) {
|
||||
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination:
|
||||
// record no plugin error and surface nothing. Genuine user/plugin errors still record.
|
||||
if (!completion.cancelled && !completion.error.empty())
|
||||
set_plugin_error(plugin_key, completion.error);
|
||||
if (on_done)
|
||||
on_done(completion);
|
||||
});
|
||||
|
||||
if (!started) {
|
||||
launch.outcome.error = _u8L("Plugin system is shutting down.");
|
||||
set_plugin_error(plugin_key, launch.outcome.error);
|
||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution failed. plugin_key=" << plugin_key
|
||||
<< " error=" << launch.outcome.error;
|
||||
return launch;
|
||||
}
|
||||
|
||||
if (!error.empty()) {
|
||||
set_plugin_error(plugin_key, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
launch.launched_async = true;
|
||||
return launch;
|
||||
}
|
||||
|
||||
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {
|
||||
|
||||
@@ -98,6 +98,19 @@ struct Plugin
|
||||
void release_module();
|
||||
};
|
||||
|
||||
// Outcome of one Script invocation, delivered to a script completion callback.
|
||||
//
|
||||
// `cancelled` marks a run terminated by PluginManager's own cancellation (plugin unload / shutdown):
|
||||
// expected termination, not a failure. Callers must not surface it as an error nor as a normal
|
||||
// success. `error` carries only genuine user/plugin exceptions; `result` is meaningful when neither
|
||||
// `cancelled` nor a non-empty `error` is set.
|
||||
struct ScriptCompletion
|
||||
{
|
||||
ExecutionResult result;
|
||||
std::string error;
|
||||
bool cancelled = false;
|
||||
};
|
||||
|
||||
class PluginManager
|
||||
{
|
||||
public:
|
||||
@@ -214,6 +227,25 @@ public:
|
||||
|
||||
ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error);
|
||||
|
||||
// Outcome of launching a Script capability.
|
||||
struct ScriptLaunchResult
|
||||
{
|
||||
bool launched_async = false; // true => a run started off-thread; wait for on_done
|
||||
ScriptCompletion outcome; // meaningful when !launched_async (start failure only)
|
||||
};
|
||||
// Invoked on the GUI thread when a Script invocation finishes (or inline, on the calling thread,
|
||||
// when the run could not be started at all).
|
||||
using ScriptCompletionFn = std::function<void(const ScriptCompletion&)>;
|
||||
|
||||
// Starts a Script capability off the caller's thread. Both a synchronous `def execute` and an
|
||||
// `async def execute` run on a ScriptAsyncRuntime worker, so this never blocks the UI thread and
|
||||
// no invocation runs before returning. `launched_async` is true exactly when a run started; the
|
||||
// caller then waits for on_done instead of a returned result. When it is false, `outcome.error`
|
||||
// explains why (the caller's guarded UI normally prevents this).
|
||||
ScriptLaunchResult launch_script_capability(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
ScriptCompletionFn on_done = {});
|
||||
|
||||
void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx);
|
||||
|
||||
private:
|
||||
@@ -309,6 +341,74 @@ private:
|
||||
std::atomic<bool> m_shutting_down{false};
|
||||
};
|
||||
|
||||
// ── Async Script execution ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A Script capability whose Python `execute` is an `async def` (or otherwise returns a
|
||||
// coroutine) must not run on the UI thread, and cannot use the synchronous trampoline path,
|
||||
// which casts the return value to ExecutionResult and would reject a coroutine.
|
||||
//
|
||||
// ScriptAsyncRuntime drives such executions off the calling (UI) thread. Each invocation gets its
|
||||
// own worker thread and, for an `async def`, its own asyncio event loop. Every invocation body —
|
||||
// detection, a synchronous `def execute`, or an `async def` coroutine — runs on that worker, so no
|
||||
// Script invocation ever blocks the UI thread and no invocation is queued behind another plugin's.
|
||||
// For the whole invocation the worker holds:
|
||||
// * a shared_ptr to the capability, so the capability and its module stay alive across awaits;
|
||||
// * a PythonRuntimeLease plus the GIL, so the interpreter cannot finalize underneath it;
|
||||
// * the capability's audit scope (ScopedPluginAuditContext),
|
||||
// which keeps plugin unload and interpreter shutdown from tearing the module out from under a
|
||||
// suspended run. Unload cancels the run and drains it (it never force-unloads after a timeout).
|
||||
//
|
||||
// Interface for host awaitables (see plugin/host/): a host awaitable is constructed inside the
|
||||
// Script coroutine, i.e. on the worker thread with that invocation's loop running, so it can
|
||||
// read that loop with asyncio.get_running_loop() or ScriptAsyncRuntime::current_loop(). To
|
||||
// resume from another thread (typically the UI thread) it must resolve its future with
|
||||
// loop.call_soon_threadsafe(...) and must never touch plugin Python objects, or the loop,
|
||||
// directly from that other thread. The loop only runs while the coroutine is suspended inside
|
||||
// run_until_complete; once Script execute returns the loop is closed and the module may unload.
|
||||
class ScriptAsyncRuntime
|
||||
{
|
||||
public:
|
||||
static ScriptAsyncRuntime& instance();
|
||||
|
||||
~ScriptAsyncRuntime();
|
||||
ScriptAsyncRuntime(const ScriptAsyncRuntime&) = delete;
|
||||
ScriptAsyncRuntime& operator=(const ScriptAsyncRuntime&) = delete;
|
||||
|
||||
// Starts one Script invocation on its own worker thread. The invocation body (Python `execute`,
|
||||
// whether `def` or `async def`) and its detection both run on that worker, never on the calling
|
||||
// thread. `on_done` is invoked on the GUI thread (or inline when there is no wx app) when the
|
||||
// invocation finishes; a `cancelled` completion is expected termination from cancel_plugin().
|
||||
// Returns false when the runtime is shutting down.
|
||||
bool start(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
std::shared_ptr<PluginCapabilityInterface> capability,
|
||||
std::function<void(const ScriptCompletion&)> on_done);
|
||||
|
||||
// Requests cancellation of every active run owned by plugin_key: marks the run and cancels
|
||||
// its asyncio task via loop.call_soon_threadsafe (a synchronous invocation has no loop).
|
||||
void cancel_plugin(const std::string& plugin_key);
|
||||
|
||||
// True while a run for `capability` is active.
|
||||
bool has_active_run(const PluginCapabilityInterface* capability) const;
|
||||
|
||||
// Stops accepting runs and waits for every in-flight invocation worker. Nothing is torn down
|
||||
// by force.
|
||||
void shutdown();
|
||||
|
||||
// Re-arms the runtime after a shutdown() so a later initialize() in the same process can
|
||||
// start runs again.
|
||||
void reset();
|
||||
|
||||
// The asyncio loop of the Script invocation running on the calling thread, or an empty
|
||||
// object when the calling thread is not running one. Must be called with the GIL held.
|
||||
static pybind11::object current_loop();
|
||||
|
||||
private:
|
||||
ScriptAsyncRuntime();
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> m_impl;
|
||||
};
|
||||
|
||||
// Resolve each configured capability reference to a loaded capability of type T and run `execute`.
|
||||
template<typename T>
|
||||
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
@@ -61,6 +62,80 @@
|
||||
} while (0)
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// True when `object` is a coroutine object (the value produced by calling an `async def`).
|
||||
// Must be called with the GIL held.
|
||||
inline bool py_is_coroutine(const pybind11::handle& object)
|
||||
{
|
||||
if (!object)
|
||||
return false;
|
||||
pybind11::object inspect = pybind11::module_::import("inspect");
|
||||
return inspect.attr("iscoroutine")(object).cast<bool>();
|
||||
}
|
||||
|
||||
// Result of invoking a Script capability's Python `execute` exactly once.
|
||||
//
|
||||
// A synchronous override (`def execute`) runs to completion here and yields `result`.
|
||||
// An asynchronous override (`async def execute`, or any override that returns a coroutine)
|
||||
// yields the coroutine object in `awaitable` WITHOUT running it: the cast to ExecutionResult
|
||||
// that the trampoline would otherwise perform is impossible for a coroutine, so the caller
|
||||
// chooses how to drive it. Whoever drives it must keep the capability referenced and re-open
|
||||
// this call's GIL + runtime lease + audit scope for the coroutine's whole lifetime (see
|
||||
// ScriptAsyncRuntime in PluginManager.hpp).
|
||||
struct ScriptExecuteInvocation
|
||||
{
|
||||
bool is_async = false;
|
||||
pybind11::object awaitable; // valid iff is_async; a coroutine object created under the GIL
|
||||
ExecutionResult result; // valid iff !is_async
|
||||
std::string error; // non-empty when the call or coroutine creation raised
|
||||
};
|
||||
|
||||
// Invokes ScriptPluginCapability::execute() under the GIL, replicating the audited trampoline
|
||||
// call (capability ref counter, audit scope, traceback-preserving rethrow) but returning a
|
||||
// coroutine object instead of forcing a cast when the plugin's `execute` is `async def`.
|
||||
//
|
||||
// `capability` must be the concrete, Python-bound capability instance (obtainable from
|
||||
// get_plugin_capability()); it is resolved back to its Python object through pybind's instance
|
||||
// map. Never throws: a Python exception is logged (full traceback) and reported in `error`.
|
||||
inline ScriptExecuteInvocation invoke_script_execute(const std::shared_ptr<PluginCapabilityInterface>& capability)
|
||||
{
|
||||
ScriptExecuteInvocation invocation;
|
||||
if (!capability)
|
||||
return invocation;
|
||||
|
||||
try {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
PluginCapabilityInterface::RefCounter ref_counter(*capability);
|
||||
|
||||
std::optional<ScopedPluginAuditContext> audit_scope;
|
||||
if (const std::string& audit_key = capability->audit_plugin_key(); !audit_key.empty())
|
||||
audit_scope.emplace(audit_key, capability->name());
|
||||
|
||||
pybind11::object py_capability = pybind11::cast(capability);
|
||||
pybind11::object returned = py_capability.attr("execute")();
|
||||
|
||||
if (py_is_coroutine(returned)) {
|
||||
invocation.is_async = true;
|
||||
invocation.awaitable = std::move(returned);
|
||||
return invocation;
|
||||
}
|
||||
|
||||
invocation.result = returned.cast<ExecutionResult>();
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
const bool audit_denial = PluginAuditManager::instance().audit_denial_pending();
|
||||
if (audit_denial)
|
||||
PluginAuditManager::instance().clear_current_plugin();
|
||||
invocation.error = err.what();
|
||||
log_python_exception_keep(err);
|
||||
if (audit_denial)
|
||||
PluginAuditManager::instance().clear_audit_denial();
|
||||
} catch (const std::exception& ex) {
|
||||
invocation.error = ex.what();
|
||||
}
|
||||
|
||||
return invocation;
|
||||
}
|
||||
|
||||
template<class Base> class PyPluginCommonTrampoline : public Base
|
||||
{
|
||||
public:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "PluginHostBindings.hpp"
|
||||
#include "PluginHostUi.hpp"
|
||||
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
||||
#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState
|
||||
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/cast.h>
|
||||
@@ -17,12 +18,18 @@
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/GUI/MainFrame.hpp>
|
||||
#include <slic3r/GUI/Plater.hpp>
|
||||
#include <slic3r/GUI/PresetComboBoxes.hpp> // TabPresetComboBox::set_filament_idx
|
||||
#include <slic3r/GUI/Tab.hpp>
|
||||
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <wx/string.h>
|
||||
|
||||
#include <functional>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace py = pybind11;
|
||||
@@ -48,6 +55,709 @@ PresetCollection& printer_presets(PresetBundle& bundle)
|
||||
return static_cast<PresetCollection&>(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";
|
||||
}
|
||||
}
|
||||
|
||||
PresetCollection& collection_for(PresetBundle& bundle, Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return bundle.prints;
|
||||
case Preset::TYPE_FILAMENT: return bundle.filaments;
|
||||
case Preset::TYPE_PRINTER: return printer_presets(bundle);
|
||||
default: throw std::invalid_argument("Unsupported preset category");
|
||||
}
|
||||
}
|
||||
|
||||
// Marker for a user-declined selection: Tab::select_preset() returned false. It derives from
|
||||
// std::runtime_error so the synchronous update() path keeps raising RuntimeError (pybind11 maps the
|
||||
// base), while the awaitable begin_update() path raises the distinct, documented
|
||||
// orca.host.preset.PresetSelectionCancelled.
|
||||
struct SelectionCancelled : std::runtime_error
|
||||
{
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// Marker for a selection the awaiting coroutine's cancellation overtook: the Future was cancelled
|
||||
// (task cancellation, or plugin unload) while the selection dialog was open. The user's already
|
||||
// accepted decision is not reversed; only the remaining slot assignment and the transaction
|
||||
// creation are skipped. Distinct from SelectionCancelled, which is the user declining.
|
||||
struct SelectionAbandoned : std::runtime_error
|
||||
{
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// True once the awaiting coroutine's Future has been cancelled. AsyncFuture sets this from its
|
||||
// loop-thread done-callback, so this reads only an atomic and never touches Python or the loop from
|
||||
// the UI thread. Empty for the synchronous path, which cannot be cancelled.
|
||||
using CancelProbe = std::function<bool()>;
|
||||
|
||||
// One PresetSelectionCancelled carrying `message`. The type is looked up live from the bound module
|
||||
// rather than cached in a static PyObject*: a cached reference would dangle across an interpreter
|
||||
// reset (plugin-system shutdown then re-init in one process). GIL held by the caller. Falls back to
|
||||
// RuntimeError when the binding is not present.
|
||||
py::object selection_cancelled_error(const std::string& message)
|
||||
{
|
||||
try {
|
||||
return py::module_::import("orca").attr("host").attr("preset").attr("PresetSelectionCancelled")(message);
|
||||
} catch (py::error_already_set&) {
|
||||
PyErr_Clear();
|
||||
return py::module_::import("builtins").attr("RuntimeError")(message);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// The staged preset update lifecycle described in docs/HLSD/preset-plugin-api.md.
|
||||
//
|
||||
// begin_update() resolves and binds one target (selecting it through the normal UI flow when
|
||||
// the caller asked for a preset that is not already active), returns a PresetTransaction that
|
||||
// stages changes with set(), and applies them only on commit(). The transaction holds names,
|
||||
// never preset pointers, so it stays valid across reloads; a target or filament slot that changed
|
||||
// while the operation was pending is rejected instead of silently editing the new mapping.
|
||||
//
|
||||
// begin_update() is awaitable: called from the background Script worker (a running asyncio loop),
|
||||
// it posts the target selection to the UI thread and returns a real Future immediately, resolving
|
||||
// it thread-safely (through PluginHostUi::post_to_ui_thread / loop.call_soon_threadsafe) with a
|
||||
// name-based PresetTransaction once the selection and any unsaved-edit dialog settle, or failing
|
||||
// it with the resolution error. A user-declined selection fails it with the distinct
|
||||
// orca.host.preset.PresetSelectionCancelled rather than a generic RuntimeError; that is separate
|
||||
// from asyncio task cancellation, which cancels the Future and is dropped by the resolver, so the
|
||||
// coroutine sees asyncio.CancelledError instead. No GUI or preset pointer crosses the suspension.
|
||||
// The synchronous update() shorthand keeps the original blocking path (a cancel still raises
|
||||
// RuntimeError there) and shares the same target resolution; it is safe only from that worker,
|
||||
// never from the UI thread or a slicing pipeline hook.
|
||||
//
|
||||
// Known limits of this implementation, kept explicit rather than hidden:
|
||||
// * A requested preset_name is resolved by the exact name its dropdown entry shows: the item's
|
||||
// display label or its stored item alias (the internal preset name). A name two entries carry
|
||||
// is rejected as ambiguous instead of being resolved by position. Process and printer resolve
|
||||
// against the tab dropdown; a filament preset_name resolves against the requested slot's own
|
||||
// sidebar selector, not the tab's, so its compatibility filter is the one that applies. A
|
||||
// target the selector does not offer fails before the flow's unsaved-edit prompt. The flow
|
||||
// itself is unchanged and the target is still confirmed as the edited preset afterwards.
|
||||
// * A filament preset_name that names a different preset for a slot selects it through
|
||||
// Tab::select_preset() (the normal flow, honouring unsaved-edit prompts) and then updates the
|
||||
// slot mapping with PresetBundle::set_filament_preset() plus every dependent side effect the
|
||||
// sidebar selection path performs: the project-dirty/selection refresh, the dynamic filament
|
||||
// list, the slot's own combo (re-run so it lists and selects the assigned target), the
|
||||
// conditional support-filament flushing recalc and the slot's AMS sync badge (read from the
|
||||
// slot combo's own flag). The private Plater::priv::on_select_preset() path cannot be reused
|
||||
// from here, so this mirrors its filament branch rather than calling it.
|
||||
// * Cancellation boundary: AsyncFuture observes a cancellation two ways. Its loop-thread
|
||||
// done-callback sets an atomic; is_cancelled() also reads the Future's own cancelled() under the
|
||||
// GIL, so a cancel that Future.cancel() already applied is seen even before the loop schedules
|
||||
// the done-callback. That closes the pre-start window and the after-modal window (checked before
|
||||
// any slot mapping). A selection still queued therefore never starts, and one already inside the
|
||||
// modal stops after the modal returns: the remaining slot assignment and the transaction are
|
||||
// skipped. A late cancel that lands after the user already accepted a selection, or after the
|
||||
// transaction was created, is NOT rolled back: accepted UI decisions stand. The narrow window
|
||||
// where a cancel and the operation's start race cannot be fully eliminated, but whichever side
|
||||
// wins the coroutine still observes asyncio.CancelledError, not a plugin error.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Outcome of one committed transaction. Identifies the target, whether selecting it switched
|
||||
// anything, and for filament (whose preset may be shared) which slots share the edited preset.
|
||||
struct PresetUpdateResult
|
||||
{
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
std::string target_name;
|
||||
bool selection_changed = false;
|
||||
int slot_number = -1; // 1-based for filament, -1 otherwise
|
||||
bool slot_assignment_changed = false;
|
||||
std::vector<int> shared_slots;
|
||||
std::vector<std::string> keys;
|
||||
bool dirty = false;
|
||||
};
|
||||
|
||||
struct PresetTransaction
|
||||
{
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
std::string target_name;
|
||||
bool filament = false;
|
||||
int slot_number = 0; // 1-based, filament only
|
||||
std::string slot_assignment_snapshot; // slot's assigned preset as of begin_update()
|
||||
bool selection_changed = false;
|
||||
bool slot_assignment_changed = false;
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> staged;
|
||||
std::set<std::string> staged_keys;
|
||||
bool committed = false;
|
||||
|
||||
void set(const std::string& key, const std::string& value);
|
||||
PresetUpdateResult commit();
|
||||
};
|
||||
|
||||
GUI::GUI_App& require_gui_app()
|
||||
{
|
||||
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");
|
||||
return app;
|
||||
}
|
||||
|
||||
GUI::Tab* require_tab(GUI::GUI_App& app, Preset::Type type)
|
||||
{
|
||||
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");
|
||||
return tab;
|
||||
}
|
||||
|
||||
// True for a combo row that is a group header, a marker or a disabled row: it carries a marker in
|
||||
// its client data and is not a selectable preset.
|
||||
bool combo_item_is_marker(GUI::PresetComboBox& combo, int item)
|
||||
{
|
||||
const auto marker = reinterpret_cast<std::size_t>(combo.GetClientData(item));
|
||||
return marker >= GUI::PresetComboBox::LABEL_ITEM_DISABLED && marker < GUI::PresetComboBox::LABEL_ITEM_MAX;
|
||||
}
|
||||
|
||||
// How a caller-supplied name resolved against a combo's actual items.
|
||||
struct ComboPresetMatch
|
||||
{
|
||||
int item = -1; // first matching item, or -1
|
||||
bool ambiguous = false; // two distinct items carry this name
|
||||
std::string internal_name; // internal preset name of the match (empty when none)
|
||||
};
|
||||
|
||||
// Resolve `requested` by the name each combo item shows: its display label, or its stored item
|
||||
// alias (the internal preset name, or the printer model for a system printer preset). Names are
|
||||
// compared with the modified-dirty suffix removed, i.e. exactly what the dropdown shows. Reading
|
||||
// the combo's own items means this respects the combo's real selectable model (visibility, its
|
||||
// show-incompatible state and, for a slot combo, that slot's compatibility). A name that two
|
||||
// distinct items carry is reported ambiguous instead of being resolved by position.
|
||||
ComboPresetMatch resolve_combo_preset(GUI::PresetComboBox& combo, const std::string& requested)
|
||||
{
|
||||
ComboPresetMatch match;
|
||||
const std::string want = Preset::remove_suffix_modified(requested);
|
||||
const int item_count = combo.GetCount();
|
||||
for (int item = 0; item < item_count; ++item) {
|
||||
if (combo_item_is_marker(combo, item))
|
||||
continue;
|
||||
const wxString alias = combo.GetItemAlias(item);
|
||||
const std::string alias_name = alias.empty() ? std::string()
|
||||
: Preset::remove_suffix_modified(alias.ToUTF8().data());
|
||||
const std::string label_name = Preset::remove_suffix_modified(combo.GetString(item).ToUTF8().data());
|
||||
if ((alias_name.empty() || alias_name != want) && label_name != want)
|
||||
continue;
|
||||
if (match.item >= 0 && match.item != item) {
|
||||
match.ambiguous = true;
|
||||
return match;
|
||||
}
|
||||
match.item = item;
|
||||
match.internal_name = alias_name.empty() ? label_name : alias_name;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
// Index of the item whose internal name is `preset_name`, or -1. Used for per-item flags (the AMS
|
||||
// sync badge); the internal name is unique, so no ambiguity handling is needed here.
|
||||
int combo_item_for_preset(GUI::PresetComboBox& combo, const std::string& preset_name)
|
||||
{
|
||||
const std::string want = Preset::remove_suffix_modified(preset_name);
|
||||
const int item_count = combo.GetCount();
|
||||
for (int item = 0; item < item_count; ++item) {
|
||||
if (combo_item_is_marker(combo, item))
|
||||
continue;
|
||||
const wxString alias = combo.GetItemAlias(item);
|
||||
const std::string alias_name = alias.empty() ? std::string()
|
||||
: Preset::remove_suffix_modified(alias.ToUTF8().data());
|
||||
if (!alias_name.empty() && alias_name == want)
|
||||
return item;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Resolve `requested` to the internal preset name its dropdown entry names, or throw. `category`
|
||||
// and `selector` are used only for the error text. A name the selector does not offer, or that two
|
||||
// entries share, raises std::invalid_argument.
|
||||
std::string resolve_preset_in_combo(GUI::PresetComboBox& combo, const std::string& requested,
|
||||
const std::string& category, const std::string& selector)
|
||||
{
|
||||
const ComboPresetMatch match = resolve_combo_preset(combo, requested);
|
||||
if (match.ambiguous)
|
||||
throw std::invalid_argument("The " + category + " name \"" + requested + "\" is ambiguous in the " + selector
|
||||
+ "; more than one entry shows it, so name the preset by its own entry");
|
||||
if (match.item < 0)
|
||||
throw std::invalid_argument("The " + category + " preset \"" + requested + "\" is not offered by the " + selector);
|
||||
return match.internal_name;
|
||||
}
|
||||
|
||||
// The preset a filament slot currently resolves to, by its internal name. A slot whose stored
|
||||
// name is empty or does not resolve yields an error identifying the slot.
|
||||
std::string resolved_slot_preset(PresetBundle& bundle, int slot_number)
|
||||
{
|
||||
if (slot_number < 1 || static_cast<size_t>(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<size_t>(slot_number) - 1];
|
||||
if (assigned_name.empty())
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset");
|
||||
Preset* preset = bundle.filaments.find_preset(assigned_name);
|
||||
if (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");
|
||||
return preset->name;
|
||||
}
|
||||
|
||||
void PresetTransaction::set(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (committed)
|
||||
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
|
||||
if (key.empty())
|
||||
throw std::invalid_argument("A setting key is required");
|
||||
// Conflicting duplicate writes to one setting are rejected rather than resolved by order.
|
||||
if (!staged_keys.insert(key).second)
|
||||
throw std::invalid_argument("Setting \"" + key + "\" is already staged in this transaction; "
|
||||
"conflicting duplicate writes to one setting are rejected");
|
||||
staged.emplace_back(key, value);
|
||||
}
|
||||
|
||||
PresetUpdateResult PresetTransaction::commit()
|
||||
{
|
||||
if (committed)
|
||||
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
|
||||
if (type == Preset::TYPE_INVALID)
|
||||
throw std::runtime_error("This preset transaction has no target");
|
||||
|
||||
PresetUpdateResult result;
|
||||
PluginHostUi::run_on_ui_thread([&] {
|
||||
GUI::GUI_App& app = require_gui_app();
|
||||
GUI::Tab* tab = require_tab(app, type);
|
||||
PresetBundle& bundle = *app.preset_bundle;
|
||||
PresetCollection& collection = collection_for(bundle, type);
|
||||
|
||||
// The target must still be the edited preset: the user may have switched away while the
|
||||
// transaction was staged, and editing a different preset would break "explicit target".
|
||||
const std::string edited = collection.get_edited_preset().name;
|
||||
if (edited != target_name)
|
||||
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \"" + edited
|
||||
+ "\", not this transaction's target \"" + target_name
|
||||
+ "\"; nothing was applied");
|
||||
|
||||
if (filament) {
|
||||
// Reject a stale slot mapping instead of editing whatever the slot now points at.
|
||||
const std::string now_assigned = resolved_slot_preset(bundle, slot_number);
|
||||
if (now_assigned != slot_assignment_snapshot)
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " now holds \"" + now_assigned
|
||||
+ "\", not \"" + slot_assignment_snapshot
|
||||
+ "\" as when this transaction was created; the slot mapping changed, so nothing was applied");
|
||||
}
|
||||
|
||||
// Apply the whole batch through the tab's batch path: it validates every key and value before
|
||||
// touching the preset, rejects duplicate keys, runs the same per-setting commit path a hand
|
||||
// edit uses (validation, on_change, dirty state) and refreshes once for the batch. A failure
|
||||
// restores the edited preset, so no staged setting survives a failed commit.
|
||||
tab->apply_preset_config_values(staged);
|
||||
|
||||
result.type = type;
|
||||
result.target_name = target_name;
|
||||
result.selection_changed = selection_changed;
|
||||
result.slot_number = filament ? slot_number : -1;
|
||||
result.slot_assignment_changed = slot_assignment_changed;
|
||||
result.keys.reserve(staged.size());
|
||||
for (const auto& entry : staged)
|
||||
result.keys.push_back(entry.first);
|
||||
result.dirty = collection.get_edited_preset().is_dirty;
|
||||
|
||||
if (filament) {
|
||||
// Every slot whose assigned preset resolves to the edited one is affected: the preset
|
||||
// is shared, not a per-slot override.
|
||||
for (std::size_t index = 0; index < bundle.filament_presets.size(); ++index) {
|
||||
Preset* preset = bundle.filaments.find_preset(bundle.filament_presets[index]);
|
||||
if (preset != nullptr && preset->name == target_name)
|
||||
result.shared_slots.push_back(static_cast<int>(index) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
committed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Process/printer target resolution: the active preset when no name is given, otherwise the named
|
||||
// preset selected through the normal tab-selection flow (which enforces the dropdown/compatibility
|
||||
// rules and the unsaved-edit prompt). Binds only after confirming the requested preset is edited.
|
||||
// Runs on the UI thread with the GIL released; both the blocking and the awaitable entry points
|
||||
// share it (the awaitable one invokes it directly from its UI callback, never through
|
||||
// run_on_ui_thread()).
|
||||
void bind_preset_target(Preset::Type type, const std::string& preset_name, PresetTransaction& transaction,
|
||||
const CancelProbe& cancelled = {})
|
||||
{
|
||||
GUI::GUI_App& app = require_gui_app();
|
||||
GUI::Tab* tab = require_tab(app, type);
|
||||
PresetCollection& collection = collection_for(*app.preset_bundle, type);
|
||||
|
||||
const std::string current = collection.get_edited_preset().name;
|
||||
std::string target = current;
|
||||
bool selection_changed = false;
|
||||
|
||||
if (!preset_name.empty()) {
|
||||
// Resolve by the name the tab's dropdown actually shows (display label or item alias), so a
|
||||
// plugin can pass exactly what the user sees. With no populated dropdown there is nothing
|
||||
// to resolve against, so fall back to collection internal names.
|
||||
GUI::TabPresetComboBox* combo = tab->get_combo_box();
|
||||
std::string resolved;
|
||||
if (combo != nullptr && combo->GetCount() > 0)
|
||||
resolved = resolve_preset_in_combo(*combo, preset_name, preset_tab_name(type),
|
||||
"preset selector for the selected printer");
|
||||
else
|
||||
resolved = preset_name;
|
||||
|
||||
Preset* requested = collection.find_preset(resolved);
|
||||
if (requested == nullptr)
|
||||
throw std::invalid_argument("No " + std::string(preset_tab_name(type)) + " preset named \"" + resolved
|
||||
+ "\" is available to select");
|
||||
target = requested->name;
|
||||
if (target != current) {
|
||||
// Normal UI selection: honours the unsaved-edit prompt; a declined prompt returns false
|
||||
// and is a user cancellation, distinct from any other resolution failure.
|
||||
if (!tab->select_preset(target))
|
||||
throw SelectionCancelled("Switching the edited " + std::string(preset_tab_name(type)) + " preset to \""
|
||||
+ target + "\" was canceled; nothing was staged");
|
||||
// A cancelled task must not go on to bind a transaction. The accepted selection is the
|
||||
// user's decision and is left in place; only the remaining work stops.
|
||||
if (cancelled && cancelled())
|
||||
throw SelectionAbandoned("The edited " + std::string(preset_tab_name(type)) + " preset was switched to \""
|
||||
+ target + "\", but the plugin operation was cancelled; the transaction was not created");
|
||||
selection_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// select_preset() can still leave a different preset edited (e.g. the target is not an
|
||||
// allowed dropdown choice), so confirm before binding.
|
||||
if (collection.get_edited_preset().name != target)
|
||||
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \""
|
||||
+ collection.get_edited_preset().name + "\", not \"" + target
|
||||
+ "\"; the transaction was not created");
|
||||
|
||||
transaction.type = type;
|
||||
transaction.target_name = target;
|
||||
transaction.selection_changed = selection_changed;
|
||||
}
|
||||
|
||||
// Blocking form, used by update() and by any caller that cannot suspend.
|
||||
PresetTransaction begin_preset_update(Preset::Type type, const std::string& preset_name)
|
||||
{
|
||||
PresetTransaction transaction;
|
||||
PluginHostUi::run_on_ui_thread([&] { bind_preset_target(type, preset_name, transaction); });
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Filament target resolution: slot_number identifies the slot; an omitted preset_name keeps the
|
||||
// preset assigned to it. A supplied preset_name selects that preset for the slot through the normal
|
||||
// tab-selection flow, then records the slot assignment and its dependent Plater side effects (the
|
||||
// ones the sidebar selection path performs) only after that flow succeeds, so a canceled prompt
|
||||
// changes neither. A mixed-color virtual slot is rejected before any of this, with or without a
|
||||
// preset_name. A requested preset the requested slot's own dropdown does not offer is rejected
|
||||
// before the selection flow, so no dialog is raised for it; the one exception is the slot's own
|
||||
// current assignment, which stays targetable when the dropdown hides it. A different preset the
|
||||
// tab happens to edit is a globally edited target, not an offered choice for this slot, and is
|
||||
// rejected.
|
||||
// Runs on the UI thread with the GIL released; shared by the blocking and awaitable entry points.
|
||||
void bind_filament_target(int slot_number, const std::string& preset_name, PresetTransaction& transaction,
|
||||
const CancelProbe& cancelled = {})
|
||||
{
|
||||
GUI::GUI_App& app = require_gui_app();
|
||||
PresetBundle& bundle = *app.preset_bundle;
|
||||
GUI::Plater* plater = app.plater();
|
||||
|
||||
// A mixed-color virtual slot has no single filament preset behind it, so every form of this
|
||||
// call is rejected: with or without a preset_name there is no one preset to edit or assign.
|
||||
// Range-guarded so an out-of-range slot still gets resolved_slot_preset()'s clear error.
|
||||
if (slot_number >= 1 && static_cast<size_t>(slot_number) <= bundle.filament_presets.size()
|
||||
&& bundle.is_mixed_filament(static_cast<size_t>(slot_number) - 1))
|
||||
throw std::invalid_argument("Filament slot " + std::to_string(slot_number)
|
||||
+ " is a mixed-color virtual slot; it has no single filament preset to edit or assign");
|
||||
|
||||
// Resolve and validate before any selection, so a bad slot fails without a prompt.
|
||||
const std::string assigned_internal = resolved_slot_preset(bundle, slot_number);
|
||||
|
||||
GUI::Tab* tab = require_tab(app, Preset::TYPE_FILAMENT);
|
||||
|
||||
const size_t slot_index = static_cast<size_t>(slot_number) - 1;
|
||||
|
||||
std::string target = assigned_internal;
|
||||
if (!preset_name.empty()) {
|
||||
// Resolve against the requested sidebar slot's own selector, not the tab's: that is the
|
||||
// model the user picks this slot's filament from, including its compatibility filter. The
|
||||
// selector matches the exact name shown (display label or item alias).
|
||||
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
|
||||
GUI::PlaterPresetComboBox* slot_combo = slot_index < slot_combos.size() ? slot_combos[slot_index] : nullptr;
|
||||
std::string resolved;
|
||||
if (slot_combo != nullptr && slot_combo->GetCount() > 0) {
|
||||
const ComboPresetMatch match = resolve_combo_preset(*slot_combo, preset_name);
|
||||
if (match.ambiguous)
|
||||
throw std::invalid_argument("The filament name \"" + preset_name
|
||||
+ "\" is ambiguous in the filament preset selector for filament slot "
|
||||
+ std::to_string(slot_number)
|
||||
+ "; more than one entry shows it, so name the preset by its own entry");
|
||||
if (match.item >= 0) {
|
||||
resolved = match.internal_name;
|
||||
} else {
|
||||
// Not shown by this slot's selector. Only the slot's current assignment stays
|
||||
// targetable (a hidden or incompatible-but-selected preset the dropdown omits);
|
||||
// resolve it by internal name or by display alias and accept only that preset. A
|
||||
// different preset the tab happens to edit is a globally edited target, not an
|
||||
// offered choice for this slot, and is rejected.
|
||||
Preset* by_name = bundle.filaments.find_preset(preset_name);
|
||||
if (by_name == nullptr) {
|
||||
const std::string& alias_name = bundle.filaments.get_preset_name_by_alias(preset_name);
|
||||
by_name = bundle.filaments.find_preset(alias_name);
|
||||
}
|
||||
if (by_name == nullptr || by_name->name != assigned_internal)
|
||||
throw std::invalid_argument("Filament preset \"" + preset_name
|
||||
+ "\" is not offered by the filament preset selector for filament slot "
|
||||
+ std::to_string(slot_number));
|
||||
resolved = by_name->name;
|
||||
}
|
||||
} else {
|
||||
// The slot selector is not populated; fall back to the tab selector, then to collection
|
||||
// internal names, so the call still works when the sidebar has not been laid out.
|
||||
GUI::TabPresetComboBox* tab_combo = tab->get_combo_box();
|
||||
if (tab_combo != nullptr && tab_combo->GetCount() > 0)
|
||||
resolved = resolve_preset_in_combo(*tab_combo, preset_name, "filament",
|
||||
"filament preset selector for the selected printer");
|
||||
else
|
||||
resolved = preset_name;
|
||||
}
|
||||
|
||||
Preset* requested = bundle.filaments.find_preset(resolved);
|
||||
if (requested == nullptr)
|
||||
throw std::invalid_argument("No filament preset named \"" + resolved + "\" is available to select");
|
||||
target = requested->name;
|
||||
}
|
||||
|
||||
bool selection_changed = false;
|
||||
if (bundle.filaments.get_edited_preset().name != target) {
|
||||
// A declined prompt is a user cancellation: it is thrown before any slot-assignment change,
|
||||
// so neither the mapping nor a setting is touched.
|
||||
if (!tab->select_preset(target))
|
||||
throw SelectionCancelled("Switching the edited filament preset to \"" + target + "\" for filament slot "
|
||||
+ std::to_string(slot_number)
|
||||
+ " was canceled; neither the slot assignment nor any setting was changed");
|
||||
// The modal above may have been open when the awaiting task was cancelled. The accepted
|
||||
// selection stands (we do not reverse a user decision), but the remaining slot assignment
|
||||
// and the transaction are skipped.
|
||||
if (cancelled && cancelled())
|
||||
throw SelectionAbandoned("The edited filament preset was switched to \"" + target
|
||||
+ "\", but the plugin operation was cancelled before filament slot "
|
||||
+ std::to_string(slot_number)
|
||||
+ " was reassigned; neither the slot assignment nor the transaction was created");
|
||||
if (bundle.filaments.get_edited_preset().name != target)
|
||||
throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name
|
||||
+ "\", not \"" + target + "\"; the transaction was not created");
|
||||
selection_changed = true;
|
||||
}
|
||||
|
||||
bool assignment_changed = false;
|
||||
if (!preset_name.empty() && target != assigned_internal) {
|
||||
// Cancellation may also arrive on the modal-free path (the tab already edited the target).
|
||||
if (cancelled && cancelled())
|
||||
throw SelectionAbandoned("The plugin operation was cancelled before filament slot " + std::to_string(slot_number)
|
||||
+ " was reassigned; neither the slot assignment nor the transaction was created");
|
||||
if (app.app_config == nullptr)
|
||||
throw std::runtime_error("Application configuration is not available");
|
||||
// Mirror the filament branch of Plater::priv::on_select_preset() (the sidebar's normal
|
||||
// preset selection): update the mapping, then every dependent project/UI side effect,
|
||||
// rather than the vector entry alone.
|
||||
const bool was_support = GUI::is_support_filament(static_cast<int>(slot_index));
|
||||
bundle.set_filament_preset(slot_index, target);
|
||||
plater->update_project_dirty_from_presets();
|
||||
bundle.export_selections(*app.app_config);
|
||||
plater->sidebar().update_dynamic_filament_list();
|
||||
// Assigning a support filament (or moving away from one) changes which filaments the
|
||||
// flush rules apply to, so the normal flow recalculates the flushing volumes when
|
||||
// auto-calc covers every filament.
|
||||
if (GUI::is_support_filament(static_cast<int>(slot_index)) != was_support
|
||||
&& app.app_config->get("auto_calculate_flush") == "all")
|
||||
plater->sidebar().auto_calc_flushing_volumes(static_cast<int>(slot_index));
|
||||
// Refresh the requested slot's own sidebar combo so its list and current selection show the
|
||||
// newly assigned target (the list may not have contained it), matching the sidebar
|
||||
// selection path. Fetch the pointer after update_dynamic_filament_list(), which may
|
||||
// rebuild the combo vector.
|
||||
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
|
||||
if (slot_index < slot_combos.size() && slot_combos[slot_index] != nullptr) {
|
||||
GUI::PlaterPresetComboBox* slot_combo = slot_combos[slot_index];
|
||||
slot_combo->update();
|
||||
// Keep the slot's AMS sync badge truthful, reading the item's own flag, the same source
|
||||
// on_select_preset() reads, so no UI state is invented here.
|
||||
const int item = combo_item_for_preset(*slot_combo, target);
|
||||
if (item >= 0)
|
||||
slot_combo->set_sync_badge(slot_combo->GetFlag(item)
|
||||
== static_cast<int>(GUI::PresetComboBox::FilamentAMSType::FROM_AMS));
|
||||
}
|
||||
plater->on_filament_change(slot_index);
|
||||
assignment_changed = true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
transaction.type = Preset::TYPE_FILAMENT;
|
||||
transaction.filament = true;
|
||||
transaction.slot_number = slot_number;
|
||||
transaction.target_name = target;
|
||||
transaction.selection_changed = selection_changed;
|
||||
transaction.slot_assignment_changed = assignment_changed;
|
||||
// Snapshot the mapping after the explicit assignment, as the slot's resolved internal name,
|
||||
// so commit() compares like with like (a stored alias resolves to the same internal preset).
|
||||
transaction.slot_assignment_snapshot = resolved_slot_preset(bundle, slot_number);
|
||||
}
|
||||
|
||||
// Blocking form, used by the update() shorthand.
|
||||
PresetTransaction begin_filament_update(int slot_number, const std::string& preset_name)
|
||||
{
|
||||
PresetTransaction transaction;
|
||||
PluginHostUi::run_on_ui_thread([&] { bind_filament_target(slot_number, preset_name, transaction); });
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Compatibility shorthand: one staged setting, committed immediately through the same
|
||||
// begin_update()/set()/commit() path, so targeting, selection, validation and commit are shared.
|
||||
void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value)
|
||||
{
|
||||
PresetTransaction transaction = begin_preset_update(type, std::string());
|
||||
transaction.set(key, value);
|
||||
transaction.commit();
|
||||
}
|
||||
|
||||
void update_filament_slot(int slot_number, const std::string& key, const std::string& value)
|
||||
{
|
||||
PresetTransaction transaction = begin_filament_update(slot_number, std::string());
|
||||
transaction.set(key, value);
|
||||
transaction.commit();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Awaitable begin_update(): see the lifecycle comment above.
|
||||
//
|
||||
// Each entry point runs on the plugin worker with the GIL held and an asyncio loop running. It
|
||||
// validates the application synchronously (so a call without a wx app fails exactly like update()),
|
||||
// creates a Future on the running loop, posts the selection to the UI thread, and returns the
|
||||
// Future at once. The UI callback runs bind_*_target() with the GIL released, then -- with the GIL
|
||||
// re-acquired -- resolves the Future with the resulting PresetTransaction, or fails it with the
|
||||
// resolution error. Nothing is staged until the caller's later set()/commit(); a canceled or
|
||||
// unavailable target changes no preset and maps no slot.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Map the failures bind_*_target() raises to the Python exceptions the awaitable path should
|
||||
// surface. A user cancellation becomes the distinct PresetSelectionCancelled (not a plain
|
||||
// RuntimeError, and not asyncio.CancelledError, which is what a cancelled task raises and is
|
||||
// dropped by the future resolver before it ever reaches the coroutine). The rest mirror what
|
||||
// pybind11 would raise for the blocking path (std::invalid_argument -> ValueError,
|
||||
// std::out_of_range -> IndexError).
|
||||
py::object preset_update_failure(const std::exception& error)
|
||||
{
|
||||
if (dynamic_cast<const SelectionCancelled*>(&error) != nullptr)
|
||||
return selection_cancelled_error(error.what());
|
||||
if (dynamic_cast<const std::invalid_argument*>(&error) != nullptr)
|
||||
return py::module_::import("builtins").attr("ValueError")(error.what());
|
||||
if (dynamic_cast<const std::out_of_range*>(&error) != nullptr)
|
||||
return py::module_::import("builtins").attr("IndexError")(error.what());
|
||||
return py::module_::import("builtins").attr("RuntimeError")(error.what());
|
||||
}
|
||||
|
||||
// Fail an awaitable whose UI work could not be posted at all (the application went away between the
|
||||
// synchronous check and post_to_ui_thread()). Called with the GIL held.
|
||||
void fail_unpostable(const std::shared_ptr<AsyncFuture>& handle, const char* message)
|
||||
{
|
||||
handle->fail(py::module_::import("builtins").attr("RuntimeError")(message));
|
||||
}
|
||||
|
||||
py::object begin_preset_update_async(Preset::Type type, const std::string& preset_name)
|
||||
{
|
||||
PluginHostUi::require_ui_application();
|
||||
std::shared_ptr<AsyncFuture> handle = AsyncFuture::create();
|
||||
PluginHostUi::track_pending_async(handle);
|
||||
py::object future = handle->future();
|
||||
|
||||
const bool posted = PluginHostUi::post_to_ui_thread([type, preset_name, handle]() {
|
||||
// The plugin was unloaded, or the task was cancelled, before this ran: do not select. This
|
||||
// reads the Future's own cancelled() (not only the atomic), so a cancel Future.cancel()
|
||||
// already applied is seen even before the loop runs its done-callback.
|
||||
if (handle->is_cancelled())
|
||||
return;
|
||||
PresetTransaction transaction;
|
||||
try {
|
||||
bind_preset_target(type, preset_name, transaction, [handle]() { return handle->is_cancelled(); });
|
||||
} catch (const std::exception& error) {
|
||||
PythonGILState gil;
|
||||
if (gil)
|
||||
handle->fail(preset_update_failure(error));
|
||||
return;
|
||||
}
|
||||
// Cancelled after the bind but before delivery: the resolver would drop the result anyway.
|
||||
if (handle->is_cancelled())
|
||||
return;
|
||||
PythonGILState gil;
|
||||
if (gil)
|
||||
handle->complete(py::cast(transaction));
|
||||
});
|
||||
if (!posted)
|
||||
fail_unpostable(handle, "OrcaSlicer application is not initialized");
|
||||
return future;
|
||||
}
|
||||
|
||||
py::object begin_filament_update_async(int slot_number, const std::string& preset_name)
|
||||
{
|
||||
PluginHostUi::require_ui_application();
|
||||
std::shared_ptr<AsyncFuture> handle = AsyncFuture::create();
|
||||
PluginHostUi::track_pending_async(handle);
|
||||
py::object future = handle->future();
|
||||
|
||||
const bool posted = PluginHostUi::post_to_ui_thread([slot_number, preset_name, handle]() {
|
||||
// The plugin was unloaded, or the task was cancelled, before this ran: do not select, so a
|
||||
// queued UI selection never starts. This reads the Future's own cancelled() (not only the
|
||||
// atomic), so a cancel Future.cancel() already applied is seen even before the loop runs its
|
||||
// done-callback.
|
||||
if (handle->is_cancelled())
|
||||
return;
|
||||
PresetTransaction transaction;
|
||||
try {
|
||||
bind_filament_target(slot_number, preset_name, transaction, [handle]() { return handle->is_cancelled(); });
|
||||
} catch (const std::exception& error) {
|
||||
PythonGILState gil;
|
||||
if (gil)
|
||||
handle->fail(preset_update_failure(error));
|
||||
return;
|
||||
}
|
||||
// Cancelled after the bind but before delivery: the resolver would drop the result anyway.
|
||||
if (handle->is_cancelled())
|
||||
return;
|
||||
PythonGILState gil;
|
||||
if (gil)
|
||||
handle->complete(py::cast(transaction));
|
||||
});
|
||||
if (!posted)
|
||||
fail_unpostable(handle, "OrcaSlicer application is not initialized");
|
||||
return future;
|
||||
}
|
||||
|
||||
void reload_local_bundle(const std::string& bundle_id)
|
||||
{
|
||||
bool reloaded = false;
|
||||
@@ -100,7 +810,7 @@ std::vector<std::string> list_bundle_ids()
|
||||
|
||||
void host_bindings::register_presets(py::module_& host)
|
||||
{
|
||||
auto preset = host.def_submodule("preset", "Preset operations");
|
||||
auto preset = host.def_submodule("preset", "Preset operations");
|
||||
auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
|
||||
bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
|
||||
"Reload one local preset bundle from the active user's _local directory.");
|
||||
@@ -108,6 +818,19 @@ void host_bindings::register_presets(py::module_& host)
|
||||
"IDs of local preset bundles in the active user's _local directory that reload_local() accepts, "
|
||||
"including bundles added after startup.");
|
||||
|
||||
// The outcome of a user-declined selection in the awaitable begin_update(), told apart from an
|
||||
// invalid/unavailable target (ValueError) and from asyncio task cancellation
|
||||
// (asyncio.CancelledError). It subclasses RuntimeError, so a plugin catching RuntimeError still
|
||||
// handles it, and the synchronous update() path is unchanged (it raises RuntimeError). The type
|
||||
// is owned by the module (and looked up live when raised), not cached in a static reference, so
|
||||
// a later interpreter reset cannot leave a stale pointer behind.
|
||||
py::dict selection_cancelled_ns;
|
||||
selection_cancelled_ns["__module__"] = py::str("orca.host.preset");
|
||||
preset.attr("PresetSelectionCancelled") = py::module_::import("builtins").attr("type")(
|
||||
"PresetSelectionCancelled",
|
||||
py::make_tuple(py::reinterpret_borrow<py::object>(PyExc_RuntimeError)),
|
||||
selection_cancelled_ns);
|
||||
|
||||
py::enum_<Preset::Type>(host, "PresetType")
|
||||
.value("Invalid", Preset::TYPE_INVALID)
|
||||
.value("Print", Preset::TYPE_PRINT)
|
||||
@@ -119,6 +842,146 @@ void host_bindings::register_presets(py::module_& host)
|
||||
.value("Plate", Preset::TYPE_PLATE)
|
||||
.value("Model", Preset::TYPE_MODEL);
|
||||
|
||||
py::class_<PresetUpdateResult>(preset, "PresetUpdateResult",
|
||||
"Outcome of one committed preset transaction. target_name is the preset that was "
|
||||
"edited; selection_changed says whether begin_update() switched the active preset; "
|
||||
"for filament, slot_number is the 1-based target slot, slot_assignment_changed "
|
||||
"says whether selecting the requested preset reassigned that slot, and shared_slots "
|
||||
"lists every 1-based slot whose assigned preset is the edited (shared) one. keys "
|
||||
"lists the settings applied, in order, and dirty says whether the edited preset "
|
||||
"now differs from its saved state.")
|
||||
.def_readonly("type", &PresetUpdateResult::type)
|
||||
.def_readonly("target_name", &PresetUpdateResult::target_name)
|
||||
.def_readonly("selection_changed", &PresetUpdateResult::selection_changed)
|
||||
.def_readonly("slot_number", &PresetUpdateResult::slot_number)
|
||||
.def_readonly("slot_assignment_changed", &PresetUpdateResult::slot_assignment_changed)
|
||||
.def_readonly("shared_slots", &PresetUpdateResult::shared_slots)
|
||||
.def_readonly("keys", &PresetUpdateResult::keys)
|
||||
.def_readonly("dirty", &PresetUpdateResult::dirty)
|
||||
.def_property_readonly("category", [](const PresetUpdateResult& result) { return preset_tab_name(result.type); })
|
||||
.def("__repr__", [](const PresetUpdateResult& result) {
|
||||
return std::string("<PresetUpdateResult ") + preset_tab_name(result.type) + " \"" + result.target_name
|
||||
+ "\" keys=" + std::to_string(result.keys.size()) + ">";
|
||||
});
|
||||
|
||||
py::class_<PresetTransaction>(preset, "PresetTransaction",
|
||||
"A staged, single-target preset edit returned by awaiting begin_update(). set() stages "
|
||||
"a serialized value without mutating the preset; commit() validates and applies the "
|
||||
"whole batch through the normal setting-change path. begin_update() itself suspends "
|
||||
"until any selection or unsaved-edit dialog is answered, without blocking the UI "
|
||||
"thread; set() and commit() run synchronously on the plugin worker. Nothing is saved "
|
||||
"to disk: the edited preset stays dirty until the user saves it.")
|
||||
.def_readonly("type", &PresetTransaction::type)
|
||||
.def_readonly("target_name", &PresetTransaction::target_name)
|
||||
.def_readonly("selection_changed", &PresetTransaction::selection_changed)
|
||||
.def_property_readonly("slot_number", [](const PresetTransaction& transaction) {
|
||||
return transaction.filament ? transaction.slot_number : -1;
|
||||
})
|
||||
.def_property_readonly("category", [](const PresetTransaction& transaction) { return preset_tab_name(transaction.type); })
|
||||
.def_property_readonly("staged_keys", [](const PresetTransaction& transaction) {
|
||||
std::vector<std::string> keys;
|
||||
keys.reserve(transaction.staged.size());
|
||||
for (const auto& entry : transaction.staged)
|
||||
keys.push_back(entry.first);
|
||||
return keys;
|
||||
})
|
||||
.def("set", &PresetTransaction::set, py::arg("key"), py::arg("value"),
|
||||
"Stage one setting change. key must be a setting of the target preset category and value its "
|
||||
"serialized string form; staging validates neither, so commit() is where an invalid key or value "
|
||||
"fails. Staging the same key twice in one transaction raises ValueError (conflicting duplicate "
|
||||
"writes are rejected). After commit() the transaction is closed.")
|
||||
.def("commit", &PresetTransaction::commit,
|
||||
"Validate and apply every staged setting through the same path as editing it in the GUI, then return "
|
||||
"a PresetUpdateResult. An invalid key or value raises ValueError; a changed target or filament slot "
|
||||
"mapping raises RuntimeError; a failure leaves none of the staged settings applied. The edited preset "
|
||||
"becomes dirty until the user saves it.");
|
||||
|
||||
py::class_<PresetEditor>(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(
|
||||
"begin_update",
|
||||
[](const PresetEditor& editor, const std::string& preset_name) {
|
||||
if (editor.type == Preset::TYPE_FILAMENT)
|
||||
throw std::invalid_argument("begin_update on the filament editor takes "
|
||||
"(slot_number, preset_name=\"\"); use orca.host.preset.filament.begin_update(slot_number, ...)");
|
||||
return begin_preset_update_async(editor.type, preset_name);
|
||||
},
|
||||
py::arg("preset_name") = "",
|
||||
"Awaitable. Resolve and bind this category's target preset and resolve to a PresetTransaction. With no "
|
||||
"preset_name the currently active preset is targeted; a preset_name must be a preset this category can "
|
||||
"select, and is selected through the normal preset-selection flow (which prompts about unsaved edits). "
|
||||
"The returned awaitable is pending until any such dialog is answered. A selection the user cancels raises "
|
||||
"orca.host.preset.PresetSelectionCancelled (a RuntimeError subclass; distinct from asyncio task "
|
||||
"cancellation, which raises asyncio.CancelledError); an unavailable or invalid target raises ValueError. "
|
||||
"Either failure creates no transaction and stages no setting. Must be awaited from the background Script "
|
||||
"worker's running event loop. This form is for process and printer; filament takes "
|
||||
"begin_update(slot_number, preset_name=\"\").")
|
||||
.def(
|
||||
"begin_update",
|
||||
[](const PresetEditor& editor, int slot_number, const std::string& preset_name) {
|
||||
if (editor.type != Preset::TYPE_FILAMENT)
|
||||
throw std::invalid_argument("The slot_number form of begin_update() is only available on "
|
||||
"orca.host.preset.filament; call begin_update(preset_name=\"\") on this editor");
|
||||
return begin_filament_update_async(slot_number, preset_name);
|
||||
},
|
||||
py::arg("slot_number"), py::arg("preset_name") = "",
|
||||
"Awaitable. Resolve and bind a filament preset by 1-based slot and resolve to a PresetTransaction. "
|
||||
"Omitting preset_name targets the preset assigned to the slot; supplying a different name selects it for "
|
||||
"the slot through the normal flow (prompting about unsaved edits) and, once that succeeds, records the new "
|
||||
"slot assignment with the same Plater side effects the sidebar selection performs (project/selection "
|
||||
"refresh, dynamic filament list, conditional support-filament flushing recalc and the slot's AMS sync "
|
||||
"badge). A requested preset this slot's dropdown does not offer is rejected before that flow, so it "
|
||||
"raises no prompt; the slot's own current assignment stays targetable even when the dropdown hides it. A "
|
||||
"selection the user cancels raises orca.host.preset.PresetSelectionCancelled (a "
|
||||
"RuntimeError subclass; distinct from asyncio task cancellation, which raises asyncio.CancelledError) and "
|
||||
"changes neither the slot assignment nor any setting. An out-of-range, empty or unresolved slot is "
|
||||
"rejected, and a mixed-color virtual slot is rejected whether or not preset_name is given. The "
|
||||
"transaction edits the shared preset, so every slot assigned to it is affected. Must be awaited from the "
|
||||
"background Script worker's running event loop.")
|
||||
.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. "
|
||||
"Equivalent to awaiting begin_update() with the active preset as target, then "
|
||||
"set(key, value) and commit(); unlike begin_update() this call blocks the plugin worker.")
|
||||
.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, as is a mixed-color virtual slot, which has no single preset to edit. Other "
|
||||
"preset editors accept only update(key, value); this form raises "
|
||||
"ValueError on them. Equivalent to awaiting begin_update(slot_number) with an omitted "
|
||||
"preset_name (the slot's current assignment is kept), then set(key, value) and commit(); "
|
||||
"unlike begin_update() this call blocks the plugin worker.");
|
||||
|
||||
// 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_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
|
||||
.def_readonly("type", &Preset::type)
|
||||
.def_readonly("name", &Preset::name)
|
||||
|
||||
@@ -143,6 +143,38 @@ std::function<void()> make_close_adapter(const CallablePtr& holder)
|
||||
};
|
||||
}
|
||||
|
||||
// The Python-side resolver scheduled onto a plugin loop by AsyncFuture. It runs on the loop's own
|
||||
// thread with the GIL held. The cancelled() check drops the outcome of a coroutine whose task was
|
||||
// cancelled while the UI dialog was open; set_result/set_exception on a cancelled Future would
|
||||
// otherwise raise InvalidStateError on the loop.
|
||||
py::object make_future_resolver()
|
||||
{
|
||||
return py::cpp_function([](py::object future, py::object value, bool is_error) {
|
||||
if (future.attr("cancelled")().cast<bool>())
|
||||
return;
|
||||
if (is_error)
|
||||
future.attr("set_exception")(value);
|
||||
else
|
||||
future.attr("set_result")(value);
|
||||
});
|
||||
}
|
||||
|
||||
// A done-callback on the awaitable's Future, run by the loop. If the Future was cancelled (the
|
||||
// awaiting task was cancelled, e.g. ScriptAsyncRuntime::cancel_plugin, or the coroutine cancelled
|
||||
// itself), it marks the AsyncFuture abandoned. That is what lets the UI thread -- which must never
|
||||
// touch the loop or the Future -- notice cancellation atomically, so a queued selection does not
|
||||
// start and one already inside a modal stops when the modal returns. The weak reference keeps the
|
||||
// callback from owning the AsyncFuture.
|
||||
py::object make_cancellation_watcher(const std::weak_ptr<AsyncFuture>& weak_handle)
|
||||
{
|
||||
return py::cpp_function([weak_handle](py::object future) {
|
||||
if (future.attr("cancelled")().cast<bool>()) {
|
||||
if (auto handle = weak_handle.lock())
|
||||
handle->abandon();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Registry of live plugin UI resources. Keyed by an opaque id; tracks the
|
||||
// owning plugin so all of a plugin's UI can be torn down on unload.
|
||||
@@ -229,11 +261,66 @@ public:
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
// Awaitables posted to the UI thread from a plugin worker, keyed by the plugin. Held weakly so
|
||||
// the registration never keeps a finished operation (or its Python loop/Future) alive.
|
||||
void track_async(const std::shared_ptr<AsyncFuture>& handle, const std::string& plugin_key)
|
||||
{
|
||||
if (!handle)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lk(m_mtx);
|
||||
auto& weaks = m_async[plugin_key];
|
||||
// Prune registrations whose UI callback already returned, so the list stays bounded.
|
||||
weaks.erase(std::remove_if(weaks.begin(), weaks.end(),
|
||||
[](const std::weak_ptr<AsyncFuture>& weak) { return weak.expired(); }),
|
||||
weaks.end());
|
||||
weaks.push_back(handle);
|
||||
}
|
||||
void abandon_async(const std::string& plugin_key)
|
||||
{
|
||||
std::vector<std::shared_ptr<AsyncFuture>> pending;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_mtx);
|
||||
auto it = m_async.find(plugin_key);
|
||||
if (it == m_async.end())
|
||||
return;
|
||||
for (const std::weak_ptr<AsyncFuture>& weak : it->second) {
|
||||
if (auto handle = weak.lock())
|
||||
pending.push_back(std::move(handle));
|
||||
}
|
||||
// The registrations are intentionally kept: pending_async_count() must still see the
|
||||
// in-flight UI callback so PluginManager can drain it. They are pruned once the weak
|
||||
// reference expires (the callback returned).
|
||||
}
|
||||
// abandon() is GIL-free and thread-safe: it only stops a later completion from touching the
|
||||
// unloaded plugin's loop.
|
||||
for (const std::shared_ptr<AsyncFuture>& handle : pending)
|
||||
handle->abandon();
|
||||
}
|
||||
|
||||
// Live registrations for plugin_key: their UI callback has been posted but has not returned.
|
||||
// Expired weak references are pruned so the count reaches 0 when the last callback finishes.
|
||||
std::size_t pending_async_count(const std::string& plugin_key)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_mtx);
|
||||
auto it = m_async.find(plugin_key);
|
||||
if (it == m_async.end())
|
||||
return 0;
|
||||
auto& weaks = it->second;
|
||||
weaks.erase(std::remove_if(weaks.begin(), weaks.end(),
|
||||
[](const std::weak_ptr<AsyncFuture>& weak) { return weak.expired(); }),
|
||||
weaks.end());
|
||||
const std::size_t count = weaks.size();
|
||||
if (count == 0)
|
||||
m_async.erase(it);
|
||||
return count;
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex m_mtx;
|
||||
std::unordered_map<int, wxWindow*> m_resources;
|
||||
std::unordered_map<int, std::string> m_owners;
|
||||
std::unordered_map<std::string, std::vector<std::weak_ptr<GilSafeCallable>>> m_callbacks;
|
||||
std::unordered_map<std::string, std::vector<std::weak_ptr<AsyncFuture>>> m_async;
|
||||
int m_next_id{1};
|
||||
};
|
||||
|
||||
@@ -646,11 +733,115 @@ void plater_notification(GUI::NotificationManager::NotificationLevel notificatio
|
||||
|
||||
} // namespace
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// AsyncFuture: the awaitable suspension primitive (see PluginHostUi.hpp).
|
||||
// --------------------------------------------------------------------------
|
||||
std::shared_ptr<AsyncFuture> AsyncFuture::create()
|
||||
{
|
||||
auto handle = std::shared_ptr<AsyncFuture>(new AsyncFuture());
|
||||
py::object loop = py::module_::import("asyncio").attr("get_running_loop")();
|
||||
handle->m_loop = loop;
|
||||
handle->m_future = loop.attr("create_future")();
|
||||
handle->m_bind = make_future_resolver();
|
||||
// Watch for task/Future cancellation on the loop, so the host UI thread sees it as an atomic.
|
||||
handle->m_future.attr("add_done_callback")(make_cancellation_watcher(handle));
|
||||
return handle;
|
||||
}
|
||||
|
||||
void AsyncFuture::complete(py::object value)
|
||||
{
|
||||
if (m_abandoned.load(std::memory_order_acquire))
|
||||
return;
|
||||
try {
|
||||
m_loop.attr("call_soon_threadsafe")(m_bind, m_future, std::move(value), py::bool_(false));
|
||||
} catch (py::error_already_set& error) {
|
||||
// The loop closed before delivery (plugin teardown or shutdown). The awaiting task is gone
|
||||
// with it, so the outcome is dropped.
|
||||
BOOST_LOG_TRIVIAL(debug) << "orca.host awaitable result was not delivered: " << error.what();
|
||||
PyErr_Clear();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncFuture::fail(py::object exc)
|
||||
{
|
||||
if (m_abandoned.load(std::memory_order_acquire))
|
||||
return;
|
||||
try {
|
||||
m_loop.attr("call_soon_threadsafe")(m_bind, m_future, std::move(exc), py::bool_(true));
|
||||
} catch (py::error_already_set& error) {
|
||||
BOOST_LOG_TRIVIAL(debug) << "orca.host awaitable error was not delivered: " << error.what();
|
||||
PyErr_Clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool AsyncFuture::is_cancelled() const
|
||||
{
|
||||
if (m_abandoned.load(std::memory_order_acquire))
|
||||
return true;
|
||||
// Future.cancel() flips the Future's state synchronously on the loop thread, while the
|
||||
// done-callback that sets the atomic is only scheduled afterwards; reading the Future's own
|
||||
// state under the GIL closes that window for a caller that must decide before it starts or
|
||||
// before it maps a slot. cancelled() is a plain flag read and never mutates the Future.
|
||||
PythonGILState gil;
|
||||
if (!gil || !m_future)
|
||||
return false;
|
||||
try {
|
||||
return m_future.attr("cancelled")().cast<bool>();
|
||||
} catch (py::error_already_set&) {
|
||||
PyErr_Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFuture::~AsyncFuture()
|
||||
{
|
||||
PythonGILState gil;
|
||||
if (gil) {
|
||||
m_bind = py::object();
|
||||
m_future = py::object();
|
||||
m_loop = py::object();
|
||||
} else {
|
||||
// Interpreter is gone; leaking is the only safe option (mirrors the UI callable holders).
|
||||
(void) m_bind.release();
|
||||
(void) m_future.release();
|
||||
(void) m_loop.release();
|
||||
}
|
||||
}
|
||||
|
||||
void PluginHostUi::run_on_ui_thread(std::function<void()> fn)
|
||||
{
|
||||
run_on_ui_blocking(std::move(fn));
|
||||
}
|
||||
|
||||
void PluginHostUi::require_ui_application()
|
||||
{
|
||||
if (wxTheApp == nullptr)
|
||||
throw std::runtime_error("OrcaSlicer application is not initialized");
|
||||
}
|
||||
|
||||
bool PluginHostUi::post_to_ui_thread(std::function<void()> fn)
|
||||
{
|
||||
if (wxTheApp == nullptr)
|
||||
return false;
|
||||
GUI::wxGetApp().CallAfter(std::move(fn));
|
||||
return true;
|
||||
}
|
||||
|
||||
void PluginHostUi::track_pending_async(const std::shared_ptr<AsyncFuture>& handle)
|
||||
{
|
||||
UiRegistry::instance().track_async(handle, PluginAuditManager::instance().current_plugin());
|
||||
}
|
||||
|
||||
void PluginHostUi::abandon_pending_async(const std::string& plugin_key)
|
||||
{
|
||||
UiRegistry::instance().abandon_async(plugin_key);
|
||||
}
|
||||
|
||||
std::size_t PluginHostUi::pending_async_count(const std::string& plugin_key)
|
||||
{
|
||||
return UiRegistry::instance().pending_async_count(plugin_key);
|
||||
}
|
||||
|
||||
void PluginHostUi::RegisterBindings(pybind11::module_& host)
|
||||
{
|
||||
auto ui = host.def_submodule(
|
||||
@@ -781,6 +972,10 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
|
||||
return;
|
||||
|
||||
auto teardown = [plugin_key]() {
|
||||
// Stop resolving any awaitable still pending for this plugin before its windows go: a UI
|
||||
// callback that runs after unload must not touch the unloaded plugin's loop.
|
||||
UiRegistry::instance().abandon_async(plugin_key);
|
||||
|
||||
for (auto& callback : UiRegistry::instance().take_callbacks_for_plugin(plugin_key))
|
||||
callback->disable();
|
||||
|
||||
|
||||
@@ -2,11 +2,70 @@
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// One awaitable pending on a plugin worker's running asyncio loop, completed from the UI thread.
|
||||
//
|
||||
// This is the suspension primitive for host calls that must show a dialog before they can resolve
|
||||
// (the awaitable preset begin_update(); see docs/HLSD/preset-plugin-api.md and the async-bridge
|
||||
// architecture gate). It carries no GUI or preset state: only the Python loop, the Future created
|
||||
// on it, and a Python-side resolver.
|
||||
//
|
||||
// Threading contract:
|
||||
// * create() runs on the plugin worker with the GIL held and an asyncio loop running; it binds
|
||||
// that loop and creates the Future, which is the awaitable handed back to Python.
|
||||
// * The host posts the UI work with PluginHostUi::post_to_ui_thread(); the UI callback then calls
|
||||
// complete()/fail() with the GIL held. Both marshal the outcome through
|
||||
// loop.call_soon_threadsafe, so the Future is resolved on its own loop thread, never from the
|
||||
// UI thread.
|
||||
// * A Future that a cancelled task or a closed loop already discarded is dropped silently, so a
|
||||
// late UI result cannot corrupt or resurrect a cancelled coroutine.
|
||||
// * abandon() stops delivery without touching Python, for lifecycle teardown that must not wait.
|
||||
// Python references are always released under the GIL, from whichever thread destroys the
|
||||
// handle; if the interpreter is already gone the references are intentionally leaked rather
|
||||
// than decref'd unsafely (same rule as the UI callable holders).
|
||||
class AsyncFuture
|
||||
{
|
||||
public:
|
||||
// Requires the GIL and a running asyncio loop (pybind11::error_already_set otherwise).
|
||||
static std::shared_ptr<AsyncFuture> create();
|
||||
|
||||
// The Python Future to return as the awaitable. Requires the GIL.
|
||||
pybind11::object future() const { return m_future; }
|
||||
|
||||
// Resolve the awaitable. Requires the GIL. complete() sets the result, fail() raises `exc`.
|
||||
void complete(pybind11::object value);
|
||||
void fail(pybind11::object exc);
|
||||
|
||||
// Stop delivery (thread-safe, GIL-free) and let a host UI callback notice it must not start or
|
||||
// continue. Set when the owning plugin unloads, and by the loop-thread done-callback when the
|
||||
// Future is cancelled. Idempotent.
|
||||
void abandon() { m_abandoned.store(true, std::memory_order_release); }
|
||||
bool abandoned() const { return m_abandoned.load(std::memory_order_acquire); }
|
||||
|
||||
// True once the awaitable has been cancelled or abandoned. Unlike abandoned(), this also reads
|
||||
// the Python Future's own cancelled() under the GIL, so it observes a cancellation that
|
||||
// Future.cancel() applied synchronously even before the loop runs the done-callback that sets
|
||||
// the atomic. It never mutates the Future or the loop. Safe to call from the UI thread.
|
||||
bool is_cancelled() const;
|
||||
|
||||
~AsyncFuture();
|
||||
|
||||
private:
|
||||
AsyncFuture() = default;
|
||||
|
||||
pybind11::object m_loop;
|
||||
pybind11::object m_future;
|
||||
pybind11::object m_bind;
|
||||
std::atomic<bool> m_abandoned{false};
|
||||
};
|
||||
|
||||
// Binds the `orca.host.ui` submodule: native message boxes, progress dialogs,
|
||||
// and interactive HTML windows/dialogs for plugins. All calls run on the main/UI
|
||||
// thread (marshaled from the plugin worker thread) and the host owns every window.
|
||||
@@ -22,6 +81,30 @@ public:
|
||||
|
||||
static void run_on_ui_thread(std::function<void()> fn);
|
||||
|
||||
// Fail synchronously (std::runtime_error) when there is no wx application, matching
|
||||
// run_on_ui_thread()'s first check, so an awaitable entry point can reject before it touches
|
||||
// asyncio.
|
||||
static void require_ui_application();
|
||||
|
||||
// Post `fn` to run on the main/UI thread without blocking the caller. `fn` runs with the GIL
|
||||
// released and must touch only C++/wx state and host-owned handles. Returns false (and drops
|
||||
// `fn`) when the application is not initialized.
|
||||
static bool post_to_ui_thread(std::function<void()> fn);
|
||||
|
||||
// Lifecycle tracking for awaitables posted from a plugin worker. track_pending_async() keys the
|
||||
// handle by the current plugin; the registration holds only a weak reference and expires when
|
||||
// the UI operation finishes. abandon_pending_async() (plugin unload / draining) stops every
|
||||
// still-pending awaitable for the plugin from resolving into an unloaded loop.
|
||||
static void track_pending_async(const std::shared_ptr<AsyncFuture>& handle);
|
||||
static void abandon_pending_async(const std::string& plugin_key);
|
||||
|
||||
// Number of plugin-worker awaitables for plugin_key whose UI work has been posted but has not
|
||||
// finished yet. Expired registrations are pruned, so this reaches 0 once every posted UI
|
||||
// callback has returned. PluginManager drains on this before unloading a plugin's module: a
|
||||
// Tab::select_preset dialog accepted after that would otherwise mutate presets after unload.
|
||||
// GIL-free; safe from any thread.
|
||||
static std::size_t pending_async_count(const std::string& plugin_key);
|
||||
|
||||
// Lifecycle hook: close and tear down every UI window owned by a plugin. PluginManager invokes
|
||||
// this after plugin teardown and also for bulk unload during application shutdown.
|
||||
static void close_windows_for_plugin(const std::string& plugin_key);
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
// why: keep the Windows headers ahead of libslic3r. Model.hpp pulls in Format/STEP.hpp, and
|
||||
// OCCT's Standard_Macro.hxx defines NOUSER and NOGDI on Windows so that windows.h does not
|
||||
// collide with OCCT's identifiers. Defined that early they also hide the USER and GDI
|
||||
// declarations, and the wx MSW headers Field.hpp reaches (wx/wxprec.h -> wx/msw/private.h)
|
||||
// need WNDPROC, RGB and the ::GetWindowRect family. Including windows.h first, as
|
||||
// libslic3r_gui does through pchheader.hpp or win_platform.hpp, keeps them.
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#include <CommCtrl.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
@@ -6,8 +23,12 @@
|
||||
#include <libslic3r/TriangleMesh.hpp>
|
||||
#include <slic3r/GUI/DockPanel.hpp>
|
||||
#include <slic3r/GUI/AuiPaneLayout.hpp>
|
||||
#include <slic3r/GUI/OptionsGroup.hpp>
|
||||
#include <slic3r/GUI/Widgets/WebHosting.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
@@ -137,6 +158,146 @@ 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<std::string>() == 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>() == Slic3r::Preset::TYPE_PRINT);
|
||||
CHECK(printer.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_PRINTER);
|
||||
CHECK(filament.attr("type").cast<Slic3r::Preset::Type>() == 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("Plugin host API exposes staged preset update transactions", "[PluginHost][Python]")
|
||||
{
|
||||
py::object preset = import_orca_module().attr("host").attr("preset");
|
||||
py::object transaction_type = preset.attr("PresetTransaction");
|
||||
py::object result_type = preset.attr("PresetUpdateResult");
|
||||
py::object selection_cancelled = preset.attr("PresetSelectionCancelled");
|
||||
py::object runtime_error = py::module_::import("builtins").attr("RuntimeError");
|
||||
|
||||
CHECK(has_attr(transaction_type, "set"));
|
||||
CHECK(has_attr(transaction_type, "commit"));
|
||||
CHECK(py::module_::import("builtins").attr("issubclass")(selection_cancelled, runtime_error).cast<bool>());
|
||||
CHECK(has_attr(result_type, "target_name"));
|
||||
CHECK(has_attr(result_type, "selection_changed"));
|
||||
CHECK(has_attr(result_type, "slot_assignment_changed"));
|
||||
CHECK(has_attr(result_type, "shared_slots"));
|
||||
|
||||
for (const char* name : { "process", "printer" }) {
|
||||
CAPTURE(name);
|
||||
py::object editor = preset.attr(name);
|
||||
CHECK(has_attr(editor, "begin_update"));
|
||||
try {
|
||||
editor.attr("begin_update")();
|
||||
FAIL("begin_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);
|
||||
}
|
||||
}
|
||||
|
||||
py::object filament = preset.attr("filament");
|
||||
CHECK(has_attr(filament, "begin_update"));
|
||||
try {
|
||||
filament.attr("begin_update")(1);
|
||||
FAIL("filament begin_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("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");
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ set(SLIC3R_APP_KEY "OrcaSlicer")
|
||||
if(NOT DEFINED BBL_INTERNAL_TESTING)
|
||||
set(BBL_INTERNAL_TESTING "0")
|
||||
endif()
|
||||
set(SoftFever_VERSION "2.5.0-alpha")
|
||||
set(SoftFever_VERSION "2.5.0-dev")
|
||||
string(REGEX MATCH "^([0-9]+)\\.([0-9]+)\\.([0-9]+)"
|
||||
SoftFever_VERSION_MATCH ${SoftFever_VERSION})
|
||||
set(ORCA_VERSION_MAJOR ${CMAKE_MATCH_1})
|
||||
|
||||
Reference in New Issue
Block a user