Compare commits

...
Author SHA1 Message Date
peachismomo d80af50f23 feat: preset APIs 2026-10-17 01:44:15 +08:00
peachismomo 3bfe40e17a Merge branch 'main' into feat/preset-plugin-apis 2026-10-16 21:59:48 +08:00
Ian Chua 62f05dad2b doc: HLSD for preset plugin APIs 2026-10-16 21:54:34 +08:00
SoftFever 82aee1abdd Cache compiles in the macOS build script, including Xcode builds (#16553) 2026-10-16 19:36:50 +08:00
peachismomo 4d3c61c02a fix: tests 2026-10-16 19:12:30 +08:00
Ian Chua cefdd9b609 fix: max filepath length on windows (#16552)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Windows traditionally limits many file operations to `MAX_PATH` (260
characters). Deeply nested plugin packages can exceed this limit during
`.whl` extraction, leaving files missing and causing the plugin to fail
to load.

This change adds a shared extended_path helper that prepares paths for
Windows file operations using the extended-length prefixes `\\?\` or
`\\?\UNC\`. The helper is used for ZIP archive I/O and extraction
destinations, including plugin wheel extraction. This avoids relying on
the Windows long-path setting, which changes machine-wide settings.

Paths remain unprefixed outside file operations. Code that bypasses the
shared ZIP helpers may still require separate long-path handling.

See [Microsoft's documentation on path length
limitation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry)

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->
Tested on Windows 11 by using a portable installation of OrcaSlicer and
placing it in some really long filepath length e.g.
`C:/somename/<240 characters long folder path>/OrcaSlicer` and then
running and trying to install any plugin locally or on OrcaCloud.

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-16 18:47:49 +08:00
SoftFever caade80624 Keep the texture picker from stalling on open (#16547)
# Description

Opening the texture picker decoded every texture in the expanded
sections on the GUI thread in a single frame, each one twice at full
size. This stalled the UI on large libraries, and the full-size decodes
stayed in memory afterwards.

- Picker thumbnails are loaded lazily: only tiles inside the visible
scroll area are considered, at most 2 uncached decodes per frame, and
the rest fill in over the following frames. Deferred tiles draw as a
placeholder.
- Those follow-up frames are requested with `request_extra_frame()`.
Marking the canvas dirty from inside the render did not schedule another
idle pass, so thumbnails waited for the next mouse event.
- The selected texture is still centred on open, even when it is off
screen. The scroll reveal runs before the visibility check so that tile
is loaded on the next frame.
- Library validation no longer decodes pixels. The new
`png::is_decodable_8bit_png()` reads only the PNG header and accepts
8-bit grey or RGB/RGBA, which is what `decode_png()` and
`decode_colored_png()` take.
- Picker thumbnails are decoded without entering the decode cache
(`decode_height_texture(layer, keep_decoded = false)`). Only the 128 px
thumbnail is kept per library entry; the full-size decode is made
transiently and freed.

Tradeoff: the header-only check does not read pixel data, so a PNG with
a valid header but corrupt or truncated pixel data now passes validation
and fails when the texture is loaded. The previous full decode caught
that case.

Each thumbnail decode still runs at full size on the GUI thread, one at
a time. Peak memory per decode is unchanged; a streaming downscale would
be needed to reduce it.

# Screenshots/Recordings/Graphs

Old:


https://github.com/user-attachments/assets/03038532-396f-48c2-898c-aa06ef83e5f5


New:


https://github.com/user-attachments/assets/49fdc31a-ce1b-470a-88a7-4c6319f03b81



## Tests

Not yet run. Suggested checks:
- Open the texture picker with a library of many textures, and do not
move the mouse: tiles should fill in over a few frames without a visible
stall.
- Open the picker with a layer whose texture is far down the list: it
should be centred and loaded.
- Import a valid 8-bit grey and an RGB PNG, plus an unsupported one
(16-bit, palette, or grey-alpha): only the supported ones should be
accepted as-is, and the rest converted on load.
- Watch memory while scrolling through a large colour library: it should
not grow by the full decoded size of every texture shown.

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-16 18:11:46 +08:00
Lam Wei Lun cbd8ce98d5 Keep the texture picker redrawing while thumbnails load
Marking the canvas dirty from inside the render does not schedule another
idle pass, so deferred thumbnails waited for the next mouse event. Asking
for an extra frame does.
2026-10-16 17:55:37 +08:00
Lam Wei Lun 99714afc4c Keep picker thumbnails from retaining full-size texture decodes
The thumbnail is downscaled on upload, but the full decode it was made
from stayed in the decode cache for as long as the library entry lived.
Thumbnail decodes now skip the cache, so only the 128 px thumbnail is
kept per library texture.
2026-10-16 17:49:35 +08:00
Lam Wei Lun 3d07663ab2 Merge branch 'main' into fix/texture-picker-lazy-png-header 2026-10-16 15:41:04 +08:00
Lam Wei Lun 4149401c53 Keep the texture picker from stalling on open
Opening the texture picker decoded every texture in the expanded sections
on the GUI thread in one frame, each one twice at full size. Only the
visible tiles now load, at most a couple per frame, and the rest fill in
over the following frames.

The library validation no longer decodes the pixels: a header-only check,
is_decodable_8bit_png(), accepts exactly what the two decoders take.
2026-10-16 02:41:12 +08:00
peachismomo dc9203126d fix: tests 2026-10-15 21:00:39 +08:00
Ian Chua 8c2a1198d6 Merge branch 'main' into feat/preset-plugin-apis 2026-10-15 20:46:38 +08:00
Ian Chua 193cfc6dbf feat: introduce plugin APIs for mutable presets 2026-10-15 20:32:49 +08:00
34 changed files with 4595 additions and 122 deletions
+13
View File
@@ -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")
+50
View File
@@ -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"
+255
View File
@@ -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.
+398
View File
@@ -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.
+300
View File
@@ -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)
+957
View File
@@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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)
+29
View File
@@ -211,6 +211,35 @@ bool decode_colored_png(const ReadBuf &in_buf, ImageColorscale &out_img)
return decode_colored_png(stream, out_img);
}
bool is_decodable_8bit_png(const ReadBuf &pngbuf)
{
static const constexpr int PNG_SIG_BYTES = 8;
struct ReadBufStream stream{pngbuf};
std::vector<png_byte> sig(PNG_SIG_BYTES, 0);
stream.read(sig.data(), PNG_SIG_BYTES);
if (!png_check_sig(sig.data(), PNG_SIG_BYTES))
return false;
PNGDescr dsc;
dsc.png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
if (!dsc.png)
return false;
dsc.info = png_create_info_struct(dsc.png);
if (!dsc.info)
return false;
if (!png_read_header_guarded(dsc.png, dsc.info, &stream, PNG_SIG_BYTES))
return false;
const size_t color_type = png_get_color_type(dsc.png, dsc.info);
const size_t bit_depth = png_get_bit_depth(dsc.png, dsc.info);
const bool grey = color_type == PNG_COLOR_TYPE_GRAY;
const bool colour = color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_RGB_ALPHA;
return png_get_image_width(dsc.png, dsc.info) > 0 && png_get_image_height(dsc.png, dsc.info) > 0 &&
bit_depth == 8 && (grey || colour);
}
// Down to earth function to store a packed RGB image to file. Mostly useful for debugging purposes.
// Based on https://www.lemoda.net/c/write-png/
+3
View File
@@ -50,6 +50,9 @@ struct ReadBuf { const void *buf = nullptr; const size_t sz = 0; };
bool is_png(const ReadBuf &pngbuf);
// True if decode_png() or decode_colored_png() would accept these bytes, judged from the header alone.
bool is_decodable_8bit_png(const ReadBuf &pngbuf);
struct ReadBufStream: public IStream {
const ReadBuf &rbuf_ref;
size_t pos = 0;
+3 -1
View File
@@ -283,7 +283,7 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer)
return decode_height_texture(layer).has_color();
}
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer)
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded)
{
DecodedHeightTexture result;
if (layer.empty())
@@ -351,6 +351,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
}
}
if (keep_decoded) {
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
// cache doesn't grow without bound across many add/remove-texture cycles in a long session.
@@ -359,6 +360,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
it = it->second.first.expired() ? entries.erase(it) : std::next(it);
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
}
}
// The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before,
// because `preview_adjust` is an edit to the image while `smoothing` is a live low-pass over whatever
+3 -2
View File
@@ -599,8 +599,9 @@ DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, i
// colour PNGs are accepted; for a colour source the height is its luminance, using the same
// coefficients wxImage::ConvertToGreyscale() uses, so a texture imported as colour displaces exactly
// as it did when the importer flattened it to grey on the way in. Returns an empty
// DecodedHeightTexture if image_data is empty or is not a PNG at all.
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer);
// DecodedHeightTexture if image_data is empty or is not a PNG at all. keep_decoded = false leaves the
// result out of the decode cache, for one-off reads such as a picker thumbnail that must not hold full-size pixels.
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded = true);
// decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the
// texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read.
+7
View File
@@ -842,6 +842,13 @@ void load_string_file(const boost::filesystem::path& p, std::string& str);
bool check_layer_id_pattern(const std::string& pattern, int layer_id);
// On Windows, return an equivalent path carrying the extended-length ("\\?\" / "\\?\UNC\")
// prefix so the Win32 APIs accept it past MAX_PATH even when LongPathsEnabled is off. The path
// is made absolute and lexically normalized first, because the prefix disables normalisation.
// A path that is already extended (or a device path) is returned unchanged; on other platforms,
// or when an absolute path cannot be formed, the input is returned unchanged.
boost::filesystem::path extended_path(const boost::filesystem::path& path);
} // namespace Slic3r
#if WIN32
+53 -8
View File
@@ -17,6 +17,7 @@
#if defined(_MSC_VER) || defined(__MINGW64__)
#include "boost/nowide/cstdio.hpp"
#include "boost/nowide/convert.hpp"
#endif
#include "I18N.hpp"
@@ -62,7 +63,11 @@ bool open_zip(mz_zip_archive *zip, const char *fname, bool isread)
FILE *f = nullptr;
#if defined(_MSC_VER) || defined(__MINGW64__)
f = boost::nowide::fopen(fname, mode);
// fname is UTF-8 (the nowide contract). Route it through the shared extended-path helper so an
// archive stored past MAX_PATH opens even with LongPathsEnabled off.
const std::wstring wide_path = boost::nowide::widen(fname);
const std::string extended = boost::nowide::narrow(Slic3r::extended_path(boost::filesystem::path(wide_path)).wstring());
f = boost::nowide::fopen(extended.c_str(), mode);
#elif defined(__GNUC__) && defined(_LARGEFILE64_SOURCE)
f = fopen64(fname, mode);
#else
@@ -101,6 +106,14 @@ bool close_zip(mz_zip_archive *zip, bool isread)
}
return ret;
}
#ifdef WIN32
// mz_file_write_func writing an extracted entry into an already-open FILE*.
size_t mz_write_to_cfile(void *opaque, mz_uint64 /*file_ofs*/, const void *buffer, size_t size)
{
return std::fwrite(buffer, 1, size, static_cast<FILE *>(opaque));
}
#endif
}
bool open_zip_reader(mz_zip_archive *zip, const std::string &fname)
@@ -116,6 +129,36 @@ bool open_zip_writer(mz_zip_archive *zip, const std::string &fname)
bool close_zip_reader(mz_zip_archive *zip) { return close_zip(zip, true); }
bool close_zip_writer(mz_zip_archive *zip) { return close_zip(zip, false); }
bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path)
{
if (!zip)
return false;
#ifdef WIN32
// Open the destination ourselves with a Windows extended-length path: miniz's narrow
// extract_to_file cannot be relied on past MAX_PATH with LongPathsEnabled off, and its wide
// variant takes a plain (unprefixed) path and a text-mode stream. This keeps the write binary.
FILE *file = ::_wfopen(Slic3r::extended_path(dest_path).wstring().c_str(), L"wb");
if (!file) {
zip->m_last_error = MZ_ZIP_FILE_OPEN_FAILED;
return false;
}
const bool written = mz_zip_reader_extract_to_callback(zip, file_index, mz_write_to_cfile, file, 0) != 0;
const bool closed = std::fclose(file) == 0;
if (!written || !closed) {
if (written && !closed)
zip->m_last_error = MZ_ZIP_FILE_CLOSE_FAILED;
// Drop the partial entry rather than leave a truncated file behind.
boost::system::error_code ec;
boost::filesystem::remove(Slic3r::extended_path(dest_path), ec);
return false;
}
return true;
#else
return mz_zip_reader_extract_to_file(zip, file_index, dest_path.string().c_str(), 0) != 0;
#endif
}
std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat)
{
if (stat.m_is_utf8)
@@ -156,10 +199,13 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin
}
const std::string dest_file = dest_dir + "/" + stat.m_filename;
try {
if (stat.m_is_directory) {
// Use the extended-length form for every filesystem call so entries under a destination
// past MAX_PATH are created even with LongPathsEnabled off.
const boost::filesystem::path dest_path(dest_file);
if (!boost::filesystem::exists(dest_path))
boost::filesystem::create_directories(dest_path);
const boost::filesystem::path io_dest = extended_path(dest_path);
if (stat.m_is_directory) {
if (!boost::filesystem::exists(io_dest))
boost::filesystem::create_directories(io_dest);
continue;
}
if (stat.m_uncomp_size == 0) {
@@ -167,10 +213,9 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin
continue;
}
// Replace a symlink at the destination rather than writing through it.
const boost::filesystem::path dest_path(dest_file);
if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(dest_path)))
boost::filesystem::remove(dest_path);
if (!mz_zip_reader_extract_to_file(&archive, stat.m_file_index, dest_file.c_str(), 0)) {
if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(io_dest)))
boost::filesystem::remove(io_dest);
if (!extract_zip_entry_to_file(&archive, stat.m_file_index, dest_path)) {
BOOST_LOG_TRIVIAL(error) << "Unzip: extract file " << stat.m_filename << " to dest " << dest_file << " failed";
close_zip_reader(&archive);
return false;
+5
View File
@@ -1,6 +1,7 @@
#ifndef MINIZ_EXTENSION_HPP
#define MINIZ_EXTENSION_HPP
#include <boost/filesystem/path.hpp>
#include <string>
#include <miniz.h>
@@ -13,6 +14,10 @@ bool close_zip_writer(mz_zip_archive *zip);
std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat);
// Extracts every entry of the archive under dest_dir. Nothing is written if any entry would resolve outside dest_dir.
bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir);
// Extracts one entry into dest_path, binary-safe and overwriting. On Windows the destination is
// opened through an extended-length path so it works past MAX_PATH even with LongPathsEnabled off;
// a partial file is removed if the write fails. False for a directory/unsupported entry or on error.
bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path);
class MZ_Archive {
public:
+40
View File
@@ -2254,5 +2254,45 @@ bool check_layer_id_pattern(const std::string& pattern, int layer_id){
return mod >= 0 && mod < count;
}
boost::filesystem::path extended_path(const boost::filesystem::path& path) {
#ifdef WIN32
namespace fs = boost::filesystem;
// Already extended, or an explicit device path (which is not subject to MAX_PATH and must not
// be absolutized or normalized): leave it alone.
{
fs::path prefix_candidate = path;
const std::wstring native = prefix_candidate.make_preferred().wstring();
if (native.rfind(L"\\\\?\\", 0) == 0 || native.rfind(L"\\\\.\\", 0) == 0)
return fs::path(native);
}
fs::path absolute = path;
if (!absolute.is_absolute()) {
boost::system::error_code ec;
absolute = fs::absolute(absolute, ec);
if (ec)
return path; // can't form a valid prefixed path from a relative one
}
// A trailing separator is redundant to the Win32 APIs, but lexically_normal() would rewrite it
// as a "." element, which the "\\?\" prefix (normalisation off) takes literally. Drop it first.
while (absolute.has_relative_path() && absolute.filename() == ".")
absolute = absolute.parent_path();
// The "\\?\" prefix turns off Win32 normalisation, so do it here first.
absolute = absolute.lexically_normal();
// Extended paths only recognize backslash separators.
const std::wstring native = absolute.make_preferred().wstring();
if (native.empty())
return path;
if (native.rfind(L"\\\\", 0) == 0)
return fs::path(L"\\\\?\\UNC\\" + native.substr(2)); // \\server\share\... -> \\?\UNC\server\share\...
return fs::path(L"\\\\?\\" + native); // C:\dir\... -> \\?\C:\dir\...
#else
return path;
#endif
}
}; // namespace Slic3r
+70 -8
View File
@@ -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;
}
};
@@ -174,6 +174,10 @@ constexpr int HEIGHT_TEX_MAX_PX = 2048;
// The full-size image is only ever adjusted once, on commit.
constexpr int ADJUST_PREVIEW_MAX_PX = 384;
// Library textures the texture picker decodes per frame. Each decode runs on the GUI thread, so the
// rest of the visible tiles fill in over the following frames instead of in one stall.
constexpr int PICKER_LOADS_PER_FRAME = 2;
// How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine
// band (see collect_paint_region()). Two is enough to grade the size change without the band's own
// cost growing to matter: it is a ring around a perimeter, not an area.
@@ -4118,7 +4122,7 @@ const GLGizmoTextureDisplacement::LibraryTexture *GLGizmoTextureDisplacement::ge
if (entry.image_data) {
TextureDisplacementLayer probe;
probe.image_data = entry.image_data;
entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe));
entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe, /* keep_decoded */ false));
if (!entry.thumbnail)
entry.image_data.reset(); // decoded to nothing usable - treat it as a failed load
} else {
@@ -4331,6 +4335,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
std::optional<TextureLibraryEntry> to_remove;
bool import_clicked = false;
bool download_clicked = false;
int loads_left = PICKER_LOADS_PER_FRAME;
bool loads_deferred = false;
// Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile
// overlays a remove button on its corner, and that button would otherwise be "the previous item" the
@@ -4348,11 +4354,30 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
};
// One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by
// name, the user's own most recently used first - the order texture_library() produced); a user tile
// carries a remove button on hover. Returns how many it drew.
// carries a remove button on hover. Returns how many tiles it laid out, drawn or not.
const auto grid = [&](const ImVec2 &origin, const std::vector<const TextureLibraryEntry *> &entries, bool removable) {
int n = 0;
for (const TextureLibraryEntry *entry : entries) {
ImGui::SetCursorScreenPos(tile_pos(origin, n++));
const ImVec2 rect_min = tile_pos(origin, n++);
const ImVec2 rect_max = ImVec2(rect_min.x + tile, rect_min.y + tile);
const bool current = entry->path == layer->path;
// The selected tile is centred even when it is off screen, so it is drawn and loaded next frame.
if (current && m_picker_reveal_selected) {
ImGui::SetScrollFromPosY(0.5f * (rect_min.y + rect_max.y) - ImGui::GetWindowPos().y, 0.5f);
m_parent.request_extra_frame();
}
if (!ImGui::IsRectVisible(rect_min, rect_max))
continue;
// Thumbnails decode on this thread, so only a few per frame; the rest fill in on later frames.
const bool cached = m_library_textures.count(entry->path) != 0;
if (!cached && loads_left == 0) {
loads_deferred = true;
dl->AddRectFilled(rect_min, rect_max, ImGui::GetColorU32(ImGuiCol_FrameBg), style.FrameRounding);
continue;
}
if (!cached)
--loads_left;
ImGui::SetCursorScreenPos(rect_min);
ImGui::PushID(entry->path.c_str());
const LibraryTexture *tex = get_library_texture(entry->path);
GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr;
@@ -4375,7 +4400,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (removable)
ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks
const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
const bool current = entry->path == layer->path;
const bool hovered = ImGui::IsItemHovered();
// The layer's current texture carries an accent ring, and the tile under the pointer a thinner,
// fainter one. Both are drawn inside the tile, where the grid's clip rect never cuts them.
@@ -4384,9 +4408,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
dl->AddRect(ImVec2(a.x + 0.5f * w, a.y + 0.5f * w), ImVec2(b.x - 0.5f * w, b.y - 0.5f * w),
ImGui::GetColorU32(current ? orca : ImVec4(orca.x, orca.y, orca.z, 0.6f)), 0.f, 0, w);
}
// Centred, as far as the scroll range allows, so the textures around it are in view too.
if (current && m_picker_reveal_selected)
ImGui::SetScrollFromPosY(0.5f * (a.y + b.y) - ImGui::GetWindowPos().y, 0.5f);
if (hovered)
panel_tooltip(entry->name);
@@ -4575,6 +4596,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
ImGui::PopTextWrapPos();
}
if (loads_deferred)
m_parent.request_extra_frame();
// Done whether or not the current texture was drawn: one in a collapsed section stays where it is.
m_picker_reveal_selected = false;
ImGui::EndChild();
+15
View File
@@ -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);
+40
View File
@@ -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*/);
+31 -13
View File
@@ -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,20 +1174,29 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
show_status(message, "error");
};
{
wxBusyCursor busy;
result = manager.run_script_capability(plugin_key, capability_name, 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;
if (!error.empty()) {
complete_with_error(error, wxString());
// 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;
}
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 ExecutionResult& result = completion.result;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
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;
@@ -1203,6 +1209,18 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
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;
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
}
// 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)
+133
View File
@@ -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()
{
+40
View File
@@ -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
+3 -15
View File
@@ -223,23 +223,11 @@ std::vector<unsigned char> read_file_bytes(const std::string &path)
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors
// exactly what decode_height_texture() accepts: 8-bit grayscale, or 8-bit colour (whose luminance is
// the height and whose RGB is available to colour the model). Anything else is converted on load.
// True if decode_height_texture() can read these bytes, so they can be stored on a layer as-is.
// Anything else is converted on load.
bool is_supported_height_map(const std::vector<unsigned char> &bytes)
{
if (bytes.empty())
return false;
const png::ReadBuf rbuf{ bytes.data(), bytes.size() };
if (!png::is_png(rbuf))
return false;
png::ImageGreyscale gray;
if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0)
return true;
png::ImageColorscale color;
return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 &&
color.bytes_per_pixel >= 3 &&
color.buf.size() == color.cols * color.rows * size_t(color.bytes_per_pixel);
return !bytes.empty() && png::is_decodable_8bit_png(png::ReadBuf{ bytes.data(), bytes.size() });
}
std::vector<TextureLibraryEntry> g_library;
+6 -17
View File
@@ -34,10 +34,6 @@
#include <unordered_set>
#include <vector>
#ifdef WIN32
#include <boost/locale/encoding_utf.hpp>
#endif
namespace Slic3r {
const char* const INSTALL_STATE_FILE = ".install_state.json";
@@ -665,7 +661,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::create_directories(destination, ec);
fs::create_directories(extended_path(destination), ec);
if (ec) {
error = "Failed to create plugin staging directory: " + ec.message();
return false;
@@ -709,7 +705,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo
const fs::path output_path = destination / relative_path;
if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) {
fs::create_directories(output_path, ec);
fs::create_directories(extended_path(output_path), ec);
if (ec) {
error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message();
return false;
@@ -717,26 +713,19 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo
continue;
}
fs::create_directories(output_path.parent_path(), ec);
fs::create_directories(extended_path(output_path.parent_path()), ec);
if (ec) {
error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message();
return false;
}
if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) {
if (fs::exists(extended_path(output_path), ec) && fs::is_directory(extended_path(output_path), ec)) {
error = "Plugin zip file conflicts with an existing directory: " + output_path.string();
return false;
}
const std::string encoded_output_path = encode_path(output_path.string().c_str());
mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0);
#ifdef WIN32
if (!extracted) {
const std::wstring wide_output_path = boost::locale::conv::utf_to_utf<wchar_t>(output_path.generic_string());
extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0);
}
#endif
if (!extracted) {
// Shared with the rest of the archive code: long-path capable and binary-safe.
if (!extract_zip_entry_to_file(&reader.archive, stat.m_file_index, output_path)) {
error = "Failed to extract plugin zip entry " + relative_key + ": " +
MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive));
return false;
+6
View File
@@ -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
+442 -30
View File
@@ -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) {
+100
View File
@@ -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,
+75
View File
@@ -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;
@@ -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)
+195
View File
@@ -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();
+83
View File
@@ -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);
+50
View File
@@ -18,6 +18,7 @@
#include <fstream>
#include <ios>
#include <iterator>
#include <libslic3r/Utils.hpp>
#include <miniz.h>
#include <string>
#include <utility>
@@ -176,6 +177,55 @@ TEST_CASE("Confined extraction writes nothing outside the target for Windows-spe
CHECK(list_dir(tmp.path()) == std::vector<std::string>{"bundle.zip", "cache"});
}
TEST_CASE("Confined extraction writes files under a target path longer than MAX_PATH", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
// Directory entries are explicit: extract_archive_confined recreates directories only for them.
write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"},
{"vendor/machine/printer.json", "{\"b\":2}"}});
// One 240-char component keeps every name below the NTFS 255 limit while pushing the full
// target path past the 260-char MAX_PATH the Win32 APIs enforce when LongPathsEnabled is off.
const fs::path target = tmp.path() / std::string(240, 'd');
REQUIRE(target.string().size() > 260);
fs::create_directories(extended_path(target));
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
// Verify through extended-length paths, otherwise the checks themselves are bounded by MAX_PATH.
CHECK(fs::is_directory(extended_path(target / "vendor")));
CHECK(read_file(extended_path(target / "vendor.json")) == "{\"a\":1}");
CHECK(read_file(extended_path(target / "vendor" / "machine" / "printer.json")) == "{\"b\":2}");
// The temporary dir guard cannot remove a deep tree on Windows, so drop it here.
boost::system::error_code ec;
fs::remove_all(extended_path(target), ec);
CHECK_FALSE(ec);
}
TEST_CASE("A zip archive under a path longer than MAX_PATH can be written and read", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
// Create the long directory with the extended-length path, then exercise the archive
// reader/writer against an archive whose own path is past MAX_PATH.
const fs::path long_dir = tmp.path() / std::string(240, 'z');
fs::create_directories(extended_path(long_dir));
const fs::path zip_file = long_dir / "bundle.zip";
REQUIRE(zip_file.string().size() > 260);
write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}});
const fs::path target = tmp.path() / "out";
fs::create_directories(target);
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
boost::system::error_code ec;
fs::remove_all(extended_path(long_dir), ec);
CHECK_FALSE(ec);
}
#ifndef _WIN32
TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]")
{
+46
View File
@@ -521,3 +521,49 @@ TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]")
CHECK_FALSE(ascii_iequals("[", "{"));
CHECK_FALSE(ascii_iequals("@", "`"));
}
TEST_CASE("extended_path turns a Windows path into its long-path form", "[Utils]") {
#ifdef _WIN32
namespace fs = boost::filesystem;
SECTION("a normal absolute drive path is prefixed") {
CHECK(extended_path(fs::path(R"(C:\models\model.3mf)")).wstring() == LR"(\\?\C:\models\model.3mf)");
}
SECTION("separators are made native") {
CHECK(extended_path(fs::path("C:/models/model.3mf")).wstring() == LR"(\\?\C:\models\model.3mf)");
}
SECTION("dot segments are collapsed before prefixing") {
// The "\\?\" prefix disables Win32 normalisation, so "." and ".." must be gone beforehand.
CHECK(extended_path(fs::path(R"(C:\models\..\model.3mf)")).wstring() == LR"(\\?\C:\model.3mf)");
}
SECTION("a trailing separator is dropped, not turned into a dot element") {
CHECK(extended_path(fs::path(R"(C:\models\)")).wstring() == LR"(\\?\C:\models)");
}
SECTION("a UNC path becomes an extended UNC path") {
CHECK(extended_path(fs::path(R"(\\server\share\model.3mf)")).wstring() == LR"(\\?\UNC\server\share\model.3mf)");
}
SECTION("an already extended path is returned unchanged") {
const fs::path extended(R"(\\?\C:\models\model.3mf)");
CHECK(extended_path(extended) == extended);
}
SECTION("a device path is returned unchanged") {
const fs::path device(R"(\\.\C:\models\model.3mf)");
CHECK(extended_path(device) == device);
}
SECTION("a bare device path without a root directory is returned unchanged") {
// Not "absolute" by Boost's rules, so it must be recognized before absolutizing.
const fs::path device(R"(\\.\PhysicalDrive0)");
CHECK(extended_path(device) == device);
}
SECTION("a relative path is completed against the working directory") {
const fs::path result = extended_path(fs::path("model.3mf"));
CHECK(result.wstring().rfind(L"\\\\?\\", 0) == 0);
CHECK(result.filename() == fs::path("model.3mf"));
}
#else
SECTION("non-Windows returns the path unchanged") {
const boost::filesystem::path p = boost::filesystem::temp_directory_path() / "model.3mf";
CHECK(extended_path(p) == p);
}
#endif
}
+161
View File
@@ -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");
+69
View File
@@ -3,6 +3,7 @@
#include <catch2/catch_all.hpp>
#include <ios>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
@@ -10,13 +11,17 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "catch2/catch_message.hpp"
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <fstream>
#include <string>
#include <libslic3r/Zipper.hpp>
using namespace Slic3r;
namespace fs = boost::filesystem;
@@ -31,6 +36,14 @@ fs::path write_py_file(const fs::path& dir, const std::string& filename, const s
return p;
}
// The shared production helper: makes the path absolute and lexically normalized and, on
// Windows, prefixes it with the extended-length "\\?\" form, so the test can create, verify and
// clean up a tree past MAX_PATH even with LongPathsEnabled off. Identity on other platforms.
fs::path to_extended_path(const fs::path& p)
{
return Slic3r::extended_path(p);
}
} // namespace
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
@@ -184,3 +197,59 @@ TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginIns
REQUIRE(read_install_state(plugin_dir, scanned));
CHECK(scanned.installed_version == "1.0.0");
}
TEST_CASE("extract_zip_to_directory writes entries under a destination longer than MAX_PATH",
"[PluginInstall][PluginFsUtils]")
{
ScopedTemporaryDir dir("deep-extract");
const fs::path wheel = dir.path() / "deep_pkg-1.0-py3-none-any.whl";
const std::string payload = "print('hello from a deep path')\n";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry("pkg/", "", 0); // explicit directory entry
zipper.add_entry("pkg/module.py", payload.data(), payload.size());
zipper.finalize();
}
// A single 240-char component keeps every filename below the NTFS 255 limit while pushing the
// full destination (temp dir + component) past the 260-char MAX_PATH the Win32 APIs enforce
// when LongPathsEnabled is off.
const fs::path destination = dir.path() / std::string(240, 'd');
REQUIRE(destination.string().size() > 260);
std::string error;
REQUIRE(extract_zip_to_directory(wheel, destination, error));
INFO("extract error: " << error);
CHECK(error.empty());
// Verify through a long-path-capable path, otherwise the check itself is bounded by MAX_PATH.
const fs::path module_py = destination / "pkg" / "module.py";
REQUIRE(fs::exists(to_extended_path(module_py)));
REQUIRE(fs::is_regular_file(to_extended_path(module_py)));
// Remove the deep subtree here: ScopedTemporaryDir's plain remove_all cannot reach past
// MAX_PATH on Windows.
boost::system::error_code ec;
fs::remove_all(to_extended_path(destination), ec);
CHECK_FALSE(ec);
}
TEST_CASE("extract_zip_to_directory rejects an entry that escapes the destination",
"[PluginInstall][PluginFsUtils]")
{
ScopedTemporaryDir dir("escape-extract");
const fs::path wheel = dir.path() / "evil-1.0-py3-none-any.whl";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry("pkg/module.py", "print('ok')\n", 12);
zipper.add_entry("../escape.txt", "escaped\n", 8);
zipper.finalize();
}
std::string error;
CHECK_FALSE(extract_zip_to_directory(wheel, dir.path() / "out", error));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("escapes the plugin package"));
CHECK_FALSE(fs::exists(dir.path() / "escape.txt"));
}