# Introducing a Python Plugin System (WIP)
This PR opens up a way to extend OrcaSlicer with **Python plugins** —
small scripts (or full wheels) that run inside an embedded CPython
interpreter, without anyone having to fork the app or touch the C++
core.
I'm putting this up **early and on purpose**. It works end-to-end today,
but it is not finished and the public surface is deliberately small.
Before we lock anything in, we want the community's opinions on the
three decisions that are hard to reverse later: **what API we expose,
which plugin types we invest in, and how the security/audit layer should
behave.** Consider this a request for comments more than a merge
candidate.
## Why
People keep wanting to bolt their own behavior onto the slicer — custom
G-code post-processing, automation, bespoke printer/host integrations,
one-off analysis. Today that means maintaining a patched fork. The goal
here is a *sanctioned* extension path: a stable, documented seam where a
plugin can hook into a specific point in OrcaSlicer's workflow, with a
clear boundary around what plugin code is allowed to do.
## What's in this PR
**An embedded Python runtime.** A single CPython interpreter is started
once (intended to be on the main thread), with proper GIL handoff so
plugin code can run from worker threads. Plugin `stderr` (including
tracebacks from threads a plugin spawns) is persisted to
`data_dir()/log/python_*.log`.
**One API module, `orca`.** This is the surface a plugin sees. It
exposes the plugin base classes, the `@orca.plugin` decorator and
`register_capability()`, a typed `ExecutionResult`, and the
`PluginType`/`PluginResult` enums, along with per-type base classes
under `orca.gcode` / `orca.script` / `orca.printer_agent`. A host
bridge, `orca.host`, provides **read-only** access to the current model
and preset/config values, plus interactive `host.plater()` and `host.ui`
helpers (messages, dialogs, windows, progress). There is deliberately no
*write* access to slicer models or config, and no general GUI/toolkit
access beyond these host helpers. The exact shape of `orca.host` is one
of the things we most want feedback on.
**Three plugin types to start:**
- `post-processing` — runs during G-code export and receives the G-code
path + output context.
- `script` — a manual "Run" action from the Plugins dialog.
- `printer-connection` — a Python "printer agent" that registers into
the network layer on load. This is still WIP, along with a printer agent
workflow that is also WIP.
(The `PluginType` enum reserves several more names — Automation,
Analysis, Importer, Exporter, Visualization — but only the three above
are wired up.)
**Two packaging forms:** a single `.py` file with [PEP
723](https://peps.python.org/pep-0723/) inline metadata, or a `.whl`
wheel (with third-party dependencies installed via a bundled `uv`).
**Discovery, install, and a Plugins dialog** — local side-loading plus a
cloud subscription service, catalog/loader lifecycle, and per-plugin
error reporting in the UI.
**Audit-hook groundwork (PEP 578).** Every C++→Python call opens a
per-call audit context, and a CPython audit hook filters filesystem
access against a write allow-list (`data_dir()`, plus scoped roots like
the current G-code folder). This is *groundwork, not a sandbox* — see
Limitations.
**Docs.** Substantially complete author and contributor guides live
under `docs/plugins/` (development guide, security/audit deep-dive,
architecture overview, worked examples); the *feature* is what's WIP,
not the docs.
## Orca Cloud integration
Plugins are **fully integrated with Orca Cloud**, distributed in a
similar way to preset bundles — so this builds directly on the cloud
foundation rather than bolting on a separate mechanism.
- **Subscribe, don't side-load.** Instead of manually copying files, you
subscribe to a plugin from the cloud and OrcaSlicer pulls it down and
loads it for you — the same one-click experience as preset bundles.
- **Tied to your account, synced across machines.** Subscribed plugins
live under your user (`orca_plugins/_subscribed/<user_id>/`) and follow
you to any machine you're signed in on, exactly like your presets. Sign
out and the cloud plugins are unloaded; sign back in and they're
restored.
- **Stays up to date.** When a new version is published, OrcaSlicer can
fetch and install the update rather than leaving you on a stale copy.
- **Managed from the Plugins dialog.** Browse, install, update, and
unsubscribe live alongside local side-loading — which still works for
development and private plugins.
- **Integrated with presets.** Plugin references travel with a preset
bundle (via the preset's `plugins` fields), so publishing a preset that
uses plugins carries those references through the existing cloud sync.
If a referenced plugin is missing when you install the bundle on another
machine, OrcaSlicer **offers to install** the missing plugins for you (a
one-click prompt), provided those plugins are on the cloud.
## Where we need feedback
Really any form of feedback would be helpful; we'd rather grow this
slowly from real use cases than expose internals we can't keep stable —
which is why the current surface is kept small. The `orca.host` API in
particular is where we'd most value opinions.
## Limitations / known gaps (it's WIP)
- **The audit hook is not a sandbox.** It currently enforces only the
`open` event's writes, and only for string paths (fd/bytes opens are not
checked). `subprocess`, sockets, `ctypes`, `os.open`, and non-`open`
filesystem mutations (`os.remove`/`rename`/`mkdir`) are **not** blocked
yet. An `Enforcing` mode is stubbed but not yet wired, so today all
calls run in the writes-only "loading" mode. More details can be found
[here](https://www.orcaslicer.com/wiki/developer_reference/plugin_development/plugin_audit_hook.html#limitations).
- **The `orca` API is unstable** and will change based on this
discussion. Don't build anything load-bearing on it yet.
- The `requires-python` field is parsed but not enforced.
- Dependency install and some of the Plugins dialog UX are functional
but still rough around the edges.
## Docs
[How to
Use](https://www.orcaslicer.com/wiki/plugins/getting_started.html)
[Developer
Reference](https://www.orcaslicer.com/wiki/developer_reference/plugin_development/plugin_system.html)
## Software Development Kit
Currently, there is a script `generate_orca_python_stubs.py` to generate
the `.pyi` files that can be used for intellisense. We will release the
stub file as an SDK in future releases, but for now, if you intend to
develop plugins, you can generate the stub files locally.
## Notes
This system was developed primarily on Windows and Linux; testing on
macOS has so far been limited. macOS-specific behavior — the bundled
Python/`uv` runtime, path handling, and the audit hook — is the most
likely to need attention, and feedback or testing from macOS users is
especially welcome.
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
## Orca Cloud to OrcaSlicer Plugins Workflow Overview:
https://github.com/user-attachments/assets/abbd7900-3062-4e33-8f77-5d30d567be1d
# Description
Use Space to trigger a new **speed dial**, which allows users to run
**app actions**. The only app actions implemented currently are python
plugin scripts.
## Notes
- Only toggleable in the Prepare (3D) view. Press Space, type to filter,
Enter or double-click to run.
- Focuses on search bar automatically
- Frecency-sorted (run count + recency) with alphabetical fallback (C++
computes)
- Pin actions as favourites and they will show on the top favourites
bar. Done through star icon on each row.
- Script plugins have no icon art yet, so tiles show a collision-aware
monogram: the capability's initial, escalating only when names collide -
prepend the package initial, then add an ordinal - so same-named actions
from different plugins stay distinguishable.
- E.g., capability Bravo from plugin Alpha normally shows just B. If
another action's name also starts with B, they disambiguate by
prepending the package initial (Alpha -> AB). If two still collide on
both initials (both AB), they become AB1 and AB2.
- "Run X?" confirm with a per-plugin "don't ask again" scope, owned
C++-side; suppression persists.
- Persistence (`speed_dial` AppConfig section): `favourite_actions`
(ordered id list) + per-action `stats` + `ask_suppressed`.
- Example of shape in data_dir:
```json
{
"speed_dial": {
"ask_suppressed": "[\"9b12aa079924bbc4\"]",
"favourite_actions": "[\"ccfdf8b9e492b624\",\"9b12aa079924bbc4\",\"b7abfa67626248e4\"]",
"stats": "{\"31d9d129a616a8b7\":{\"count\":4,\"last\":1783924989},\"53ec17d430634f62\":{\"count\":3,\"last\":1783939329},\"9b12aa079924bbc4\":{\"count\":5,\"last\":1783924981},\"9f2cb0d3ca56a87c\":{\"count\":1,\"last\":1783668370},\"b7abfa67626248e4\":{\"count\":4,\"last\":1783939325},\"f93469da14248128\":{\"count\":3,\"last\":1783939337}}"
},
}
```
# Screenshots/Recordings/Graphs
<img width="688" height="335" alt="image"
src="https://github.com/user-attachments/assets/683efa3b-9401-4977-a347-d70193188165"
/>
<img width="681" height="172" alt="image"
src="https://github.com/user-attachments/assets/fafb4965-054b-4fd5-ad6a-03145264fbe0"
/>
<img width="683" height="335" alt="image"
src="https://github.com/user-attachments/assets/00d5bd49-95c0-4f2f-966b-6c04c14c3cf3"
/>
## Tests
- **Web layer (green):** node-vm logic test `test-speeddial-logic.js`
covers `filterActions`, `visibleFavourites` (incl. the runnable guard),
`selectedActionId`, `resultCountText`, `actionLabel`, `tileCode`,
`nextSel`, and payload seeding - pure helpers, DOM-free.
- **Backend:** `ActionRegistry` FNV-1a id golden-vector Catch2 test
(`test_speed_dial_action_id`) pins the hash; the registry was verified
by fresh-context review including a threading fix (`run()` operates on a
stack copy so a queued refresh can't reallocate the action vector
mid-run).
- **Manual (all passing):**
- Space opens the dial in Prepare only; no regression to existing
Prepare-tab keys or the Plugins dialog.
- Search auto-focuses; typing filters live; a freshly-run action rises
in the frecency order.
- Up jumps to the favourites bar, Down into the list, Alt+1..9 hits
favourites; Enter and double-click run the highlighted action.
- Star pins/unpins an action; favourites persist across an app restart.
- "Run X?" confirm with per-plugin "don't ask again" is respected on
later runs.
- The `?` shortcuts dialog shows the Space row.
- Verified in both light and dark themes.
## Known Issues
When there are no actions, plugins, or scripts, the search bar will show
"Search 0 actions". This is bad UX. One alternative considered was to
show a call to action, for example "Please load plugin scripts so that
they appear here".
However, this is ultimately not implemented, as eventually it is not
expected that actions will be empty. The action registry will not be
expected to be empty because we will include in-app actions such as
opening dialogues or other app actions.
<img width="722" height="117" alt="image"
src="https://github.com/user-attachments/assets/a4b9c0db-b2bf-44bc-9550-398dd8b4c7aa"
/>
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
Adapt the speed dial's ActionRegistry to the collapsed
get_plugin_capability(PluginCapabilityId) overload, and restore the
script success/skipped status message the dialog lost when its
PluginScriptRunner refactor was superseded by ActionRegistry.
# Description
This PR introduces persistent, capability-scoped configuration for plugins. Users can configure plugins through the Plugins dialog instead of manually editing the plugin’s Python source.
Configuration exists at two levels:
- **Global** — one configuration per capability, shared by every preset. Edited in the Plugins dialog’s **Config** tab.
- **Per preset** — an optional override stored on a process or printer preset, edited from that preset’s **Plugin Preferences** group. A preset that overrides a capability configures the slices it drives; presets that do not simply use the global configuration.
Plugin authors can use the built-in JSON editor or provide a custom HTML settings interface. Both levels use the same editor and the same stored shape.
## New Plugin Configuration APIs
The following APIs are available to all plugin capability types.
### Python APIs
- `get_config() -> str` — Returns a raw JSON string of the capability’s effective configuration: the active preset’s override if it has one, otherwise the global configuration, otherwise `{}`.
- `save_config(config) -> bool` — Persists JSON-compatible configuration for the capability and returns whether the write succeeded. Always writes the **global** configuration (see [Preset overrides](#preset-overrides)).
- `get_config_version() -> str` — Returns the plugin version that last saved the configuration `get_config()` returned, from that same level, or `""` if it has never been saved.
- `has_config_ui() -> bool` — Override and return `True` to use a custom configuration interface instead of the built-in JSON editor.
- `get_config_ui() -> str` — Returns the HTML used to render the custom configuration interface.
- `get_default_config() -> str` — Returns a raw JSON string of the configuration applied by **Restore defaults** in the Plugins dialog. The default implementation returns `{}`.
### Custom UI JavaScript APIs
Custom configuration interfaces receive a sandboxed `window.orca` bridge:
- `window.orca.getConfig()` — Returns the current capability configuration.
- `window.orca.saveConfig(config)` — Requests that the host persist the supplied configuration.
- `window.orca.onConfig(callback)` — Immediately invokes the callback with the current configuration and invokes it again after successful saves or restores.
`saveConfig()` is asynchronous and does not return a Promise. Custom interfaces should use `onConfig()` to observe the successfully persisted state.
## Plugins Dialog
Every activated capability appears in the Plugins dialog’s **Config** tab, where its global configuration is edited.
The editor shown for a capability is selected as follows:
- If `has_config_ui()` returns `True` and `get_config_ui()` returns valid, non-empty HTML, the dialog renders the custom interface.
- Otherwise, the dialog renders the built-in JSON editor.
- If a custom interface cannot be loaded, the dialog reports the error and falls back to the JSON editor.
- The **Restore defaults** action replaces the stored configuration with the value returned by `get_default_config()`.
Custom interfaces run in a sandboxed iframe and can access configuration only through the provided `window.orca` bridge.
## Preset overrides
Process and printer presets gain a **Plugin Preferences → Capabilities** setting (Advanced mode). Its **Configure** button opens a dialog listing the capabilities that preset actually uses — the ones its `plugins` manifest declares *and* one of its plugin-backed options points at — and edits each one’s configuration for that preset alone. The button shows the number of overrides the preset carries.
That dialog offers two actions:
- **Save** — stores the edited configuration as this preset’s override.
- **Restore defaults** — discards the preset’s override, so the capability falls back to the global configuration. A preset holding no override *is* a preset at its defaults.
### How a running capability reads its configuration
`get_config()` resolves in this order:
1. The active preset’s override for this capability, if it has one.
2. The global configuration in `config.json`.
3. `{}`.
Which preset is consulted follows from the capability’s type. A plugin-backed option declares the capability type it accepts (`ConfigOptionDef::plugin_type`) and belongs to exactly one preset type, so `slicing-pipeline` capabilities are configured by the process preset and `printer-connection` capabilities by the printer preset. Nothing is hardcoded: declaring `plugin_type` on a new option is all it takes to place a new capability type on that map.
`get_config_version()` reports the version stamp from whichever level supplied the configuration, so a plugin migrating a stale config is never handed one level’s data with another level’s version.
`save_config()` from Python always writes the global configuration, never a preset — presets are the user’s to edit, and a plugin saving from a worker thread cannot mark one dirty. A capability whose active preset overrides it will therefore keep reading that override back rather than what it saved.
### Storage
A preset’s overrides live in an ordinary string setting on the preset (`plugin_preference_overrides`), holding a JSON array of entries keyed by plugin and capability. Because it is an ordinary setting, the whole preset lifecycle carries it for free: the dirty marker, the revert arrow, inheritance, project (3MF) round-tripping, and preset sync all behave exactly as they do for every other setting. The dialog is a pure editor over that text — it never writes to the preset itself and never writes to the global config file.
## Configuration Storage
All global plugin configuration is stored in a shared file:
`data_dir()/orca_plugins/config.json`
Configuration entries are isolated by plugin and capability. The host also records the plugin version that last wrote each entry.
The configuration file is intentionally stored outside individual plugin directories. This allows settings to survive:
- Plugin upgrades and reloads
- Local plugin deletion and reinstallation
- Cloud plugin unsubscribe and resubscribe operations
Reinstalling or resubscribing to the same plugin restores access to its previously saved configuration.
## Known limitations
**Filament capabilities cannot be overridden per preset.** There is no single active filament preset — one is selected per extruder — and `get_config()` does not say which extruder the capability is running for, so a filament override could only be applied by guessing. Rather than hand a plugin another extruder's settings, filament capabilities read the global configuration.
Nothing reaches this today: no filament option declares a `plugin_type`, so no capability type maps to the filament preset. Lifting it means pushing the extruder onto the plugin call context the Python trampoline already maintains and resolving the preset from that, with the extruder optional — whole slicing steps (`posSlice`, `psGCodePostProcess`) span every extruder and have no current filament.
# Tests
`tests/slic3rutils` covers the capability config store, the Python config API, the preset override layer, the capability-type → preset-type mapping, and which capabilities a preset counts as in use.
# Screenshots/Recordings/Graphs
Custom UI
<img width="855" height="703" alt="image" src="https://github.com/user-attachments/assets/745ecb7d-9e20-4c39-b857-5aa730a27142" />
Default JSON text editor
<img width="855" height="703" alt="image" src="https://github.com/user-attachments/assets/18b7b89c-6f77-4960-a9b2-964e71f74fc3" />
Process Sidebar
<img width="717" height="360" alt="image" src="https://github.com/user-attachments/assets/8fac3e66-c06a-44e4-ad4b-4cc6c003bb2b" />
Filament dialog
<img width="1090" height="832" alt="image" src="https://github.com/user-attachments/assets/ff6a4cbe-11c0-4d04-9ecb-9a717bdeb3f4" />
Printer settings dialog
<img width="1090" height="832" alt="image" src="https://github.com/user-attachments/assets/c616afb0-4eb2-40c2-92c0-7f5edc50b4e6" />
Dialog opened from preset settings
<img width="860" height="725" alt="image" src="https://github.com/user-attachments/assets/069408a8-e94b-47e0-8e16-a81e0d58b4d4" />
# Example plugin with custom UI used in screenshot
[custom_ui_screenshot_demo.py](https://github.com/user-attachments/files/29995212/custom_ui_screenshot_demo.py)
<!--
> A guide for users on how to download the artifacts from this PR.
-->
[How to Download Pull Requests Artifacts for Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
The preset option was plugin_preference_overrides while the GUI field type
that renders it was GUIType::plugin_config, for one and the same thing.
Settle on "config": the store, the dialog and the Python hooks all say config
already, so renaming that way touches 4 files instead of the whole plugin
subsystem and the public plugin API.
Keep the _overrides suffix — the option is the preset's override layer over
the base PluginConfig store, a distinction EffectiveCapabilityConfig tracks.
Also wrap the printer tab's group heading in L(); it was the only one of the
three missing it, and was therefore untranslatable.
There is no arm64 self-hosted build server, so when \`vars.SELF_HOSTED\`
is set the arm64 Linux and Windows legs previously fell back to
GitHub-hosted runners. Drop those legs entirely instead, along with the
unit test jobs that consume their artifacts.
**Changes:**
- **Linux / Windows builds:** matrices switch from a static \`include:\`
list to \`fromJSON(vars.SELF_HOSTED && ... || ...)\`, so self-hosted
runs build x86_64/x64 only. The Windows job's per-arch runner
conditional is gone — the runner is now baked into each matrix entry.
- **Unit tests:** \`unit_tests_linux_aarch64\` and
\`unit_tests_windows_arm64\` are gated on \`!vars.SELF_HOSTED\`; their
\`needs\` still succeed, so \`success()\` alone would not skip them.
- **Slice check:** the profile validator artifact is now named per-arch
and uploaded from the aarch64 leg normally, or x86_64 on self-hosted.
The job's runner and download name follow the same switch, keeping the
gate alive rather than failing on a missing artifact.
- **Comments:** trimmed across the touched blocks.
No change when \`SELF_HOSTED\` is unset — an unset variable is falsy, so
GitHub-hosted runs keep both arches, the same runners, and the aarch64
slice check.
The merge kept this branch's PluginConfig design, which deletes
PluginDescriptor::settings, get_plugin_settings() and ctx.params, but left
references to them behind: the slic3rutils target did not build, and the
bindings test still asserted the removed ctx.params attribute.
Port the two settings tests onto PluginConfig instead of dropping them. They
guard a field bug where a cloud-metadata refresh wiped a plugin's settings and
it silently ran on its own defaults, so the equivalent properties are still
worth pinning: that a stored config survives the refresh, and that an edited
config reaches the plugin through a real dispatch.
Also defer PluginsConfigDialog's web commands off the webview script-message
callback, as PluginsDialog already does. Its remove_preset_override handler put
a modal wxMessageBox on that stack, which is the GTK crash class fixed in
b779a7bfed/f2ccbfc8b5 for the sibling dialog.
Cloud catalog records never carry [tool.orcaslicer.plugin.settings], so the
metadata merge wiped the locally-parsed settings and plugins silently ran on
their built-in defaults (ctx.params arrived empty).
Update Maschine G-Code according to latest Bambu Studio Version X2D
filament_change gcode: 2026/07/01
X2D layer_change gcode: 2026/07/01
X2D start gcode: 2026/06/05
X2D timelapse gcode: 2026/06/03
# Description
<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
> * What issue does this PR address or fix?
> * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->
# Screenshots/Recordings/Graphs
<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->
## Tests
<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->
<!--
> A guide for users on how to download the artifacts from this PR.
-->
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
open_terminal_dialog is reached from the plugins dialog's webview
command, and TerminalDialog hosts a webview of its own — same class as
the plugin-window crash. Defer the window work via CallAfter, guard the
re-front Show() per #13657, and drop the redundant Raise() on creation.
wx 3.3.2 delivers webview script messages synchronously inside the native
callback on GTK and macOS, so script plugins run with the plugins-dialog
webview's signal/delegate frame on the stack. Creating and presenting the
orca.host.ui window from there crashed on Linux at Raise() --
gtk_window_present while GTK's deferred show was still in flight.
Defer the whole window creation to a CallAfter with a pre-bound registry
handle (post/close stay FIFO-safe, teardown races become a no-op), and
drop Raise() plus the show_modeless_dialog wrapper: Show() already
activates and fronts a new window on every platform.
Drop the orphaned PluginCallbackList (dead
after the PluginManager migration) and hop
run_on_*_callbacks onto the UI thread via
CallAfter, snapshotting under the mutex on the
worker first. Keeps wx subscribers off the
detached load/unload workers.
Only one action source ever existed, so the
IActionSource interface and ScriptActionSource
are gone. ActionRegistry now subscribes to the
plugin loader and enumerates actions directly
in init() - no polymorphism for one impl.
Replace the opaque FNV-hash SpeedDialActionId
with a readable composed id of the form
prefix:title:source_key. Split AppAction's
single source field into source_key (stable
identity, e.g. plugin_key) and source_name
(display), so identity and display no longer
share one field.
## What this does
Ports the AMS filament drying control feature from BambuStudio. Most
work was done by Claude Code with deepseek-v4-pro. Thanks Bambu & CC &
DeepSeek :P
Allows users to start, monitor, and stop AMS-based filament drying
directly from the OrcaSlicer UI for N3F (AMS 2 Pro) and N3S (AMS HT) AMS
units.
Mostly from
c8f70c6ca7
Part of #12091
## Screenshots
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/24f579cb-c67c-4d6e-bf77-c31e018f2f70"
/>
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/74f628aa-6f5f-4150-b2e9-082e4ffc3527"
/>
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/d2f26412-a054-4085-9236-5074a030b001"
/>
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/7beef770-8fbc-4375-b244-e0de44b8db2f"
/>
## Changes
- **Data model:** Added drying status enums (`DryStatus`,
`DrySubStatus`, `CannotDryReason`, etc.), `DrySettings` struct,
`DevFilamentDryingPreset` struct to `DevAms`/`DevFilaSystem`
- **Promoted `DevAmsType`** to a global enum (`EXT_SPOOL=0, AMS=1,
AMS_LITE=2, N3F=3, N3S=4`), renamed `DUMMY` → `EXT_SPOOL`
- **JSON parsing:** Extended `DevFilaSystemParser` to parse drying
status fields from printer status messages
- **Commands:** Added `CtrlAmsStartDryingHour()` and
`CtrlAmsStopDrying()` sending `"ams_filament_drying"` JSON via MQTT
- **Backend utility:** New `DevUtilBackend` class with
`GetFilamentDryingPreset()` for reading filament drying config keys
- **UI dialog:** New `AMSDryControl` dialog with three pages
(status/control, guide, progress) matching BambuStudio behavior
- **Integration:** Wired AMS humidity indicator click to open the drying
dialog for N3F/N3S AMS types
- **Assets:** 12 new drying-related images from BambuStudio
- **Firmware parsing:** Added `is_support_remote_dry` flag parsed from
`fun2` bit 5
## Constraints
- N3F/N3S only — standard AMS and AMS Lite continue to use the existing
humidity popup
- Backward compatible — existing `command_ams_drying_stop()` preserved
<!--
> A guide for users on how to download the artifacts from this PR.
-->
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
The eight presets added in 0545750c1b never got ids: the six iQ processes
had none, and K1 SE 0.8 / V-Core 4 0.8 carried ids copied from the presets
they were duplicated from — K1 SE 0.8 still shared K1C 0.8's id. Regenerated
with scripts/assign_vendor_setting_ids.py; none of the eight have shipped in
a release, so no existing id changes meaning.