Compare commits

..
74 changed files with 2862 additions and 2066 deletions

No files matched your search

@@ -574,8 +574,7 @@ DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
DMABUF renderer. On a hypervisor guest (`/proc/cpuinfo` `hypervisor` flag) and WebKitGTK >= 2.44 it sets
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` alone, since virtual GPUs cannot back the hardware path either. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
@@ -568,18 +568,13 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
## WebKitGTK on Linux sessions
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
sets the DMABUF workarounds and then branches on `GDK_BACKEND`:
sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`:
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
- **Hypervisor guests**, same position: when `is_hypervisor_guest()` (the `hypervisor` flag in
`/proc/cpuinfo`) and WebKitGTK >= 2.44, it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` non-replacing.
Virtual GPUs (VirtualBox VMSVGA, VMware, QEMU) cannot back the hardware DMABUF path either, and
FORCE_SHM alone was confirmed to fix web views in VirtualBox; the `WEBKIT_FORCE_DMABUF_RENDERER` pair
is not needed without the Debian `isNVIDIA()` patch. The flag is x86-only, so ARM guests are not covered.
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
@@ -595,9 +590,9 @@ Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_
(see `references/platforms.md`), not these environment variables.
**Pitfall.**
- **Rule:** On NVIDIA and hypervisor guests reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver and virtual GPUs cannot back WebKit's
hardware DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware
DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
-25
View File
@@ -232,31 +232,6 @@ jobs:
mkdir -p ${{ runner.temp }}/profile-check-results
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
# one long line per setting, for check_profiles_comment.yml to post.
- name: Diff custom G-code
if: ${{ always() && github.event_name == 'pull_request' }}
continue-on-error: true
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
results=${{ runner.temp }}/profile-check-results
diff=${{ runner.temp }}/gcode_diff.md
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
if [ -s "$diff" ]; then
{
echo "<!-- profile-gcode-diff-comment -->"
cat "$diff"
echo ""
# The comment workflow finds this line as the only one starting with <sub>.
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
} > "$results/gcode_diff.md"
fi
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
touch "$results/gcode_diff_done.txt"
- name: Prepare comment artifact
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: |
+3 -58
View File
@@ -38,7 +38,6 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
if [ ! -f pr_number.txt ]; then
echo "No pr_number.txt in artifact, skipping."
@@ -50,72 +49,18 @@ jobs:
echo "Invalid PR number: $PR_NUMBER"
exit 1
fi
# PR code wrote the artifact, so only a run for the named PR's current head may post.
if [ -z "$HEAD_SHA" ]; then
echo "The triggering run names no head commit."
exit 1
fi
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
echo "Cannot look up PR #${PR_NUMBER}."
exit 1
fi
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
exit 0
fi
# Lists every bot comment on the PR, one JSON object per line.
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
for comment_id in $OLD_IDS; do
echo "Deleting outdated profile-validation comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done
# Post a new comment only when validation failed (pr_comment.md present).
# A failed post fails the job only after the G-code diff is handled.
failed=0
if [ -f pr_comment.md ]; then
gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
gh pr comment "$PR_NUMBER" --body-file pr_comment.md
else
echo "Validation succeeded; cleaned up prior comments without posting."
fi
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
update_gcode_diff_comment() {
if [ ! -f gcode_diff_done.txt ]; then
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
return 0
fi
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
rm gcode_diff.md
fi
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
return 0
fi
# Edits in place when only the <sub> line differs, since edits notify nobody.
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
return 0
fi
fi
for comment_id in $GCODE_IDS; do
echo "Deleting outdated G-code diff comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done
if [ -f gcode_diff.md ]; then
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
fi
}
update_gcode_diff_comment
exit "$failed"
-7
View File
@@ -53,13 +53,6 @@ Space counts as typing. The speed dial's default is the one bare Space, and
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
combo boxes), so it opens the dial from the canvases and the tab strip only.
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
while a text field has the focus (a wx text entry, spin control, web view or ImGui
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
## Which event a chord matches
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
+252
View File
@@ -0,0 +1,252 @@
# 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 = 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 = 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 synchronous: it blocks the background Script worker until the target selection and
any unsaved-edit dialog settle, then returns the bound transaction. Orca's UI thread
stays responsive because the Script capability runs on its own worker. `set()` and
`commit()` run synchronously on that same worker. The Script worker and its optional
coroutine loop are 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 call remains blocked on the Script worker until those interactions
finish; it returns the bound transaction, or raises on 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. 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 the UI-thread dispatch needed to bind the transaction.
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 Script worker stays blocked 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 between `begin_update()` and `commit()`. The API
detects that and rejects 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 like `begin_update()`: both block
the background Script worker while any selection dialog settles.
## 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()` and `update()` are synchronous blocking calls
(`begin_preset_update` / `begin_filament_update`). They marshal the target binding to
the UI thread and block the background Script worker until the selection settles, so
the wait never blocks the UI thread. They are valid only on that worker, never from the
UI thread or a slicing pipeline hook.
`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 through the blocking `begin_update()` and reuses
the normal Plater slot-selection behavior for explicit filament targets, and commits a
batch without losing existing per-setting behavior.
+351
View File
@@ -0,0 +1,351 @@
# Python plugin async bridge — High Level Design
## Purpose and scope
The embedded Python plugin runtime runs most capability calls synchronously: a C++ call site acquires
the GIL and the capability's Python method runs to completion on the calling thread. The staged preset
update (`begin_update()` in [preset-plugin-api.md](preset-plugin-api.md)) is also synchronous, but
its target selection can open unsaved-edit dialogs. It therefore blocks the Script worker, not the UI
thread, and returns only when the selection settles.
This document describes the Script capability's background execution path, including the optional
per-invocation asyncio loop for an `async def execute`, and the blocking UI marshalling that host calls
use from that worker. The bridge is deliberately narrow: only the Script capability gains a
background/async execution path. Every other capability — including the SlicingPipeline hooks — stays
synchronous on its caller's thread.
## 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` and exposed through
`ScriptAsyncRuntime::current_loop()` (GIL held). A host call such as `begin_update()` does not suspend
the loop: it blocks the worker thread while the UI settles, then returns a value the coroutine uses
directly.
`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.
### Blocking UI continuation
`PluginHostUi::run_on_ui_thread` (in `src/slic3r/plugin/host/PluginHostUi.cpp`) is the primitive host
calls use from the Script worker. It forwards to `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()`.
Because the Script capability owns a worker thread, blocking there does not freeze the UI. The same
primitive is used by `begin_update()`, `set()`/`commit()`, `reload_local_bundle()` and
`list_bundle_ids()`.
### Synchronous `begin_update()`
`begin_preset_update` and `begin_filament_update` (`src/slic3r/plugin/host/PluginHostPresets.cpp`) run
on the Script worker with the GIL held and block it:
1. the binding enters through `PluginHostUi::run_on_ui_thread`, which fails with a `RuntimeError`
before posting when there is no wx application;
2. the UI thread runs the target binding with the GIL released — `bind_preset_target` for
process/printer, `bind_filament_target` for filament — including `Tab::select_preset()` and any
unsaved-edit prompt;
3. the worker resumes with the name-based `PresetTransaction`, and nothing is staged until the
caller's later `set()`/`commit()`.
An unavailable target is rejected before selection and changes no preset. A selection the user
declines raises `RuntimeError` and creates no transaction; decisions already accepted in an earlier
dialog are not rolled back. No GUI or preset pointer outlives the call: the transaction 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.
### Cancellation
`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).
A task cancellation cannot interrupt a blocking `begin_update()` native call: the worker is inside
that call, so the loop does not run the scheduled `task.cancel()` until the selection settles and the
call returns. The transaction is therefore delivered, and any accepted UI selection stands; the
cancellation is observed at the coroutine's next suspension point. A user-declined selection is an
ordinary `RuntimeError`, not `asyncio.CancelledError`.
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 blocking UI selection cannot mutate presets after teardown.
- `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 is outstanding** — its loop-exit
condition is `all_released && !invocation_active`. `has_active_run()` distinguishes a run that was
cancelled before it started from one whose worker is still live, and a worker blocked in
`begin_update()` keeps the run active until its UI callback returns. On the GUI thread the drain
pumps `wxYield()` so a modal selection can settle instead of being starved by the blocking wait.
Only the non-Script path keeps the historical bounded 2-second grace: when no invocation is active,
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.
- **Marshal host UI work from the Script worker only.** `PluginHostUi::run_on_ui_thread` blocks the
calling worker while the UI thread runs the callback; calling it from the UI thread itself would
deadlock the marshalling path.
- **No pointer crosses a call boundary.** `begin_update()` returns copied category/name/slot 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 marshalling:
- `src/slic3r/plugin/host/PluginHostUi.hpp` / `.cpp` — `PluginHostUi::run_on_ui_thread`, the
file-local `run_on_ui_blocking`, and `close_windows_for_plugin`.
- `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`, `begin_filament_update`,
`SelectionCancelled`, `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 updates a preset. `execute` runs on the background Script worker with its
own asyncio loop; `begin_update()` blocks that worker until any selection / unsaved-edit dialog is
answered, without blocking the UI thread; `set()`/`commit()` then run synchronously on the same
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() blocks this worker until the user answers any
# selection / unsaved-edit dialog. The UI thread stays responsive.
tx = 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 calls `begin_update()` per command.
Sequence for one `begin_update(...)`:
```text
Script worker (owned, per invocation) UI thread
loop.run_until_complete(task)
begin_update() ───────────────────────► CallAfter(run_on_ui_blocking)
(blocked; not suspended) bind_*_target →
Tab::select_preset() … dialog
settles
promise set; worker resumes with PresetTransaction
set(); commit() (UI thread for the batch)
coroutine resumes at its next await
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.
-3
View File
@@ -10657,9 +10657,6 @@ msgstr ""
msgid "Send Failed"
msgstr ""
msgid "No available printers"
msgstr ""
msgid "The name length exceeds the limit."
msgstr ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Snapmaker",
"version": "02.04.00.18",
"version": "02.04.00.17",
"force_update": "0",
"description": "Snapmaker configurations",
"machine_model_list": [
@@ -49,8 +49,5 @@
],
"pressure_advance": [
"0.02"
],
"temperature_vitrification": [
"100"
]
}
@@ -46,8 +46,5 @@
],
"fan_min_speed": [
"0"
],
"temperature_vitrification": [
"100"
]
}
@@ -45,7 +45,7 @@
"250"
],
"temperature_vitrification": [
"70"
"178"
],
"fan_max_speed": [
"20"
@@ -4,8 +4,5 @@
"inherits": "fdm_filament_petg",
"from": "system",
"filament_id": "OFW8lqnb",
"instantiation": "false",
"temperature_vitrification": [
"70"
]
"instantiation": "false"
}
@@ -60,7 +60,7 @@
"250"
],
"temperature_vitrification": [
"70"
"178"
],
"fan_max_speed": [
"20"
@@ -34,8 +34,5 @@
],
"nozzle_temperature": [
"220"
],
"temperature_vitrification": [
"45"
]
}
@@ -34,8 +34,5 @@
],
"filament_cooling_final_speed": [
"60"
],
"temperature_vitrification": [
"45"
]
}
@@ -34,8 +34,5 @@
],
"nozzle_temperature": [
"220"
],
"temperature_vitrification": [
"45"
]
}
@@ -40,8 +40,5 @@
],
"nozzle_temperature": [
"220"
],
"temperature_vitrification": [
"45"
]
}
@@ -31,8 +31,5 @@
],
"slow_down_layer_time": [
"8"
],
"temperature_vitrification": [
"45"
]
}
@@ -50,8 +50,5 @@
"additional_cooling_fan_speed": [
"70",
"70"
],
"temperature_vitrification": [
"30"
]
}
@@ -137,7 +137,7 @@
"240"
],
"temperature_vitrification": [
"30"
"60"
],
"nozzle_temperature_range_low": [
"200"
@@ -302,7 +302,7 @@
"-1"
],
"temperature_vitrification": [
"30"
"60"
],
"textured_cool_plate_temp": [
"40"
@@ -29,8 +29,5 @@
"additional_cooling_fan_speed": [
"70",
"70"
],
"temperature_vitrification": [
"30"
]
}
@@ -49,8 +49,5 @@
"additional_cooling_fan_speed": [
"70",
"70"
],
"temperature_vitrification": [
"30"
]
}
@@ -97,7 +97,7 @@
"20",
"12"
],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.14",
@@ -97,7 +97,7 @@
"20",
"12"
],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.32",
@@ -97,7 +97,7 @@
"20",
"12"
],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.42",
@@ -97,7 +97,7 @@
"20",
"12"
],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.56",
+99
View File
@@ -0,0 +1,99 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# id = "preset-transaction-demo"
# name = "Preset Transaction Demo"
# description = "Stages several settings on one preset with begin_update()/set()/commit() (demo)."
# author = "OrcaSlicer contributors"
# version = "0.2.0"
# type = "script"
# ///
"""Preset transaction demo: change several settings with one commit.
Enable the plugin, then run "Preset Transaction Demo" from the Plugins dialog.
Edit CATEGORY, PRESET_NAME, FILAMENT_SLOT and UPDATES below to try other presets
and settings.
``begin_update()`` resolves the target through Orca's normal preset-selection
flow, so it may open Orca's usual "unsaved changes" dialog. The call is
synchronous: the background Script worker waits until the user answers, while
the UI thread stays responsive. ``set()`` only stages values; ``commit()``
validates and applies the whole batch. The edited preset stays unsaved (dirty)
until the user saves it through the normal preset workflow; nothing here writes
to disk.
Values are Orca's serialized config strings: numbers plain, percentages with
"%", booleans "1"/"0", choices by their internal key, lists ";"-separated.
"""
import orca
# ---------------------------------------------------------------------------
# Edit these. CATEGORY and UPDATES belong together: each presets category only
# accepts its own setting keys.
# ---------------------------------------------------------------------------
# Preset category: "process", "printer", or "filament".
CATEGORY = "process"
# Target preset name for process/printer. "" keeps the currently active preset.
# For filament this optionally selects and assigns the preset for FILAMENT_SLOT;
# "" keeps the preset the slot already has.
PRESET_NAME = ""
# 1-based filament slot. Only used when CATEGORY is "filament".
FILAMENT_SLOT = 1
# Settings staged in order, then committed together.
UPDATES = (
("layer_height", "0.25"),
("wall_loops", "3"),
)
def _begin_update():
"""Bind CATEGORY's target and return a transaction to stage changes on."""
if CATEGORY not in ("process", "printer", "filament"):
raise ValueError('CATEGORY must be "process", "printer", or "filament"')
if CATEGORY == "filament":
# Filament updates are addressed by their 1-based slot number.
return orca.host.preset.filament.begin_update(FILAMENT_SLOT, PRESET_NAME)
return getattr(orca.host.preset, CATEGORY).begin_update(PRESET_NAME)
class PresetTransactionDemo(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Preset transaction demo"
def execute(self):
# A plain def runs on the background Script worker, so begin_update() may
# block here through Orca's selection / unsaved-edit prompts.
try:
transaction = _begin_update()
for key, value in UPDATES:
transaction.set(key, value)
result = transaction.commit()
except Exception as error:
return orca.ExecutionResult.failure(
orca.PluginResult.RecoverableError,
"Preset transaction demo failed: %s" % error,
)
return orca.ExecutionResult.success(
'Committed %d setting(s) to the %s preset "%s"; %s.'
% (
len(result.keys),
result.category,
result.target_name,
"it stays unsaved until you save it"
if result.dirty
else "it already matches its saved state",
)
)
@orca.plugin
class PresetTransactionDemoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(PresetTransactionDemo)
+188
View File
@@ -0,0 +1,188 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# id = "preset-update-demo"
# name = "Preset Update Demo"
# description = "Browse and edit settings in process, printer, and filament presets."
# author = "OrcaSlicer contributors"
# version = "0.4.0"
# type = "script"
# ///
"""Interactive preset editor. Snapshots are read on the UI thread; edits run on execute's worker."""
import queue
import orca
PAGE = r'''<!doctype html><html><head><meta charset="utf-8"><style>
:root{color-scheme:light dark;--a:#087f78;--line:rgba(127,127,127,.25);--soft:rgba(127,127,127,.1)}
*{box-sizing:border-box}body{margin:0;font:13px/1.45 var(--orca-font,system-ui,sans-serif);display:flex;flex-direction:column;height:100vh;color:var(--orca-fg,#20242a);background:var(--orca-bg,#fafafa)}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px}h1{font-size:16px;margin:0}.brand{color:var(--a);font-size:20px}select,input,textarea,button{font:inherit;color:inherit}select,input,textarea{background:var(--orca-bg,#fafafa);border:1px solid var(--line);border-radius:7px;padding:7px 9px}header select{margin-left:auto;min-width:140px}.bar{padding:10px 18px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--line)}.bar select{min-width:220px}.bar label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.65}.badge{padding:3px 9px;border:1px solid var(--line);border-radius:20px;opacity:.7}.layout{display:grid;grid-template-columns:minmax(230px,42%) 1fr;min-height:0;flex:1}.list{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}.search{padding:9px;border-bottom:1px solid var(--line)}.search input{width:100%}#rows{overflow:auto;padding:4px}.row{width:100%;display:flex;gap:12px;padding:7px 9px;border:0;border-left:3px solid transparent;background:transparent;text-align:left;cursor:pointer;border-radius:3px}.row:hover,.row.sel{background:var(--soft)}.row.sel{border-left-color:var(--a)}.key{flex:1;font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis}.val{max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.65;font-family:ui-monospace,monospace}.editor{padding:20px;display:flex;flex-direction:column;gap:10px;overflow:auto}.editor h2{font:600 15px ui-monospace,monospace;margin:0;overflow-wrap:anywhere}.muted{opacity:.65;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.current{font-family:ui-monospace,monospace;background:var(--soft);padding:10px;border-radius:7px;overflow-wrap:anywhere;white-space:pre-wrap}textarea{min-height:88px;resize:vertical;font-family:ui-monospace,monospace}.actions{display:flex;gap:8px}button.action{padding:7px 15px;border-radius:7px;border:1px solid var(--line);background:transparent;cursor:pointer}button.primary{background:var(--a);border-color:var(--a);color:white}button:disabled{opacity:.45;cursor:default}.hint{margin-top:auto;padding:10px;border-left:3px solid var(--a);background:var(--soft);border-radius:4px}.status{padding:8px 18px;border-top:1px solid var(--line);opacity:.75}.error{color:#c33}.empty{padding:20px;opacity:.65}@media(max-width:650px){.layout{grid-template-columns:1fr 1fr}.bar{flex-wrap:wrap}.bar select{min-width:120px;flex:1}}
</style></head><body>
<header><span class="brand">◈</span><h1>Preset editor</h1><select id="kind"><option value="process">Process</option><option value="printer">Printer</option><option value="filament">Filament</option></select><button class="action" id="close">Close</button></header>
<div class="bar"><label id="slotlabel" hidden>Slot</label><select id="slot" hidden></select><label>Preset</label><select id="preset"></select><span class="badge" id="dirty"></span><button class="action" id="reload">Refresh</button></div>
<div class="layout"><section class="list"><div class="search"><input id="search" placeholder="Search settings…"></div><div id="rows"></div></section><section class="editor"><div class="muted">Selected setting</div><h2 id="key">Choose a setting</h2><div class="muted">Current value</div><div class="current" id="current">—</div><label class="muted" for="value">New serialized value</label><textarea id="value" disabled></textarea><div class="actions"><button class="action primary" id="apply" disabled>Apply change</button><button class="action" id="reset" disabled>Reset</button></div><div class="hint">Choose a category, preset and filament slot. Edit any listed key, then apply. Orca may ask to switch presets or resolve unsaved changes. Values stay unsaved until saved in Orca.</div></section></div><footer class="status" id="status">Loading presets…</footer>
<script>
const $=id=>document.getElementById(id),S={kind:'process',slot:1,preset:'',rows:[],selected:null,busy:true,notice:'',noticeError:false};
function send(command,extra={}){$('status').textContent='Working…';S.busy=true;controls();orca.postMessage({command,kind:S.kind,slot:S.slot,preset:$('preset').value,...extra})}
function controls(){ $('kind').disabled=S.busy;$('slot').disabled=S.busy;$('preset').disabled=S.busy;$('apply').disabled=S.busy||!S.selected;$('reload').disabled=S.busy }
function load(){send('state')}
function paint(m){const notice=S.notice,noticeError=S.noticeError;S.notice='';S.noticeError=false;S.busy=false;S.kind=m.kind;S.slot=m.slot||1;S.rows=m.rows||[];S.selected=S.rows.some(r=>r.k===S.selected)?S.selected:null;
$('slotlabel').hidden=S.kind!=='filament';$('slot').hidden=S.kind!=='filament';$('slot').innerHTML=(m.slots||[]).map(x=>`<option value="${x.slot}">Slot ${x.slot} · ${esc(x.name||'Empty')}</option>`).join('');$('slot').value=S.slot;
$('preset').innerHTML=(m.presets||[]).map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');$('preset').value=m.preset_name||'';$('dirty').textContent=m.dirty?'Modified':'Saved';
const q=$('search').value.toLowerCase(),rows=S.rows.filter(r=>(r.k+' '+r.v).toLowerCase().includes(q));$('rows').innerHTML=rows.map(r=>`<button class="row ${S.selected===r.k?'sel':''}" data-key="${esc(r.k)}"><span class="key">${esc(r.k)}</span><span class="val">${esc(r.v)}</span></button>`).join('')||'<div class="empty">No matching settings</div>';
const r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r?r.k:'Choose a setting';$('current').textContent=r?r.v:'—';$('value').disabled=!r;$('value').value=r?r.v:'';$('reset').disabled=!r;controls();$('status').textContent=m.error||notice||m.message||`${S.rows.length} settings · changes remain unsaved`;$('status').className='status'+((m.error||noticeError)?' error':'') }
function esc(x){return String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
$('kind').onchange=()=>{S.kind=$('kind').value;S.selected=null;$('preset').value='';load()};$('slot').onchange=()=>{S.slot=Number($('slot').value);S.selected=null;$('preset').value='';load()};$('preset').onchange=()=>{S.selected=null;load()};$('reload').onclick=load;$('close').onclick=()=>orca.close();$('search').oninput=()=>paint({kind:S.kind,slot:S.slot,rows:S.rows,presets:[...$('preset').options].map(o=>o.value),preset_name:$('preset').value});
$('rows').onclick=e=>{let b=e.target.closest('[data-key]');if(!b)return;S.selected=b.dataset.key;let r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r.k;$('current').textContent=r.v;$('value').value=r.v;$('value').disabled=false;$('reset').disabled=false;$('apply').disabled=false;document.querySelectorAll('.row').forEach(x=>x.classList.toggle('sel',x===b))};$('reset').onclick=()=>{let r=S.rows.find(x=>x.k===S.selected);if(r)$('value').value=r.v};$('apply').onclick=()=>send('apply',{key:S.selected,value:$('value').value});orca.onMessage(m=>{if(m.command==='state')paint(m);else if(m.command==='refresh'){S.notice=m.error||m.message||'';S.noticeError=!!m.error;load()}});load();
</script></body></html>'''
class PresetUpdateDemo(orca.script.ScriptPluginCapabilityBase):
window = None
_commands = None
_cancelled = False
def get_name(self):
return "Preset editor demo"
def execute(self):
if self.window is not None and self.window.is_open():
return orca.ExecutionResult.success("Preset editor is already open.")
# The queue exists before the window so on_cancelled() can always unblock this worker, even
# when a forced unload destroys the window without firing on_close.
commands = queue.Queue()
self._commands = commands
try:
if self._cancelled:
return orca.ExecutionResult.success("Preset editor closed.")
self.window = orca.host.ui.create_window(
title="Preset editor", html=PAGE, width=900, height=620,
on_message=self._on_message,
on_close=self._on_close,
)
while True:
message = commands.get()
if self._cancelled or message.get("command") == "close":
return orca.ExecutionResult.success("Preset editor closed.")
try:
notice = {"command": "refresh", "message": self._apply(message)}
except Exception as error:
notice = {"command": "refresh", "error": str(error)}
if self.window is not None and self.window.is_open():
self.window.post(notice)
except Exception as error:
return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError,
"Preset editor failed: %s" % error)
finally:
if self._commands is commands:
self._commands = None
self.window = None
# Both UI callbacks run on the wx main thread with the GIL held.
def _on_message(self, message):
message = dict(message) if isinstance(message, dict) else {}
if message.get("command") == "apply":
self._enqueue(message)
return
try:
state = self._snapshot(message)
except Exception as error:
state = {"command": "state", "kind": message.get("kind", "process"),
"slot": message.get("slot", 1), "rows": [], "presets": [], "slots": [],
"preset_name": "", "dirty": False, "error": str(error)}
window = self.window
if window is not None and window.is_open():
window.post(state)
def _on_close(self):
self._enqueue({"command": "close"})
def on_cancelled(self):
# Called before the unload drain. Enqueueing a close lets a worker parked in get() exit; a
# callback that arrives after the worker already left sees no queue and is harmless.
self._cancelled = True
self._enqueue({"command": "close"})
def _enqueue(self, message):
commands = self._commands
if commands is not None:
commands.put(message)
def _apply(self, message):
kind = message.get("kind", "process")
if kind not in ("process", "printer", "filament"):
raise ValueError("Unknown preset category")
name = message.get("preset") or ""
if not isinstance(name, str):
raise ValueError("Preset name must be a string")
key, value = message.get("key"), message.get("value")
if not isinstance(key, str) or not key or not isinstance(value, str):
raise ValueError("Setting key and serialized value required")
editor = getattr(orca.host.preset, kind)
if kind == "filament":
slot = int(message.get("slot", 1))
if slot < 1:
raise ValueError("Filament slot numbers are 1-based")
transaction = editor.begin_update(slot, name)
else:
transaction = editor.begin_update(name)
transaction.set(key, value)
result = transaction.commit()
status = "Set %s on %s. Changes remain unsaved." % (key, result.target_name)
if result.selection_changed:
status += " Orca switched the active preset."
if kind == "filament" and len(result.shared_slots) > 1:
status += " Shared by filament slots: %s." % ", ".join(str(s) for s in result.shared_slots)
return status
# Read-only snapshot. Runs on the UI thread so no live Preset or PresetBundle object is read
# from the Script worker, and never calls begin_update() or another blocking preset operation.
def _snapshot(self, message):
kind = message.get("kind", "process")
if kind not in ("process", "printer", "filament"):
raise ValueError("Unknown preset category")
try:
slot = int(message.get("slot", 1))
except (TypeError, ValueError):
slot = 1
requested = message.get("preset") or ""
if not isinstance(requested, str):
requested = ""
bundle = orca.host.preset_bundle()
slots = []
if kind == "filament":
assigned_names = list(bundle.current_filament_preset_names())
presets = list(bundle.current_filament_presets())
slots = [{"slot": index + 1,
"name": presets[index].name if presets[index] is not None
else (assigned_names[index] or None)}
for index in range(len(presets))]
if not 1 <= slot <= len(presets):
slot = 1
preset = bundle.filaments.find_preset(requested) if requested else None
if preset is None and presets:
preset = presets[slot - 1]
available = list(bundle.filaments.preset_names())
else:
collection = bundle.prints if kind == "process" else bundle.printers
available = list(collection.preset_names())
preset = collection.find_preset(requested) if requested else None
if preset is None:
preset = (bundle.current_process_preset() if kind == "process"
else bundle.current_printer_preset())
rows = []
if preset is not None:
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 {"command": "state", "kind": kind, "slot": slot, "slots": slots,
"presets": available, "preset_name": preset.name if preset is not None else "",
"dirty": bool(preset.is_dirty) if preset is not None else False, "rows": rows}
@orca.plugin
class PresetUpdateDemoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(PresetUpdateDemo)
+4 -251
View File
@@ -12,7 +12,6 @@ commands:
trim delete profile files no <vendor>.json list references
update-index regenerate the *_list sections of <vendor>.json
legacy-compat write the printers user presets can be migrated back to
gcode-diff print a readable markdown diff of changed custom G-code
options shared by several commands:
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
@@ -95,14 +94,11 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
"""
import argparse
import difflib
import functools
import html
import json
import os
import posixpath
import re
import subprocess
import sys
import uuid
from collections import Counter, defaultdict
@@ -196,7 +192,6 @@ VECTOR_KEYS = {
PRINT_CONFIG_CPP = os.path.normpath(
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
@functools.lru_cache(maxsize=None)
@@ -318,11 +313,11 @@ REMEDY_HINTS = {
}
def print_error(msg, file=None):
print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
def print_error(msg):
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
def print_warning(msg, file=None):
print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
def print_warning(msg):
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
@@ -3214,223 +3209,6 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
return 0
# ---------------------------------------------------------------------------
# gcode-diff
# ---------------------------------------------------------------------------
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
GCODE_DIFF_BUDGET = 60000
# The file names a section's summary shows before "and N more".
GCODE_DIFF_SUMMARY_FILES = 3
GCODE_DIFF_HEADER = (
"## Custom G-code changes\n"
"\n"
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
"diff of each changed G-code or other text setting.\n")
@functools.lru_cache(maxsize=None)
def _text_option_keys():
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
(every *_gcode option, and every one it marks multiline, such as notes), plus the
ones Tab.cpp edits as code, such as filename_format."""
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
source = f.read()
keys = set()
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
if match and (match.group(1).endswith("_gcode")
or re.search(r"def->multiline\s*=\s*true", body)):
keys.add(match.group(1))
if "machine_start_gcode" not in keys:
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
# Tab.cpp only adds keys, so a missing file is not an error.
try:
with open(TAB_CPP, encoding="utf-8") as f:
gui = f.read()
except OSError:
gui = ""
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
gui, re.DOTALL))
return frozenset(keys)
def _git(cwd, *args, stdin=None):
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
capture_output=True).stdout
def _blobs_at(profiles_dir, specs):
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
profiles_dir) pairs, all read by one git cat-file process."""
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
out = _git(profiles_dir, "cat-file", "--batch",
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
blobs, pos = {}, 0
for spec in specs:
end = out.index(b"\n", pos)
header = out[pos:end]
pos = end + 1
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
if header.endswith((b" missing", b" ambiguous")):
blobs[spec] = None
continue
_oid, kind, size = header.split()
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
pos += int(size) + 1
return blobs
def _parse_profile(raw, rel, where):
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
if raw is None:
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
return None
try:
return json.loads(raw.decode("utf-8-sig"))
except ValueError as e:
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
return None
def _changed_profiles(profiles_dir, base, head):
"""(old path, new path) of each JSON file under profiles_dir that differs between
base and head (the working tree, untracked files included, when None); the absent
side of an added or deleted file is None, and so is a side that is not JSON. Renames
are paired."""
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
*([head] if head else []), "--", ".")
fields = out.decode("utf-8").split("\0")
pairs, i = [], 0
while i + 1 < len(fields):
status = fields[i]
old = new = fields[i + 1]
if status[0] in "RC":
new = fields[i + 2]
i += 3
else:
i += 2
old = None if status[0] == "A" or not old.endswith(".json") else old
new = None if status[0] == "D" or not new.endswith(".json") else new
if old or new:
pairs.append((old, new))
if head is None:
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
return pairs
def _text_lines(value):
return [line.rstrip() for line in value.split("\n")] if value else []
def _text_changes(old, new):
"""(setting, unified diff) for every G-code or other text value that differs
between two profiles; a vector option with several values gives one per value."""
changes = []
for key in list(new) + [k for k in old if k not in new]:
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
if a == b or not all(isinstance(v, str) for v in a + b):
continue
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
continue
width = max(len(a), len(b))
for i in range(width):
diff = list(difflib.unified_diff(
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
lineterm=""))[2:] # drops the ---/+++ file header
if diff:
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
return changes
def _gcode_diff_section(files, setting, diff):
# A fence longer than any backtick run in the G-code cannot be closed by it.
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
def code(text):
return f"<code>{html.escape(text, quote=False)}</code>"
names, listing = ", ".join(map(code, files)), ""
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
def gcode_diff(profiles_dir, base, head=None, vendors=None):
"""Markdown with one collapsed diff per changed G-code setting between base and
head, or "" when none changed. Files whose diff of a setting is identical share
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
specs = ([(base, old) for old, _ in pairs if old]
+ [(head, new) for _, new in pairs if new and head])
blobs = _blobs_at(profiles_dir, specs) if specs else {}
def load(ref, rel):
if rel is None:
return {}
if ref:
return _parse_profile(blobs[(ref, rel)], rel, ref)
try:
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
except OSError as e:
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
return None
groups = {} # (setting, diff) -> files, in the order git lists them
for old_rel, new_rel in pairs:
old, new = load(base, old_rel), load(head, new_rel)
if not isinstance(old, dict) or not isinstance(new, dict):
continue
for setting, diff in _text_changes(old, new):
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
if not groups:
return ""
sections = [_gcode_diff_section(files, setting, diff)
for (setting, diff), files in groups.items()]
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
shown = 0
for section in sections:
budget -= len(section.encode("utf-8"))
if budget < 0:
break
shown += 1
note, hidden = "", len(sections) - shown
if hidden:
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
"shown because the comment would be too long for GitHub. See the Files "
"changed tab for the rest.\n")
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
def _default_gcode_diff_base(profiles_dir, head):
for main in ("upstream/main", "origin/main"):
try:
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
except subprocess.CalledProcessError:
continue
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
def run_gcode_diff(profiles_dir, vendors, base, head):
try:
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
head, vendors)
# UnicodeDecodeError means git listed a file name that is not UTF-8.
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
stderr = getattr(e, "stderr", None)
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
return 1
sys.stdout.write(markdown)
return 0
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -3455,9 +3233,6 @@ examples:
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
orca_profile_tool.py gcode-diff > gcode.md
the changed custom G-code of the working tree since it left upstream/main,
as markdown with one collapsed diff per setting
after adding, renaming or deleting profile files, run in this order:
normalize -> update-index -> generate-id -> check
@@ -3612,25 +3387,6 @@ def build_parser():
legacy_cmd.add_argument("--check", action="store_true",
help="fail if the map differs from what --base implies; write nothing")
gcode_cmd = add(
"gcode-diff", [vendor_opt, profiles_opt],
"print a readable markdown diff of changed custom G-code",
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
"setting that differs between --base and --head in each changed profile\n"
"file, one collapsed section per setting. Files whose diff of a setting is\n"
"identical share a section. Trailing spaces are removed from every line, and\n"
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
"when no such setting changed. CI posts it on profile PRs.\n"
"\n"
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
"string value that spans several lines.")
gcode_cmd.add_argument("--base", metavar="REF",
help="compare against this git ref (default: the merge base "
"with upstream/main, else origin/main)")
gcode_cmd.add_argument("--head", metavar="REF",
help="compare this git ref (default: the working tree)")
return parser
@@ -3667,9 +3423,6 @@ def main(argv=None):
if args.command == "legacy-compat":
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
if args.command == "gcode-diff":
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
if args.command == "fix-variant":
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
strict=args.strict)
+3 -309
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
subcommand dispatch that reaches them (stdlib unittest, no external deps).
normalize, trim, update-index, fix-variant and check commands, and the subcommand
dispatch that reaches them (stdlib unittest, no external deps).
The id halves are covered by test_filament_id.py and test_setting_id.py.
@@ -14,12 +14,9 @@ import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -44,24 +41,7 @@ class Tree:
os.makedirs(self.profiles)
def cleanup(self):
root = os.path.abspath(self.dir)
def writable_retry(func, path, _exc):
# Windows refuses to delete read-only files such as git's objects, and POSIX
# refuses to delete the entries of a read-only directory.
if not os.path.lexists(path): # already removed, as on a second cleanup
return
parent = os.path.dirname(os.path.abspath(path))
try:
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
os.chmod(parent, stat.S_IRWXU)
if not os.path.islink(path): # chmod would follow it out of the tree
os.chmod(path, stat.S_IRWXU)
func(path)
except (OSError, ValueError): # commonpath raises ValueError across drives
pass
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
shutil.rmtree(self.dir, **{hook: writable_retry})
shutil.rmtree(self.dir, ignore_errors=True)
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
@@ -1429,292 +1409,6 @@ class TestDispatch(TreeCase):
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
# ---------------------------------------------------------------------------
# gcode-diff
# ---------------------------------------------------------------------------
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestGcodeDiff(TreeCase):
START = "G28\nM104 S200\nG1 Z5"
def git(self, *args):
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
def printer(self, name, **extra):
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
def commit(self):
self.git("add", "-A")
self.git("commit", "-q", "--allow-empty", "-m", "x")
@classmethod
def setUpClass(cls):
# git's list of the variables that tie a process to a repository, asked with no
# GIT_* set since a stray GIT_DIR fails the query.
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
cls.repo_env_vars = set(subprocess.run(
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
capture_output=True, text=True).stdout.split())
def setUp(self):
super().setUp()
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
# tool's included, at the real repository instead of the temp tree.
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
patcher = mock.patch.dict(os.environ, env, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
self.git("init", "-q")
for name in ("A", "B", "C"):
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
self.printer("Gone", machine_end_gcode="M84")
self.commit()
def diff(self):
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
self.assertEqual(rc, 0, out)
return out
def test_identical_edits_share_one_section(self):
for name in ("A", "B"):
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
out = self.diff()
self.assertEqual(out.count("<details>"), 2)
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
"<code>machine_start_gcode</code>", out)
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
# Trailing spaces are removed.
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
self.assertIn("\n\n```diff\n@@ ", out)
self.assertIn("\n```\n\n</details>", out)
def test_nothing_is_printed_without_a_text_change(self):
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
self.assertEqual(self.diff(), "")
def test_added_and_deleted_files_show_the_whole_value(self):
# An untracked file counts as added; the validator's user dir is not a profile.
self.printer("New", machine_start_gcode="G28\nG29")
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
os.remove(self.t.path("V", "machine/Gone.json"))
out = self.diff()
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
self.assertNotIn("U.json", out)
def test_a_multi_line_value_counts_whatever_its_key(self):
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
def test_a_file_name_cannot_mention_a_user(self):
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
def test_head_compares_two_commits(self):
self.printer("A", machine_start_gcode="G28")
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
def test_a_renamed_file_diffs_against_its_old_self(self):
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
def test_a_file_renamed_off_json_loses_its_values(self):
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
def test_a_file_renamed_onto_json_is_new(self):
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
self.commit()
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
def test_a_batch_read_survives_paths_that_are_not_files(self):
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
("HEAD", "V/machine/Gone.json")])
self.assertIsNone(blobs[("HEAD", "V/machine")])
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
"M84")
def test_each_value_of_a_vector_option_is_its_own_setting(self):
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
self.commit()
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
out = self.diff()
self.assertEqual(out.count("<details>"), 1)
self.assertIn("<code>filament_start_gcode[1]</code>", out)
def test_vendor_limits_the_files(self):
self.printer("A", machine_start_gcode="G28")
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
self.assertEqual(rc, 0, out)
self.assertIn("V/machine/A.json", out)
self.assertNotIn("W/machine/X.json", out)
def test_vendor_includes_files_moved_out_of_it(self):
self.t.add_vendor("W")
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
data = self.t.read("W", "machine/A.json")
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
"--vendor", "V")
self.assertEqual(rc, 0, out)
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
def same_edit(self, names):
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
for name in names:
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
self.commit()
for name in names:
self.printer(name, machine_start_gcode="G28")
out = self.diff()
return re.search(r"<summary>(.*)</summary>", out).group(1), out
def test_a_long_file_list_moves_into_the_section(self):
summary, out = self.same_edit("ABCDE")
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
self.assertNotIn("D.json", summary)
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
out)
def test_four_files_are_all_named_in_the_summary(self):
summary, _out = self.same_edit("ABCD")
self.assertIn("<code>V/machine/D.json</code> - ", summary)
self.assertNotIn("more", summary)
def test_an_error_goes_to_stderr_only(self):
err = io.StringIO()
with contextlib.redirect_stderr(err):
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
self.assertEqual(rc, 1)
self.assertEqual(out, "")
self.assertIn("no-such-ref", err.getvalue())
def test_a_file_name_that_is_not_utf8_is_an_error(self):
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
err = io.StringIO()
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
contextlib.redirect_stderr(err):
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
self.assertEqual((rc, out), (1, ""))
self.assertIn("invalid continuation byte", err.getvalue())
def test_output_is_cut_to_whole_sections(self):
for name in ("A", "B", "C"):
self.printer(name, machine_start_gcode=name * 300)
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
out = self.diff()
self.assertEqual(out.count("<details>"), 2)
self.assertEqual(out.count("</details>"), 2)
self.assertIn("\n1 of 3 sections is not shown", out)
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
self.assertIn("\n3 of 3 sections are not shown", self.diff())
def test_cleanup_removes_the_repo(self):
# setUp's commit leaves read-only git objects, and a read-only directory blocks
# deletion on POSIX.
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
self.t.cleanup()
self.assertFalse(os.path.exists(self.t.dir))
with mock.patch.object(os, "chmod") as chmod:
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
chmod.assert_not_called()
def test_only_repository_variables_are_stripped(self):
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
def test_cleanup_leaves_symlink_targets_alone(self):
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
self.addCleanup(shutil.rmtree, outside, True)
target = os.path.join(outside, "f")
open(target, "w").close()
os.chmod(target, stat.S_IRUSR)
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
machine = self.t.path("V", "machine")
try:
os.symlink(target, os.path.join(machine, "link"))
except (OSError, NotImplementedError):
self.skipTest("cannot create symlinks here")
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
self.t.cleanup()
self.assertFalse(os.path.exists(self.t.dir))
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
def test_a_git_hook_environment_is_ignored(self):
scripts = os.path.join(REPO_ROOT, "scripts")
result = subprocess.run(
[sys.executable, "-m", "unittest",
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
cwd=scripts, capture_output=True, text=True,
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
self.assertEqual(result.returncode, 0, result.stderr)
def test_the_options_are_read_from_the_engine(self):
keys = apt._text_option_keys()
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
"notes", "post_process"} <= keys)
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
def keys_from(self, print_config, tab=None):
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
for path, text in zip(paths, (print_config, tab)):
if text is not None:
with open(path, "w", encoding="utf-8") as f:
f.write(text)
apt._text_option_keys.cache_clear()
self.addCleanup(apt._text_option_keys.cache_clear)
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
mock.patch.object(apt, "TAB_CPP", paths[1]):
return apt._text_option_keys()
def test_multiline_is_read_whatever_its_spacing(self):
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
' def->multiline = true;\n')
self.assertIn("printer_notes", keys)
def test_the_code_options_of_tab_cpp_are_read(self):
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
'option.opt.is_code = true;\n'
'option = optgroup->get_option("layer_height");\n')
self.assertIn("filename_format", keys)
self.assertNotIn("layer_height", keys)
def test_a_missing_tab_cpp_is_not_an_error(self):
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
def test_markdown_in_a_file_name_stays_literal(self):
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
-16
View File
@@ -1126,19 +1126,6 @@ static void load_downward_settings_list_from_config(std::string config_file, std
}
}
#ifdef __WXGTK__
// Virtual GPUs (VirtualBox, VMware, QEMU) cannot back WebKit's hardware DMABUF renderer.
static bool is_hypervisor_guest()
{
std::ifstream cpuinfo("/proc/cpuinfo");
std::string line;
while (std::getline(cpuinfo, line))
if (boost::starts_with(line, "flags"))
return line.find(" hypervisor") != std::string::npos;
return false;
}
#endif
int CLI::run(int argc, char **argv)
{
// Mark the main thread for the debugger and for runtime checks.
@@ -1189,9 +1176,6 @@ int CLI::run(int argc, char **argv)
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
}
if (is_hypervisor_guest() && WebView::SupportsForceSharedMemory())
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
if (x11_opt_in) {
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
+13 -37
View File
@@ -841,12 +841,8 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
iss >> this->value;
return !iss.fail();
}
bool operator==(const ConfigOption &rhs) const override
@@ -1037,12 +1033,8 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
int value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
iss >> this->value;
return !iss.fail();
}
ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1260,12 +1252,8 @@ public:
UNUSED(append);
// don't try to parse the trailing % since it's optional
std::istringstream iss(str);
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
iss >> this->value;
return !iss.fail();
}
private:
@@ -1377,14 +1365,10 @@ public:
bool deserialize(const std::string &str, bool append = false) override
{
UNUSED(append);
std::istringstream iss(str);
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
}
private:
@@ -1554,12 +1538,8 @@ public:
{
UNUSED(append);
char dummy;
Vec2d value;
if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
return false;
this->value = value;
return true;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
}
private:
@@ -1678,12 +1658,8 @@ public:
{
UNUSED(append);
char dummy;
Vec3d value;
if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
return false;
this->value = value;
return true;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
}
private:
+1 -8
View File
@@ -756,14 +756,7 @@ public:
Preset& get_edited_preset() { return m_edited_preset; }
const Preset& get_edited_preset() const { return m_edited_preset; }
// The root of the selected preset's inherits chain, or the selected preset itself when
// the chain names a preset that is not loaded (e.g. a vendor that is not installed).
const Preset& get_selected_preset_base() const
{
const Preset &selected = m_presets[m_idx_selected];
const Preset *base = get_preset_base(selected);
return base ? *base : selected;
}
const Preset& get_selected_preset_base() const { return *get_preset_base(m_presets[m_idx_selected]); }
// Return the last saved preset.
// const Preset& get_saved_preset() const { return m_saved_preset; }
+1 -1
View File
@@ -75,7 +75,7 @@ std::vector<float> Layers::get_zs() const
size_t Layers::get_layer_id_at(float z) const
{
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z > z; });
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z < z; });
return std::distance(m_items.begin(), iter);
}
+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;
}
};
+2 -2
View File
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
m_printer_name_to_preset.clear();
for (const Preset &printer_preset : printer_presets) {
if (!printer_preset.is_visible) continue;
if (preset_bundle->printers.get_preset_base(printer_preset) != &printer_preset) continue;
if (preset_bundle->printers.get_preset_base(printer_preset)->name != printer_preset.name) continue;
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
printer_choice.push_back(from_u8(printer_model->value));
@@ -4962,7 +4962,7 @@ void CreatePresetForPrinterDialog::get_visible_printer_and_compatible_filament_p
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
ConfigOptionStrings *filament_types;
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
if (filament_preset_base == &filament_preset || filament_preset_base == nullptr) {
if (filament_preset_base == &filament_preset) {
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
} else {
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
-22
View File
@@ -62,7 +62,6 @@
#include "Search.hpp"
#include "BitmapCache.hpp"
#include "GUI_App.hpp"
#include "KeyChord.hpp"
#include <nanosvg/nanosvg.h>
#include <nanosvg/nanosvgrast.h>
@@ -2135,27 +2134,6 @@ bool ImGuiWrapper::want_any_input() const
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
}
bool ImGuiWrapper::text_input_active() const
{
const ImGuiID id = ImGui::GetActiveID();
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
}
bool ImGuiWrapper::modal_popup_open() const
{
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
// frame drew it, which NewFrame() moves from Active to WasActive.
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
if (modal == nullptr)
return false;
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
}
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
{
return modal_popup_open() || (chord.edits_text() && text_input_active());
}
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
{
auto item = m_custom_glyph_rects_ids.find(tex_id);
-7
View File
@@ -35,7 +35,6 @@ struct ImRect;
namespace Slic3r {
namespace GUI {
struct KeyChord;
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
@@ -220,12 +219,6 @@ public:
bool want_keyboard() const;
bool want_text_input() const;
bool want_any_input() const;
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
bool text_input_active() const;
// True while an ImGui modal popup is on screen.
bool modal_popup_open() const;
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
bool owns_key(const KeyChord& chord) const;
// Optional inputs are used for set up value inside of an optional, with default value
//
+5 -4
View File
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
using Section = ShortcutSection;
if (wxGetApp().is_editor()) {
page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, {
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
@@ -438,16 +438,17 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
SetBackgroundColour(*wxWHITE);
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
// A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use.
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
const wxString advice = global_key_advice();
const wxString typing = _L("Global shortcuts also apply while typing.");
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms.");
m_rejection = rule + "\n" + advice;
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
int width = FromDIP(450);
for (const wxString& sentence : { rule, advice }) {
for (const wxString& sentence : { typing, rule, advice }) {
int extent = 0;
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
width = std::max(width, extent);
-34
View File
@@ -187,40 +187,6 @@ bool KeyChord::is_menu_accelerator() const
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
}
bool KeyChord::edits_text() const
{
if (!valid())
return false;
if (!is_menu_accelerator())
return true;
const int mods = modifiers & ~wxMOD_SHIFT;
switch (key) {
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
#ifdef __APPLE__
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
#else
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
#endif
case WXK_INSERT:
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
return mods == wxMOD_NONE;
default:
break;
}
if (!is_printable(key))
return false;
#ifdef __APPLE__
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
return true; // Option types a character, Control runs the Cocoa text key bindings
#else
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
return true; // AltGr types a character
#endif
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
}
// Only a chord the desktop acts on while still delivering it to the app belongs here.
bool KeyChord::is_system_shortcut() const
{
-3
View File
@@ -35,9 +35,6 @@ struct KeyChord
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
// swallowing typing in text fields.
bool is_menu_accelerator() const;
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
bool edits_text() const;
// True for a chord the desktop acts on although the app receives it, so a binding would
// take it from the system.
bool is_system_shortcut() const;
+16 -28
View File
@@ -50,7 +50,6 @@
#include <wx/panel.h>
#include <wx/settings.h>
#include <wx/textentry.h>
#include <wx/spinctrl.h>
#include <wx/notebook.h>
#include <wx/listbook.h>
#include <wx/simplebook.h>
@@ -108,8 +107,6 @@
#include "../Utils/PrintHost.hpp"
#include "GUI_App.hpp"
#include "ImGuiWrapper.hpp"
#include "OpenGLManager.hpp"
#include "Shortcuts.hpp"
#include "UnsavedChangesDialog.hpp"
#include "PublishSettingsDialog.hpp"
@@ -187,31 +184,18 @@ enum class ERescaleTarget
namespace {
bool focus_is_text_field(wxWindow* focus)
{
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
return true; // a web page can have its own text fields
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
return true; // the native Windows spin control is not a wxTextEntry
return false;
}
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
{
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
return nullptr;
return wxGetApp().imgui();
}
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
// controls, so only take Space when the focused window has no keyboard-activation meaning of its
// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
// itself does too, so Space still opens the dial on any page.
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
bool focus_keeps_space(wxWindow* focus)
{
if (!focus)
return false;
if (dynamic_cast<wxTextEntryBase*>(focus))
return true; // typing a space into a text field
if (dynamic_cast<wxWebView*>(focus))
return true; // web content scrolls and hosts its own text fields
if (dynamic_cast<::Button*>(focus))
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
if (dynamic_cast<StaticBox*>(focus))
@@ -832,12 +816,16 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
{
// CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
// the keys ImGui owns to the focused canvas.
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame:
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
wxWindow* const focus = wxWindow::FindFocus();
if (chord.edits_text() && focus_is_text_field(focus))
return false;
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
ImGuiWrapper* const imgui = wxGetApp().imgui();
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr)
return false;
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
+1 -8
View File
@@ -427,8 +427,6 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
return m_context;
}
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
{
int antialiasing_samples = 4;
@@ -472,17 +470,12 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
attribList[samples_value_idx] = 0;
}
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS);
// The GL canvas paints its entire surface, so background erasing is unnecessary.
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
return canvas;
}
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
{
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
}
void OpenGLManager::detect_multisample(int* attribList)
{
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
-2
View File
@@ -101,8 +101,6 @@ public:
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
static bool is_scene_canvas(const wxWindow* window);
static const GLInfo& get_gl_info() { return s_gl_info; }
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
+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*/);
+1 -6
View File
@@ -21178,12 +21178,7 @@ void Plater::print_job_finished(wxCommandEvent &evt)
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
if (!dev) return;
const std::string dev_id = evt.GetString().ToStdString();
MachineObject* selected = dev->get_selected_machine();
// After a LAN print, the completion event reselects the same printer.
// Keep the active session while asynchronous status traffic may still be in flight.
if (!selected || selected->get_dev_id() != dev_id || selected->connection_type() != "lan")
dev->set_selected_machine(dev_id);
dev->set_selected_machine(evt.GetString().ToStdString());
p->main_frame->request_select_tab(TAB_ID_MONITOR);
// Selects the status page on a built Device tab; one built by the switch starts there.
MonitorPanel* curr_monitor = MonitorPanel::if_built();
+44 -26
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,32 +1174,53 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
show_status(message, "error");
};
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
// the runtime delivers it later; it is called inline here only when the launch itself failed.
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
if (!alive->load())
return;
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
// error nor a normal success, and do not touch the plugin's error/state.
if (completion.cancelled)
return;
if (!completion.error.empty()) {
complete_with_error(completion.error, wxString());
return;
}
const ExecutionResult& result = completion.result;
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message
<< " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
if (failed) {
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
};
PluginManager::ScriptLaunchResult launch;
{
wxBusyCursor busy;
result = manager.run_script_capability(plugin_key, capability_name, error);
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
}
if (!error.empty()) {
complete_with_error(error, wxString());
return;
}
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
if (failed) {
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
// Every started invocation — a synchronous `def execute` included — completes later through
// `finish`; only a launch failure is reported inline. Return without blocking.
if (!launch.launched_async)
finish(launch.outcome);
}
void PluginsDialog::update_plugin(const std::string& plugin_key)
+1 -1
View File
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
{
m_printer_last_select = "";
update_select_layout(nullptr);
m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
m_printer_box->GetPrinterComboBox()->SetTextLabel("");
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
+1 -1
View File
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
}
else {
m_printer_last_select = "";
m_comboBox_printer->SetValue(_L("No available printers"));
m_comboBox_printer->SetTextLabel("");
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
+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
+1 -23
View File
@@ -74,7 +74,6 @@
#endif
#ifdef __LINUX__
#include "LinuxDisplayBackend.hpp"
#include <wx/textfile.h>
#include <wx/tokenzr.h>
#include <cstdlib>
@@ -202,11 +201,7 @@ TroubleshootDialog::TroubleshootDialog()
return std::vector<wxString>{GetOStype()};
return std::vector<wxString>{
GetOSinfo(),
#ifdef __LINUX__
GetPackageType() + GetAppDisplayBackend(),
#else
GetPackageType(),
#endif
GetCPUinfo(),
GetRAMinfo() + " RAM",
GetGPUinfo(),
@@ -439,15 +434,10 @@ wxString TroubleshootDialog::GetTimestamp()
wxString TroubleshootDialog::GetSysInfoAll()
{
wxString package = GetPackageType();
#ifdef __LINUX__
package += GetAppDisplayBackend();
#endif
wxString info;
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
+ "Build : " + wxString(build_commit_label) + "\n"
+ "Package : " + package + "\n"
+ "Package : " + GetPackageType() + "\n"
+ "Platform : " + GetOSinfo() + "\n"
+ "Processor : " + GetCPUinfo() + "\n"
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
@@ -761,18 +751,6 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
return "";
}
wxString TroubleshootDialog::GetAppDisplayBackend()
{
// Display backend actually used by the app (GDK), not the session type
wxString display;
switch (get_linux_display_backend()) {
case LinuxDisplayBackend::X11: display = "X11"; break;
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
default: break;
}
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
}
#endif
wxString TroubleshootDialog::GetPackageType()
-1
View File
@@ -68,7 +68,6 @@ protected:
#elif defined(__LINUX__)
wxString GetLinuxDistroName();
wxString GetLinuxDisplayServer();
wxString GetAppDisplayBackend();
#endif
wxString GetPackageType();
wxString GetCPUinfo();
+3 -3
View File
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
void ComboBox::ForceDropdownOpen()
{
if (!IsEnabled() || items.empty())
if (!IsEnabled())
return;
if (!drop_down) {
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
SetFocus();
if (drop_down) {
drop.Hide();
} else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
} else if (drop.HasDismissLongTime()) {
drop.autoPosition();
drop_down = true;
drop.Popup(&drop);
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
case WXK_SPACE:
if (drop_down) {
drop.DismissAndNotify();
} else if (!items.empty() && drop.HasDismissLongTime()) {
} else if (drop.HasDismissLongTime()) {
drop.autoPosition();
drop_down = true;
drop.Popup();
+60 -220
View File
@@ -5,7 +5,6 @@
#include "bambu_networking.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/DeviceManager.hpp"
@@ -20,19 +19,14 @@
#include <boost/algorithm/string/trim.hpp>
#include <boost/asio/buffer.hpp>
#include <boost/asio/connect.hpp>
#include <boost/asio/error.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/core/tcp_stream.hpp>
#include <boost/beast/http/field.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/core/error.hpp>
#include <boost/beast/core/buffers_to_string.hpp>
#include <boost/beast/core/stream_traits.hpp>
#include <boost/beast/core/string_type.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/beast/websocket/stream.hpp>
#include <boost/beast/websocket/stream_base.hpp>
@@ -43,7 +37,6 @@
#include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cctype>
@@ -127,31 +120,6 @@ std::string map_moonraker_state(std::string state)
return "IDLE";
}
// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
template<class Initiate>
beast::error_code run_until_done(net::io_context& ioc, const std::atomic<bool>& stop, Initiate&& initiate)
{
// An abort that runs before the operation starts finds nothing to cancel
if (stop.load()) {
return net::error::operation_aborted;
}
beast::error_code result;
bool done = false;
initiate([&](beast::error_code ec, auto&&...) {
result = ec;
done = true;
});
ioc.restart();
while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
return done ? result : beast::error_code{net::error::operation_aborted};
}
uint64_t steady_now_ms()
{
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
}
} // namespace
namespace Slic3r {
@@ -1330,8 +1298,6 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
}
http.timeout_connect(5)
.timeout_max(10)
// Gives up once the status stream, the only caller, stops
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
@@ -1502,47 +1468,19 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
return;
}
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_stop.store(false);
ws_thread = std::thread(
[this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); });
}
void MoonrakerPrinterAgent::stop_status_stream()
{
std::thread stopped;
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_stop.store(true);
if (ws_abort) {
ws_abort();
}
// A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
if (ws_thread.get_id() != std::this_thread::get_id()) {
stopped = std::move(ws_thread);
}
}
if (stopped.joinable()) {
stopped.join();
ws_stop.store(true);
if (ws_thread.joinable()) {
ws_thread.join();
}
}
void MoonrakerPrinterAgent::join_stopped_status_stream()
{
std::thread stopped;
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
// A running stream belongs to a newer connection, so only one that is already stopping is joined
if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
stopped = std::move(ws_thread);
}
}
if (stopped.joinable()) {
stopped.join();
}
}
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key)
{
WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint)) {
@@ -1554,108 +1492,25 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
return;
}
net::io_context ioc;
net::steady_timer backoff{ioc};
std::function<void()> abort_connection; // Set while a connection attempt runs
const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
// Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
if (ws_stop.load()) {
return;
}
ws_abort = [&] {
net::post(ioc, [&] {
backoff.cancel();
if (abort_connection) {
abort_connection();
}
});
};
}
const ScopeGuard clear_abort([this] {
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_abort = nullptr;
});
// Reconnection logic
ws_reconnect_requested.store(false); // Reset reconnect flag
int retry_count = 0;
int retry_count = 0;
const int max_retries = 10;
const int base_delay_ms = 1000;
while (!ws_stop.load()) {
while (!ws_stop.load() && retry_count < max_retries) {
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
tcp::resolver resolver{ioc};
websocket::stream<beast::tcp_stream> ws{ioc};
beast::tcp_stream& stream = beast::get_lowest_layer(ws);
net::steady_timer close_deadline{ioc};
bool closing = false;
bool deadline_pending = false;
auto last_message = std::chrono::steady_clock::now();
// Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
const auto close_politely = [&](websocket::close_code code) {
if (!ws.is_open() || closing) {
return;
}
closing = true;
deadline_pending = true;
close_deadline.expires_after(timings.close);
close_deadline.async_wait([&](beast::error_code ec) {
deadline_pending = false;
if (!ec) {
beast::error_code ignored;
stream.socket().close(ignored);
}
});
ws.async_close(code, [&](beast::error_code) {
closing = false;
close_deadline.cancel();
});
};
abort_connection = [&] {
// Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
if (closing) {
return;
}
if (ws.is_open()) {
close_politely(websocket::close_code::going_away);
} else {
beast::error_code ignored;
stream.socket().close(ignored);
}
};
const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
try {
beast::error_code connect_ec = net::error::host_not_found;
for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
beast::error_code ignored;
stream.socket().close(ignored); // A failed attempt leaves the socket open
stream.expires_after(std::chrono::seconds(5));
connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
if (!connect_ec || ws_stop.load()) {
break;
}
}
if (connect_ec) {
throw beast::system_error{connect_ec};
}
// Clears the connect deadline, which would otherwise close the socket 5 s after connecting
stream.expires_never();
net::io_context ioc;
tcp::resolver resolver{ioc};
beast::tcp_stream stream{ioc};
websocket::stream_base::timeout timeouts{};
timeouts.handshake_timeout = timings.handshake;
timeouts.idle_timeout = timings.idle;
timeouts.keep_alive_pings = true;
ws.set_option(timeouts);
// Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
if (kind == websocket::frame_type::pong && !closing &&
std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
send_heartbeat_if_due(dev_id, timings.heartbeat);
}
});
stream.expires_after(std::chrono::seconds(10));
auto const results = resolver.resolve(endpoint.host, endpoint.port);
stream.connect(results);
websocket::stream<beast::tcp_stream> ws{std::move(stream)};
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
req.set(http::field::user_agent, "OrcaSlicer");
if (!api_key.empty()) {
@@ -1667,9 +1522,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
if (!endpoint.port.empty() && endpoint.port != "80") {
host_header += ":" + endpoint.port;
}
if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
throw beast::system_error{ec};
}
ws.handshake(host_header, endpoint.target);
ws.text(true);
// Send client identification
@@ -1681,10 +1534,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
identify["params"]["type"] = "agent";
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
identify["id"] = 0;
const std::string identify_payload = identify.dump();
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
throw beast::system_error{ec};
}
ws.write(net::buffer(identify.dump()));
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
std::set<std::string> available_objects;
@@ -1736,17 +1586,28 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
}
subscribe["params"]["objects"] = std::move(objects);
subscribe["id"] = 1;
const std::string subscribe_payload = subscribe.dump();
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
throw beast::system_error{ec};
}
ws.write(net::buffer(subscribe.dump()));
// Read loop
while (!ws_stop.load()) {
ws.next_layer().expires_after(std::chrono::seconds(2));
beast::flat_buffer buffer;
const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
if (ws_stop.load()) {
break;
beast::error_code ec;
ws.read(buffer, ec);
if (ec == beast::error::timeout) {
const auto now_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
const auto last_ms = ws_last_emit_ms.load();
if (last_ms == 0 || now_ms - last_ms >= 10000) {
nlohmann::json message;
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
message = build_print_payload_locked();
}
dispatch_message(dev_id, message.dump());
ws_last_emit_ms.store(now_ms);
}
continue;
}
if (ec == websocket::error::closed) {
connection_lost = true;
@@ -1757,9 +1618,6 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
connection_lost = true;
break;
}
last_message = std::chrono::steady_clock::now();
// A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
retry_count = 0;
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
// Check if handle_ws_message triggered reconnection request
if (ws_reconnect_requested.exchange(false)) {
@@ -1767,18 +1625,20 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break;
}
}
} catch (const std::exception& e) {
if (!ws_stop.load()) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
beast::error_code ec;
ws.close(websocket::close_code::normal, ec);
// Only reset retry count on clean shutdown (not connection_lost)
if (!connection_lost && !ws_stop.load()) {
retry_count = 0;
}
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
connection_lost = true;
}
close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
ioc.restart();
// Both handlers refer to this connection, so neither may be left queued for the next one
while ((closing || deadline_pending) && ioc.run_one() != 0) {}
// Exit loop on clean shutdown
if (!connection_lost) {
break;
@@ -1789,13 +1649,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break;
}
// Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
backoff.expires_after(delay);
run([&](auto&& handler) { backoff.async_wait(handler); });
// Exponential backoff before reconnection
int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5));
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")";
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
retry_count++;
}
if (retry_count >= max_retries) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
}
}
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
@@ -1869,7 +1733,8 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
}
if (updated) {
const auto now_ms = steady_now_ms();
const auto now_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
const auto last_dispatch_ms = ws_last_dispatch_ms.load();
// Dispatch if: critical change OR throttle interval elapsed
@@ -1890,24 +1755,6 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
}
}
void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
{
const auto now_ms = steady_now_ms();
const auto last_ms = ws_last_emit_ms.load();
// Re-sends only a status the printer has reported at least once
if (last_ms == 0 || now_ms - last_ms < static_cast<uint64_t>(interval.count())) {
return;
}
nlohmann::json message;
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
message = build_print_payload_locked();
}
dispatch_message(dev_id, message.dump());
ws_last_emit_ms.store(now_ms);
}
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
{
if (!updates.is_object()) {
@@ -2292,15 +2139,8 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
}
join_stopped_status_stream();
// Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
if (is_stale()) {
return;
}
start_status_stream(dev_id, base_url, api_key);
}
// Start WebSocket status stream
start_status_stream(dev_id, base_url, api_key);
#endif
// Success!
+3 -19
View File
@@ -5,7 +5,6 @@
#include "bambu_networking.hpp"
#include <functional>
#include <chrono>
#include <cstdint>
#include <atomic>
#include <memory>
@@ -86,16 +85,6 @@ protected:
bool use_ssl = false;
} device_info;
// Copied when the stream starts, so changes apply to the next start_status_stream()
struct StatusStreamTimings
{
std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
} ws_timings;
// Tray data for AMS payload building
struct AmsTrayData {
int slot_index = 0; // 0-based slot index
@@ -113,8 +102,6 @@ protected:
// Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
// State access for derived classes
mutable std::recursive_mutex state_mutex;
@@ -146,11 +133,10 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload);
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
void join_stopped_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -210,8 +196,6 @@ private:
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread;
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
// Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
+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
+415 -30
View File
@@ -12,11 +12,16 @@
#include "PluginFsUtils.hpp"
#include "PluginHooks.hpp"
#include "PluginAuditManager.hpp"
#include "PyPluginTrampoline.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginBridge.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "libslic3r/Semver.hpp"
#include "pybind11/pytypes.h"
#include "pybind11/gil.h"
#include "pybind11/pybind11.h"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/NotificationManager.hpp"
@@ -41,6 +46,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 +74,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 +109,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 +178,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 +769,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 still follows, so the module is not unloaded
// until the run has settled.
for (const auto& capability : capabilities) {
if (!capability || !capability->is_enabled())
continue;
@@ -769,15 +788,37 @@ 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());
}
// 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 is
// drained without a deadline — forcing module unload while one is outstanding would leave Python
// executing against a freed module, or let a blocking preset selection mutate presets after the
// plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On the GUI
// thread the event loop is pumped so a modal selection 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());
});
if (all_released && !invocation_active)
return;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (!invocation_active && 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) {
@@ -2070,41 +2111,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) {
+101
View File
@@ -15,6 +15,7 @@
#include <memory>
#include <mutex>
#include <optional>
#include "pybind11/pytypes.h"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <string>
@@ -98,6 +99,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 +228,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 +342,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,
+76
View File
@@ -2,10 +2,12 @@
#define slic3r_PyPluginTrampoline_hpp_
#include "libslic3r/LifecycleEvents.hpp"
#include <exception>
#include <pybind11/embed.h>
#include <boost/log/trivial.hpp>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
@@ -14,9 +16,11 @@
#include "PythonInterpreter.hpp"
#include "PluginFsUtils.hpp"
#include "PluginAuditManager.hpp"
#include "pybind11/cast.h"
#include <pybind11/pybind11.h>
#include <pybind11/gil.h>
#include <pybind11/pytypes.h>
#include <utility>
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
@@ -61,6 +65,78 @@
} while (0)
namespace Slic3r {
// True when `object` is a coroutine object (the value produced by calling an `async def`).
// Match inspect.iscoroutine() without importing Python modules inside the plugin audit scope.
// Must be called with the GIL held.
inline bool py_is_coroutine(const pybind11::handle& object)
{
return object && PyCoro_CheckExact(object.ptr());
}
// 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:
-13
View File
@@ -584,19 +584,6 @@ bool PythonInterpreter::initialize()
}
BOOST_LOG_TRIVIAL(debug) << "Program name set successfully";
// Keep bytecode out of the Python home. The shipped .pyc files are stale against their
// sources, so a writable install would rewrite them inside the app (breaking a signed
// macOS bundle's seal) and a read-only one would recompile them on every launch.
const std::string pycache_prefix = data_dir() + "/python/pycache";
#ifdef _WIN32
status = PyConfig_SetString(&config, &config.pycache_prefix, boost::nowide::widen(pycache_prefix).c_str());
#else
status = PyConfig_SetBytesString(&config, &config.pycache_prefix, pycache_prefix.c_str());
#endif
if (PyStatus_Exception(status))
BOOST_LOG_TRIVIAL(warning) << "Failed to set the Python bytecode cache to " << pycache_prefix << ": "
<< (status.err_msg ? status.err_msg : "unknown error");
// Use pybind11's scoped_interpreter which properly initializes
// pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig
// does not set up thread state tracking that pybind11 requires.
+683 -3
View File
@@ -2,6 +2,7 @@
#include "PluginHostUi.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include <memory>
#include <pybind11/pytypes.h>
#include <pybind11/cast.h>
#include <pybind11/pybind11.h>
@@ -11,18 +12,21 @@
#include <libslic3r/Preset.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <memory>
#include <slic3r/GUI/GUI.hpp>
#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 <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace py = pybind11;
@@ -48,6 +52,543 @@ 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 (pybind11 maps the base to RuntimeError), so callers see it as an ordinary
// failed transaction and no staged setting or slot assignment is created.
struct SelectionCancelled : std::runtime_error
{
using std::runtime_error::runtime_error;
};
// --------------------------------------------------------------------------
// 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
// between begin_update() and commit() is rejected instead of silently editing the new mapping.
//
// begin_update() is synchronous: it runs on the background Script worker, marshals the target
// selection to the UI thread with PluginHostUi::run_on_ui_thread(), and blocks that worker until
// the selection and any unsaved-edit dialog settle. The UI thread stays responsive because the
// Script capability owns its worker thread. No GUI or preset pointer outlives the call; the
// returned transaction holds copied category/name/slot values. The update() shorthand shares the
// same target resolution and 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.
// * A user-declined selection does not undo decisions the user already accepted in an earlier
// dialog. It aborts before any slot assignment or transaction is created, so no staged
// setting is applied.
// --------------------------------------------------------------------------
// 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, called from begin_preset_update().
void bind_preset_target(Preset::Type type, const std::string& preset_name, PresetTransaction& transaction)
{
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");
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 begin_update() and update().
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, called from begin_filament_update().
void bind_filament_target(int slot_number, const std::string& preset_name, PresetTransaction& transaction)
{
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");
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) {
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 begin_update() and update().
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();
}
void reload_local_bundle(const std::string& bundle_id)
{
bool reloaded = false;
@@ -100,7 +641,7 @@ std::vector<std::string> list_bundle_ids()
void host_bindings::register_presets(py::module_& host)
{
auto preset = host.def_submodule("preset", "Preset operations");
auto preset = host.def_submodule("preset", "Preset operations");
auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
"Reload one local preset bundle from the active user's _local directory.");
@@ -119,6 +660,145 @@ 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 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() blocks the "
"background Script worker until any selection or unsaved-edit dialog is answered, "
"while the UI thread stays responsive; set() and commit() run synchronously on the "
"same 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(editor.type, preset_name);
},
py::arg("preset_name") = "",
"Resolve and bind this category's target preset and return 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 call blocks "
"the background Script worker until any such dialog is answered, leaving the UI thread responsive. A "
"selection the user cancels raises RuntimeError; an unavailable or invalid target raises ValueError. "
"Either failure creates no transaction and stages no setting. Call it from the background Script worker, "
"never from the UI thread. 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(slot_number, preset_name);
},
py::arg("slot_number"), py::arg("preset_name") = "",
"Resolve and bind a filament preset by 1-based slot and return 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 "
"RuntimeError 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. The call "
"blocks the background Script worker until the selection settles, leaving the UI thread responsive; call "
"it from that worker, never from the UI thread.")
.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 begin_update() with the active preset as target, then set(key, value) "
"and commit(); like begin_update() this call blocks the background Script 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 begin_update(slot_number) with an omitted preset_name "
"(the slot's current assignment is kept), then set(key, value) and commit(); like "
"begin_update() this call blocks the background Script 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)
-49
View File
@@ -1263,55 +1263,6 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
}
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
using Catch::Matchers::WithinAbs;
SECTION("float") {
ConfigOptionFloat opt(7.5);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
}
SECTION("int") {
ConfigOptionInt opt(3);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK(opt.value == 3);
}
SECTION("percent") {
ConfigOptionPercent opt(40.);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
}
SECTION("float or percent") {
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
CHECK_FALSE(opt.percent);
}
SECTION("point") {
ConfigOptionPoint opt(Vec2d(1., 2.));
REQUIRE_FALSE(opt.deserialize("5,abc"));
CHECK(opt.value == Vec2d(1., 2.));
}
SECTION("point3") {
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
CHECK(opt.value == Vec3d(1., 2., 3.));
}
}
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
using Catch::Matchers::WithinAbs;
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE(opt.deserialize("50%"));
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
CHECK(opt.percent);
ConfigOptionPoint point;
REQUIRE(point.deserialize("3x4"));
CHECK(point.value == Vec2d(3., 4.));
}
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys;
@@ -1219,24 +1219,6 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
}
TEST_CASE("The selected preset is its own base when its parent is not loaded", "[Preset]")
{
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
add_inmemory_preset(printers, "Printer");
add_inmemory_preset(printers, "User Printer", "Printer");
add_inmemory_preset(printers, "Orphan Printer", "Uninstalled Vendor Printer");
printers.select_preset_by_name("User Printer", true);
REQUIRE(printers.get_selected_preset_name() == "User Printer");
CHECK(printers.get_selected_preset_base().name == "Printer");
printers.select_preset_by_name("Orphan Printer", true);
REQUIRE(printers.get_selected_preset_name() == "Orphan Printer");
REQUIRE(printers.get_preset_base(printers.get_selected_preset()) == nullptr);
CHECK(printers.get_selected_preset_base().name == "Orphan Printer");
}
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
{
ScopedTemporaryDir temp_dir;
+1 -1
View File
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_creality_cfs_match.cpp
test_dev_mapping.cpp
test_device_access.cpp
test_moonraker_printer_agent.cpp
test_moonraker_discovery.cpp
test_network_discovery.cpp
test_filament_bitmap_utils.cpp
test_colour_picker_field.cpp
+3 -105
View File
@@ -1,7 +1,6 @@
// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
// as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
// ahead of it).
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
@@ -18,15 +17,12 @@
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <imgui/imgui.h>
#include <imgui/imgui_internal.h>
#include <wx/event.h>
#include "slic3r/GUI/ImGuiWrapper.hpp"
#include "slic3r/GUI/KeyChord.hpp"
using Catch::Matchers::WithinAbs;
using Slic3r::GUI::ImGuiWrapper;
using Slic3r::GUI::KeyChord;
namespace {
@@ -45,45 +41,6 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
return ImGui::GetIO().MouseWheel;
}
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
struct ImGuiFrames
{
ImGuiWrapper imgui;
ImGuiFrames()
{
imgui.set_display_size(800.f, 600.f);
unsigned char* pixels = nullptr;
int width = 0, height = 0;
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
}
template<class Draw> void frame(Draw draw)
{
ImGui::NewFrame();
draw();
ImGui::EndFrame();
}
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
void draw_input(bool focus)
{
ImGui::Begin("window");
if (focus)
ImGui::SetKeyboardFocusHere();
ImGui::InputText("##text", text, sizeof(text));
ImGui::End();
}
char text[16] = "";
};
void draw_modal()
{
if (ImGui::BeginPopupModal("modal"))
ImGui::EndPopup();
}
} // namespace
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
@@ -102,62 +59,3 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
}));
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
}
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
{
ImGuiFrames f;
f.frame([&] { f.draw_input(false); });
CHECK_FALSE(f.imgui.text_input_active());
f.frame([&] { f.draw_input(true); });
f.frame([&] { f.draw_input(false); });
CHECK(f.imgui.text_input_active());
f.frame([] {});
f.frame([] {});
CHECK_FALSE(f.imgui.text_input_active());
}
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
{
ImGuiFrames f;
CHECK_FALSE(f.imgui.modal_popup_open());
f.frame([] {
ImGui::OpenPopup("modal");
draw_modal();
});
CHECK(f.imgui.modal_popup_open());
// A frame opened by input, before anything is drawn in it.
ImGui::NewFrame();
CHECK(f.imgui.modal_popup_open());
draw_modal();
ImGui::EndFrame();
CHECK(f.imgui.modal_popup_open());
// A frame that no longer draws it, as when its canvas is hidden.
f.frame([] {});
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
CHECK_FALSE(f.imgui.modal_popup_open());
}
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
{
ImGuiFrames f;
const KeyChord letter{ 'A' };
const KeyChord save{ 'S', wxMOD_CONTROL };
CHECK_FALSE(f.imgui.owns_key(letter));
CHECK_FALSE(f.imgui.owns_key(save));
f.frame([&] { f.draw_input(true); });
f.frame([&] { f.draw_input(false); });
CHECK(f.imgui.owns_key(letter));
CHECK_FALSE(f.imgui.owns_key(save));
f.frame([] {
ImGui::OpenPopup("modal");
draw_modal();
});
CHECK(f.imgui.owns_key(save));
}
@@ -0,0 +1,51 @@
#include <catch2/catch_test_macros.hpp>
#include <string>
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
using namespace Slic3r;
namespace {
class DiscoveryAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::device_info;
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
return true;
}
};
} // namespace
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
{
DiscoveryAgent agent("");
unsigned announcements = 0;
nlohmann::json announcement;
agent.set_on_ssdp_msg_fn([&](std::string payload) {
++announcements;
announcement = nlohmann::json::parse(payload);
});
agent.start_discovery(true, false);
REQUIRE(announcements == 0);
REQUIRE(agent.fetch_count == 0);
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
}
@@ -1,580 +0,0 @@
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <boost/asio/buffer.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/beast/core/error.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/http/message.hpp>
#include <boost/beast/http/read.hpp>
#include <boost/beast/http/status.hpp>
#include <boost/beast/http/string_body.hpp>
#include <boost/beast/http/write.hpp>
#include <boost/beast/websocket/error.hpp>
#include <boost/beast/websocket/rfc6455.hpp>
#include <boost/beast/websocket/stream.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/IPrinterAgent.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
using namespace Slic3r;
namespace {
namespace beast = boost::beast;
namespace http = beast::http;
namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
using namespace std::chrono_literals;
class DiscoveryAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::device_info;
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
return true;
}
};
class StreamAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::start_status_stream;
using MoonrakerPrinterAgent::stop_status_stream;
using MoonrakerPrinterAgent::ws_timings;
protected:
bool init_device_info(
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
{
device_info = MoonrakerDeviceInfo{};
device_info.dev_id = dev_id;
device_info.base_url = "http://" + dev_ip + ":" + port;
return true;
}
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
info.dev_name = "Test printer";
return true;
}
};
enum class Behavior {
SilentBeforeHandshake, // Accepts the connection and never answers
Silent, // Completes the upgrade, then never reads or sends
AnswersPings, // Reports a status, then answers pings and sends nothing else
AnswersPingsWithoutStatus, // Answers pings but never reports a status
Chatters, // Reports a status, then answers pings and sends notifications that carry no status
HangsUp, // Reports a status, then closes the websocket
HangsUpWithoutAnswer, // Closes the websocket without sending anything
IgnoresClose, // Reports Klippy disconnected, then never answers the close
StallsObjectList, // Never answers the stream's object list request
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
};
class FakePrinter
{
public:
explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
{
m_accept_thread = std::thread([this] { accept_loop(); });
}
~FakePrinter()
{
release();
m_stopping = true;
// Wakes the blocking accept.
tcp::socket wake{m_accept_ioc};
beast::error_code ec;
wake.connect(m_acceptor.local_endpoint(), ec);
m_accept_thread.join();
for (std::thread& connection : m_connections)
connection.join();
}
std::string host() const { return "127.0.0.1"; }
std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
std::string base_url() const { return "http://" + host() + ":" + port(); }
// A connection is ready once it reaches the state its behavior waits in.
bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
}
bool wait_for_http_requests(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
}
// Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
bool wait_for_http_hang_ups(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
}
int close_frames()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_close_frames;
}
// Hangs up every connection that is waiting.
void release()
{
std::lock_guard<std::mutex> lock(m_mutex);
m_released = true;
m_changed.notify_all();
}
private:
void accept_loop()
{
for (;;) {
// Each connection gets its own io_context, so a connection that runs one never runs another's handlers.
auto ioc = std::make_unique<net::io_context>();
tcp::socket socket{*ioc};
beast::error_code ec;
m_acceptor.accept(socket, ec);
if (ec || m_stopping)
return;
m_connections.emplace_back(
[this, ioc = std::move(ioc), socket = std::move(socket)]() mutable { serve(*ioc, std::move(socket)); });
}
}
void serve(net::io_context& ioc, tcp::socket socket)
{
if (m_behavior == Behavior::SilentBeforeHandshake) {
mark_ready();
wait_for_release();
return;
}
beast::flat_buffer buffer;
http::request<http::string_body> request;
beast::error_code ec;
http::read(socket, buffer, request, ec);
if (ec)
return;
// The agent also queries the printer's status and object list over plain HTTP.
if (!websocket::is_upgrade(request)) {
serve_http(socket, request);
return;
}
websocket::stream<tcp::socket> ws{std::move(socket)};
ws.accept(request, ec);
// Without its object list the stream never sends the subscribe request.
const int requests = m_behavior == Behavior::StallsObjectList ? 1 : 2;
for (int i = 0; i < requests && !ec; ++i) {
beast::flat_buffer message;
ws.read(message, ec);
}
if (!ec && (m_behavior == Behavior::AnswersPings || m_behavior == Behavior::Chatters || m_behavior == Behavior::HangsUp)) {
const std::string status =
nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"result", {{"status", {{"print_stats", {{"state", "standby"}}}}}}}}.dump();
ws.write(net::buffer(status), ec);
}
if (ec)
return;
switch (m_behavior) {
case Behavior::AnswersPings:
case Behavior::AnswersPingsWithoutStatus:
case Behavior::Chatters:
mark_ready();
answer_pings(ioc, ws, m_behavior == Behavior::Chatters);
break;
case Behavior::HangsUp:
case Behavior::HangsUpWithoutAnswer:
ws.close(websocket::close_code::normal, ec);
mark_ready();
break;
case Behavior::IgnoresClose: {
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"}, {"method", "notify_klippy_disconnected"}}.dump();
ws.write(net::buffer(notification), ec);
mark_ready();
wait_for_release();
break;
}
case Behavior::StallsObjectList: answer_pings(ioc, ws, false); break;
default: mark_ready(); wait_for_release();
}
}
void serve_http(tcp::socket& socket, const http::request<http::string_body>& request)
{
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_requests;
m_changed.notify_all();
}
if (m_behavior == Behavior::StallsObjectList) {
mark_ready();
wait_for_release();
return;
}
if (m_behavior == Behavior::StallsStatusQuery) {
wait_for_release();
beast::error_code ec;
socket.close(ec);
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_hung_up;
m_changed.notify_all();
return;
}
http::response<http::string_body> response{http::status::not_found, request.version()};
response.prepare_payload();
beast::error_code ec;
http::write(socket, response, ec);
}
// Keeps a read pending, which answers pings, until released or closed, and with `chatter` also sends a notification every 50 ms.
void answer_pings(net::io_context& ioc, websocket::stream<tcp::socket>& ws, bool chatter)
{
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"},
{"method", "notify_proc_stat_update"},
{"params", nlohmann::json::array({nlohmann::json::object()})}}
.dump();
beast::flat_buffer message;
bool reading = true;
bool writing = false;
std::function<void()> read_next = [&] {
ws.async_read(message, [&](beast::error_code ec, std::size_t) {
message.clear();
if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames;
}
if (ec)
reading = false;
else
read_next();
});
};
read_next();
auto next_notification = std::chrono::steady_clock::now();
while (reading && !wait_for_release(10ms)) {
if (chatter && !writing && std::chrono::steady_clock::now() >= next_notification) {
writing = true;
next_notification += 50ms;
ws.async_write(net::buffer(notification), [&](beast::error_code, std::size_t) { writing = false; });
}
ioc.restart();
ioc.run_for(20ms);
}
beast::error_code ignored;
ws.next_layer().close(ignored);
while (reading || writing) {
ioc.restart();
ioc.run_for(20ms);
}
}
void mark_ready()
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_ready;
m_changed.notify_all();
}
void wait_for_release()
{
std::unique_lock<std::mutex> lock(m_mutex);
m_changed.wait(lock, [this] { return m_released; });
}
bool wait_for_release(std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [this] { return m_released; });
}
const Behavior m_behavior;
net::io_context m_accept_ioc;
tcp::acceptor m_acceptor{m_accept_ioc, {net::ip::make_address("127.0.0.1"), 0}};
std::atomic<bool> m_stopping{false};
std::thread m_accept_thread;
std::vector<std::thread> m_connections;
std::mutex m_mutex;
std::condition_variable m_changed;
int m_ready = 0;
int m_http_requests = 0;
int m_http_hung_up = 0;
int m_close_frames = 0;
bool m_released = false;
};
// Records when the agent dispatches a status report.
class Reports
{
public:
void attach(StreamAgent& agent)
{
agent.set_on_local_message_fn([this](std::string, std::string) {
std::lock_guard<std::mutex> lock(m_mutex);
m_times.push_back(std::chrono::steady_clock::now());
m_changed.notify_all();
});
}
bool wait_for(std::size_t count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_times.size() >= count; });
}
std::vector<std::chrono::steady_clock::time_point> times()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_times;
}
private:
std::mutex m_mutex;
std::condition_variable m_changed;
std::vector<std::chrono::steady_clock::time_point> m_times;
};
// A printer and a stream agent connected to it.
struct StreamFixture
{
explicit StreamFixture(Behavior behavior) : printer(behavior) {}
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
~StreamFixture() { printer.release(); }
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
bool stops_within(std::chrono::milliseconds limit)
{
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
printer.release();
stopped.wait();
return in_time;
}
FakePrinter printer;
StreamAgent agent{""};
};
} // namespace
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerPrinterAgent][Discovery]")
{
DiscoveryAgent agent("");
unsigned announcements = 0;
nlohmann::json announcement;
agent.set_on_ssdp_msg_fn([&](std::string payload) {
++announcements;
announcement = nlohmann::json::parse(payload);
});
agent.start_discovery(true, false);
REQUIRE(announcements == 0);
REQUIRE(agent.fetch_count == 0);
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
}
TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
{
// An open websocket gets the 200 ms close timeout, and a stalled object list request up to 1 s of curl progress to see the stop.
const auto [behavior, limit] = GENERATE(table<Behavior, std::chrono::milliseconds>({
{Behavior::SilentBeforeHandshake, 500ms},
{Behavior::Silent, 1000ms},
{Behavior::StallsObjectList, 2000ms},
}));
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.agent.ws_timings.close = 200ms;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(limit));
}
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
{
// A stop while the stream reads, and one while it waits on the object list request.
const Behavior behavior = GENERATE(Behavior::AnswersPings, Behavior::StallsObjectList);
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s));
CHECK(f.printer.close_frames() == 1);
}
TEST_CASE("Stopping the status stream does not wait out the reconnect delay", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 10s;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// Lets the stream reach its reconnect wait.
std::this_thread::sleep_for(200ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects when the printer stops answering", "[MoonrakerPrinterAgent][StatusStream]")
{
// An unanswered close ends at whichever of the handshake and close timeouts is shorter.
const auto [behavior, handshake, close] = GENERATE(table<Behavior, std::chrono::milliseconds, std::chrono::milliseconds>({
{Behavior::Silent, 300ms, 1000ms},
{Behavior::IgnoresClose, 300ms, 1000ms},
{Behavior::IgnoresClose, 1000ms, 200ms},
}));
CAPTURE(static_cast<int>(behavior), handshake.count(), close.count());
StreamFixture f(behavior);
f.agent.ws_timings.handshake = handshake;
f.agent.ws_timings.close = close;
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.reconnect_delay = 20ms;
f.start();
CHECK(f.printer.wait_for_ready_connections(2));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects at the shortest delay while the printer answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 50ms;
f.start();
// Twelve 50 ms waits; a backoff that kept doubling would need over 10 s.
CHECK(f.printer.wait_for_ready_connections(12, 3s));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream keeps retrying a printer that never answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUpWithoutAnswer);
f.agent.ws_timings.reconnect_delay = 1ms;
f.start();
// The backoff stops doubling at 32 ms, so 20 attempts take well under the wait.
CHECK(f.printer.wait_for_ready_connections(20));
CHECK(f.stops_within(3s));
}
TEST_CASE("A callback on the stream thread can stop the status stream", "[MoonrakerPrinterAgent][StatusStream]")
{
std::promise<void> stopped;
std::once_flag stopped_once;
StreamFixture f(Behavior::AnswersPings);
f.agent.set_on_local_message_fn([&](std::string, std::string) {
std::call_once(stopped_once, [&] {
f.agent.stop_status_stream();
stopped.set_value();
});
});
f.start();
REQUIRE(stopped.get_future().wait_for(5s) == std::future_status::ready);
CHECK(f.stops_within(3s));
}
TEST_CASE("A quiet printer that is still up keeps reporting its status", "[MoonrakerPrinterAgent][StatusStream]")
{
Reports reports;
StreamFixture f(Behavior::AnswersPings);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 300ms;
reports.attach(f.agent);
f.start();
// The status the printer sent, then two heartbeats.
REQUIRE(reports.wait_for(3));
const auto times = reports.times();
// The steady clock is compared in whole milliseconds, so allow 1 ms of rounding.
CHECK(times[1] - times[0] >= 299ms);
CHECK(times[2] - times[1] >= 299ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream sends no heartbeat while the printer has nothing new to report", "[MoonrakerPrinterAgent][StatusStream]")
{
const Behavior behavior = GENERATE(Behavior::Chatters, Behavior::AnswersPingsWithoutStatus);
CAPTURE(static_cast<int>(behavior));
Reports reports;
StreamFixture f(behavior);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 800ms;
reports.attach(f.agent);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// The chattering printer's status, and nothing from the one that never sent a status.
const std::size_t expected = behavior == Behavior::Chatters ? 1 : 0;
std::this_thread::sleep_for(1500ms);
CHECK(reports.times().size() == expected);
CHECK(f.stops_within(3s));
}
TEST_CASE("A disconnect during the connection queries keeps the status stream from starting", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::StallsStatusQuery);
PrinterConnectionParams params;
params.dev_id = "printer";
params.host = f.printer.host();
params.port = f.printer.port();
f.agent.connect_printer(params);
REQUIRE(f.printer.wait_for_http_requests(1));
f.agent.disconnect_printer();
f.printer.release();
// The query has returned once the printer hangs up on it, and a stream would connect right after.
REQUIRE(f.printer.wait_for_http_hang_ups(1));
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
}
+158
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,143 @@ 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");
CHECK(has_attr(transaction_type, "set"));
CHECK(has_attr(transaction_type, "commit"));
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");
+2 -16
View File
@@ -615,22 +615,8 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
// Ensure the next import executes the new submodule rather than reusing a stale package child.
write_package("'new'");
{
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
// interpreter caches it.
PythonGILState gil;
REQUIRE(gil);
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
REQUIRE(util);
for (const char* file : {"__init__.py", "sub.py"}) {
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
REQUIRE(source);
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
REQUIRE(cached);
boost::system::error_code ec;
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
}
}
boost::system::error_code ec;
fs::remove_all(package_root / "__pycache__", ec);
paths.clear();
modules.clear();
-64
View File
@@ -126,55 +126,6 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
}
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
{
CHECK(KeyChord{ 'A' }.edits_text());
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_SPACE }.edits_text());
CHECK(KeyChord{ WXK_BACK }.edits_text());
CHECK(KeyChord{ WXK_DELETE }.edits_text());
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_HOME }.edits_text());
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RETURN }.edits_text());
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
CHECK_FALSE(KeyChord{}.edits_text());
#ifdef __APPLE__
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#else
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#endif
}
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
{
#ifdef _WIN32
@@ -235,21 +186,6 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
}
}
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
{
ShortcutRegistry registry;
size_t checked = 0;
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
const ShortcutInfo& info = shortcut_info(Shortcut(i));
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
continue;
CAPTURE(info.key);
CHECK_FALSE(registry.binding(info.id).edits_text());
++checked;
}
CHECK(checked > 0);
}
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
{
ShortcutRegistry registry;