mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-17 12:51:03 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4dd2044f | ||
|
|
b7f9888516 | ||
|
|
0b478a1d0b | ||
|
|
fd28f6c189 | ||
|
|
9b2fcaa53a | ||
|
|
0acf1e6680 | ||
|
|
c6804fbb68 | ||
|
|
6d40ec93f6 | ||
|
|
d80af50f23 | ||
|
|
3bfe40e17a | ||
|
|
62f05dad2b | ||
|
|
4d3c61c02a | ||
|
|
dc9203126d | ||
|
|
8c2a1198d6 | ||
|
|
193cfc6dbf |
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)
|
||||||
@@ -812,7 +812,8 @@ bool WallToolPaths::removeEmptyToolPaths(std::vector<VariableWidthLines> &toolpa
|
|||||||
* Get the order constraints of the insets when printing walls per region / hole.
|
* Get the order constraints of the insets when printing walls per region / hole.
|
||||||
* Each returned pair consists of adjacent wall lines where the left has an inset_idx one lower than the right.
|
* Each returned pair consists of adjacent wall lines where the left has an inset_idx one lower than the right.
|
||||||
*
|
*
|
||||||
* Odd walls should always go after their enclosing wall polygons.
|
* Odd walls follow the same order as even ones, so that the centre line of a narrow wall is an inner wall
|
||||||
|
* like any other when printing inner to outer.
|
||||||
*
|
*
|
||||||
* \param outer_to_inner Whether the wall polygons with a lower inset_idx should go before those with a higher one.
|
* \param outer_to_inner Whether the wall polygons with a lower inset_idx should go before those with a higher one.
|
||||||
*/
|
*/
|
||||||
@@ -886,12 +887,7 @@ WallToolPaths::ExtrusionLineSet WallToolPaths::getRegionOrder(const std::vector<
|
|||||||
continue; // not directly adjacent
|
continue; // not directly adjacent
|
||||||
if (!shorter_then(loc_here - lineloc_nearby.j.p, (lineloc_here.j.w + lineloc_nearby.j.w) / 2 * diagonal_extension))
|
if (!shorter_then(loc_here - lineloc_nearby.j.p, (lineloc_here.j.w + lineloc_nearby.j.w) / 2 * diagonal_extension))
|
||||||
continue; // points are too far away from each other
|
continue; // points are too far away from each other
|
||||||
if (here->is_odd || nearby->is_odd) {
|
if ((nearby->inset_idx < here->inset_idx) == outer_to_inner) {
|
||||||
if (here->is_odd && !nearby->is_odd && nearby->inset_idx < here->inset_idx)
|
|
||||||
order_requirements.emplace(std::make_pair(nearby, here));
|
|
||||||
if (nearby->is_odd && !here->is_odd && here->inset_idx < nearby->inset_idx)
|
|
||||||
order_requirements.emplace(std::make_pair(here, nearby));
|
|
||||||
} else if ((nearby->inset_idx < here->inset_idx) == outer_to_inner) {
|
|
||||||
order_requirements.emplace(std::make_pair(nearby, here));
|
order_requirements.emplace(std::make_pair(nearby, here));
|
||||||
} else {
|
} else {
|
||||||
assert((nearby->inset_idx > here->inset_idx) == outer_to_inner);
|
assert((nearby->inset_idx > here->inset_idx) == outer_to_inner);
|
||||||
|
|||||||
@@ -3985,6 +3985,16 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
|
|||||||
this->placeholder_parser().set("print_time_minute", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder)));
|
this->placeholder_parser().set("print_time_minute", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder)));
|
||||||
this->placeholder_parser().set("print_time_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder)));
|
this->placeholder_parser().set("print_time_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder)));
|
||||||
this->placeholder_parser().set("used_filament_length", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
|
this->placeholder_parser().set("used_filament_length", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
|
||||||
|
// The final print statistics are known only after the whole G-code is generated, so they are filled in during post-processing too.
|
||||||
|
// The running extruded_* totals are left alone; profiles use them as numbers.
|
||||||
|
this->placeholder_parser().set("used_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
|
||||||
|
this->placeholder_parser().set("print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)));
|
||||||
|
this->placeholder_parser().set("normal_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)));
|
||||||
|
this->placeholder_parser().set("silent_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Silent_Print_Time_Placeholder)));
|
||||||
|
this->placeholder_parser().set("total_weight", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Weight_Placeholder)));
|
||||||
|
this->placeholder_parser().set("total_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Cost_Placeholder)));
|
||||||
|
this->placeholder_parser().set("total_wipe_tower_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Cost_Placeholder)));
|
||||||
|
this->placeholder_parser().set("total_wipe_tower_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Filament_Placeholder)));
|
||||||
|
|
||||||
// Sync variant-mapped params into placeholder_parser before processing start gcode
|
// Sync variant-mapped params into placeholder_parser before processing start gcode
|
||||||
update_placeholder_parser_with_variant_params();
|
update_placeholder_parser_with_variant_params();
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ const std::vector<std::string> GCodeProcessor::Reserved_Tags = {
|
|||||||
"@PRINT_TIME_HOUR@",
|
"@PRINT_TIME_HOUR@",
|
||||||
"@PRINT_TIME_MINUTE@",
|
"@PRINT_TIME_MINUTE@",
|
||||||
"@PRINT_TIME_SEC@",
|
"@PRINT_TIME_SEC@",
|
||||||
"@USED_FILAMENT_LENGTH@"
|
"@USED_FILAMENT_LENGTH@",
|
||||||
|
"@NORMAL_PRINT_TIME@",
|
||||||
|
"@SILENT_PRINT_TIME@",
|
||||||
|
"@TOTAL_WEIGHT@",
|
||||||
|
"@TOTAL_COST@",
|
||||||
|
"@TOTAL_WIPE_TOWER_COST@",
|
||||||
|
"@TOTAL_WIPE_TOWER_FILAMENT@"
|
||||||
};
|
};
|
||||||
|
|
||||||
const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
|
const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
|
||||||
@@ -147,7 +153,13 @@ const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
|
|||||||
"@PRINT_TIME_HOUR@",
|
"@PRINT_TIME_HOUR@",
|
||||||
"@PRINT_TIME_MINUTE@",
|
"@PRINT_TIME_MINUTE@",
|
||||||
"@PRINT_TIME_SEC@",
|
"@PRINT_TIME_SEC@",
|
||||||
"@USED_FILAMENT_LENGTH@"
|
"@USED_FILAMENT_LENGTH@",
|
||||||
|
"@NORMAL_PRINT_TIME@",
|
||||||
|
"@SILENT_PRINT_TIME@",
|
||||||
|
"@TOTAL_WEIGHT@",
|
||||||
|
"@TOTAL_COST@",
|
||||||
|
"@TOTAL_WIPE_TOWER_COST@",
|
||||||
|
"@TOTAL_WIPE_TOWER_FILAMENT@"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -1264,19 +1276,22 @@ void GCodeProcessor::run_post_process()
|
|||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process inline placeholders (print_time_total_sec, print_time_day, print_time_hour, print_time_minute, print_time_sec and used_filament_length)
|
// Process inline placeholders (print_time_*, used_filament_length and the print statistics known only after export)
|
||||||
auto process_inline_placeholders = [&](std::string& gcode_line) {
|
auto process_inline_placeholders = [&](std::string& gcode_line) {
|
||||||
bool processed = false;
|
bool processed = false;
|
||||||
// Every inline placeholder contains '@', so a line without one has nothing to replace.
|
// Every inline placeholder contains '@', so a line without one has nothing to replace.
|
||||||
if (gcode_line.find('@') == std::string::npos)
|
if (gcode_line.find('@') == std::string::npos)
|
||||||
return processed;
|
return processed;
|
||||||
|
|
||||||
const std::string& print_time_total_placeholder = reserved_tag(ETags::Print_Time_Total_Sec_Placeholder);
|
auto replace_tag = [&gcode_line, &processed](ETags tag, const std::string& value) {
|
||||||
const std::string& print_time_day_placeholder = reserved_tag(ETags::Print_Time_Day_Placeholder);
|
const std::string& placeholder = reserved_tag(tag);
|
||||||
const std::string& print_time_hour_placeholder = reserved_tag(ETags::Print_Time_Hour_Placeholder);
|
size_t pos = gcode_line.find(placeholder);
|
||||||
const std::string& print_time_minute_placeholder = reserved_tag(ETags::Print_Time_Minute_Placeholder);
|
while (pos != std::string::npos) {
|
||||||
const std::string& print_time_sec_placeholder = reserved_tag(ETags::Print_Time_Sec_Placeholder);
|
gcode_line.replace(pos, placeholder.length(), value);
|
||||||
const std::string& used_filament_placeholder = reserved_tag(ETags::Used_Filament_Length_Placeholder);
|
processed = true;
|
||||||
|
pos = gcode_line.find(placeholder, pos + value.length());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
double print_time_total_sec = m_time_processor.machines[static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Normal)].time;
|
double print_time_total_sec = m_time_processor.machines[static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Normal)].time;
|
||||||
if (print_time_total_sec < 0.0)
|
if (print_time_total_sec < 0.0)
|
||||||
@@ -1289,70 +1304,31 @@ void GCodeProcessor::run_post_process()
|
|||||||
int print_time_minute = (day_remainder_seconds % 3600) / 60;
|
int print_time_minute = (day_remainder_seconds % 3600) / 60;
|
||||||
int print_time_sec = day_remainder_seconds % 60;
|
int print_time_sec = day_remainder_seconds % 60;
|
||||||
|
|
||||||
// Replace print_time_total_sec
|
|
||||||
size_t pos = gcode_line.find(print_time_total_placeholder);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
char buf[64];
|
|
||||||
sprintf(buf, "%.2f", print_time_total_sec);
|
|
||||||
gcode_line.replace(pos, print_time_total_placeholder.length(), buf);
|
|
||||||
processed = true;
|
|
||||||
pos = gcode_line.find(print_time_total_placeholder, pos + strlen(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace print_time_day
|
|
||||||
pos = gcode_line.find(print_time_day_placeholder);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
char buf[64];
|
|
||||||
sprintf(buf, "%d", print_time_day);
|
|
||||||
gcode_line.replace(pos, print_time_day_placeholder.length(), buf);
|
|
||||||
processed = true;
|
|
||||||
pos = gcode_line.find(print_time_day_placeholder, pos + strlen(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace print_time_hour
|
|
||||||
pos = gcode_line.find(print_time_hour_placeholder);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
char buf[64];
|
|
||||||
sprintf(buf, "%d", print_time_hour);
|
|
||||||
gcode_line.replace(pos, print_time_hour_placeholder.length(), buf);
|
|
||||||
processed = true;
|
|
||||||
pos = gcode_line.find(print_time_hour_placeholder, pos + strlen(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace print_time_minute
|
|
||||||
pos = gcode_line.find(print_time_minute_placeholder);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
char buf[64];
|
|
||||||
sprintf(buf, "%d", print_time_minute);
|
|
||||||
gcode_line.replace(pos, print_time_minute_placeholder.length(), buf);
|
|
||||||
processed = true;
|
|
||||||
pos = gcode_line.find(print_time_minute_placeholder, pos + strlen(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace print_time_sec
|
|
||||||
pos = gcode_line.find(print_time_sec_placeholder);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
char buf[64];
|
|
||||||
sprintf(buf, "%d", print_time_sec);
|
|
||||||
gcode_line.replace(pos, print_time_sec_placeholder.length(), buf);
|
|
||||||
processed = true;
|
|
||||||
pos = gcode_line.find(print_time_sec_placeholder, pos + strlen(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace used_filament_length
|
|
||||||
pos = gcode_line.find(used_filament_placeholder);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
double total_filament_mm = 0.0;
|
double total_filament_mm = 0.0;
|
||||||
for (const auto& mm : filament_mm) {
|
for (const auto& mm : filament_mm) {
|
||||||
total_filament_mm += mm;
|
total_filament_mm += mm;
|
||||||
}
|
}
|
||||||
double used_filament_length = total_filament_mm / 1000.0; // Convert mm to m
|
|
||||||
char buf[64];
|
replace_tag(ETags::Print_Time_Total_Sec_Placeholder, Slic3r::float_to_string_decimal_point(print_time_total_sec, 2));
|
||||||
sprintf(buf, "%.2f", used_filament_length);
|
replace_tag(ETags::Print_Time_Day_Placeholder, std::to_string(print_time_day));
|
||||||
gcode_line.replace(pos, used_filament_placeholder.length(), buf);
|
replace_tag(ETags::Print_Time_Hour_Placeholder, std::to_string(print_time_hour));
|
||||||
processed = true;
|
replace_tag(ETags::Print_Time_Minute_Placeholder, std::to_string(print_time_minute));
|
||||||
pos = gcode_line.find(used_filament_placeholder, pos + strlen(buf));
|
replace_tag(ETags::Print_Time_Sec_Placeholder, std::to_string(print_time_sec));
|
||||||
}
|
replace_tag(ETags::Used_Filament_Length_Placeholder,
|
||||||
|
Slic3r::float_to_string_decimal_point(total_filament_mm / 1000.0, 2)); // Convert mm to m
|
||||||
|
// Same values as the print statistics that filename_format gets (PrintStatistics::config(), filled in by
|
||||||
|
// update_print_estimated_stats() in GCode.cpp), which runs only after this post-processing.
|
||||||
|
replace_tag(ETags::Normal_Print_Time_Placeholder, get_time_dhm(PrintEstimatedStatistics::ETimeMode::Normal));
|
||||||
|
replace_tag(ETags::Silent_Print_Time_Placeholder, is_stealth_time_estimator_enabled() ?
|
||||||
|
get_time_dhm(PrintEstimatedStatistics::ETimeMode::Stealth) :
|
||||||
|
short_time("N/A"));
|
||||||
|
const double total_cost = filament_total_cost + m_print->config().time_cost.value * print_time_total_sec / 3600.0;
|
||||||
|
replace_tag(ETags::Total_Weight_Placeholder, Slic3r::float_to_string_decimal_point(filament_total_g, 2));
|
||||||
|
replace_tag(ETags::Total_Cost_Placeholder, Slic3r::float_to_string_decimal_point(total_cost, 2));
|
||||||
|
replace_tag(ETags::Total_Wipe_Tower_Cost_Placeholder,
|
||||||
|
Slic3r::float_to_string_decimal_point(m_print->print_statistics().total_wipe_tower_cost, 2));
|
||||||
|
replace_tag(ETags::Total_Wipe_Tower_Filament_Placeholder,
|
||||||
|
Slic3r::float_to_string_decimal_point(m_print->print_statistics().total_wipe_tower_filament, 2));
|
||||||
|
|
||||||
return processed;
|
return processed;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -601,6 +601,12 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f;
|
|||||||
Print_Time_Minute_Placeholder,
|
Print_Time_Minute_Placeholder,
|
||||||
Print_Time_Sec_Placeholder,
|
Print_Time_Sec_Placeholder,
|
||||||
Used_Filament_Length_Placeholder,
|
Used_Filament_Length_Placeholder,
|
||||||
|
Normal_Print_Time_Placeholder,
|
||||||
|
Silent_Print_Time_Placeholder,
|
||||||
|
Total_Weight_Placeholder,
|
||||||
|
Total_Cost_Placeholder,
|
||||||
|
Total_Wipe_Tower_Cost_Placeholder,
|
||||||
|
Total_Wipe_Tower_Filament_Placeholder,
|
||||||
};
|
};
|
||||||
|
|
||||||
static const std::string& reserved_tag(ETags tag) { return s_IsBBLPrinter ? Reserved_Tags[static_cast<unsigned char>(tag)] : Reserved_Tags_compatible[static_cast<unsigned char>(tag)]; }
|
static const std::string& reserved_tag(ETags tag) { return s_IsBBLPrinter ? Reserved_Tags[static_cast<unsigned char>(tag)] : Reserved_Tags_compatible[static_cast<unsigned char>(tag)]; }
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ public:
|
|||||||
// Depth in the hierarchy. External perimeter has depth = 0. An external perimeter could be both a contour and a hole.
|
// Depth in the hierarchy. External perimeter has depth = 0. An external perimeter could be both a contour and a hole.
|
||||||
unsigned short depth;
|
unsigned short depth;
|
||||||
// ORCA: an external hole perimeter touching the next outer wall all around, with nothing between them.
|
// ORCA: an external hole perimeter touching the next outer wall all around, with nothing between them.
|
||||||
|
// E.g. a 1mm tube wall with 0.5mm walls: 2 lines, the contour's outer wall and the hole's.
|
||||||
bool is_thin_wall_hole = false;
|
bool is_thin_wall_hole = false;
|
||||||
|
// ORCA: an external hole perimeter with no room for an inner wall beside it, at most gap fill. Every thin wall
|
||||||
|
// hole is one, and so is e.g. a 1.5mm tube wall with 0.5mm walls: 3 lines, the two outer walls and gap fill
|
||||||
|
// between them. A 2mm tube wall has room for an inner wall a side and is not. Only set for Inner/Outer/Inner.
|
||||||
|
bool is_narrow_wall_hole = false;
|
||||||
// Children contour, may be both CCW and CW oriented (outer contours or holes).
|
// Children contour, may be both CCW and CW oriented (outer contours or holes).
|
||||||
std::vector<PerimeterGeneratorLoop> children;
|
std::vector<PerimeterGeneratorLoop> children;
|
||||||
|
|
||||||
@@ -572,7 +577,8 @@ static ExtrusionEntityCollection traverse_extrusions(const PerimeterGenerator& p
|
|||||||
extrusion_coll.append(std::move(extrusion_loop));
|
extrusion_coll.append(std::move(extrusion_loop));
|
||||||
// Orca: Reverse the order of paths for thin wall holes. We define thin wall hole as a hole with only one perimeter.
|
// Orca: Reverse the order of paths for thin wall holes. We define thin wall hole as a hole with only one perimeter.
|
||||||
const bool thin_wall_hole = !pg_extrusion.is_contour && pg_extrusions.size() == 2;
|
const bool thin_wall_hole = !pg_extrusion.is_contour && pg_extrusions.size() == 2;
|
||||||
if (thin_wall_hole && perimeter_generator.config->wall_sequence != WallSequence::OuterInner)
|
if (thin_wall_hole && perimeter_generator.config->wall_sequence != WallSequence::OuterInner &&
|
||||||
|
!perimeter_generator.outer_walls_first_for_brim())
|
||||||
std::reverse(extrusion_coll.entities.begin(), extrusion_coll.entities.end());
|
std::reverse(extrusion_coll.entities.begin(), extrusion_coll.entities.end());
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -1690,13 +1696,24 @@ void PerimeterGenerator::process_classic()
|
|||||||
|
|
||||||
last = std::move(offsets);
|
last = std::move(offsets);
|
||||||
|
|
||||||
// ORCA: a thin wall hole has no room for gap fill or an inner wall anywhere beside its outer wall.
|
// ORCA: a thin wall hole has no room for gap fill or an inner wall anywhere beside its outer wall, a
|
||||||
|
// narrow wall hole none for an inner wall, the room the next loop is offset by below.
|
||||||
if (i == 0 && ! holes[0].empty()) {
|
if (i == 0 && ! holes[0].empty()) {
|
||||||
const float room = float(0.5 * (ext_perimeter_spacing2 + gap_fill_min_width));
|
auto reach = [&last](double room) {
|
||||||
const Polygons reach = to_polygons(offset2_ex(last, -room, room + float(SCALED_EPSILON)));
|
return to_polygons(offset2_ex(last, -float(room), float(room) + float(SCALED_EPSILON)));
|
||||||
for (PerimeterGeneratorLoop &hole : holes[0])
|
};
|
||||||
hole.is_thin_wall_hole = intersection_pl(Polylines{ hole.polygon.split_at_first_point() },
|
auto out_of_reach = [](const Polygon &hole, const Polygons &reach) {
|
||||||
ClipperUtils::clip_clipper_polygons_with_subject_bbox(reach, get_extents(hole.polygon).inflated(SCALED_EPSILON))).empty();
|
return intersection_pl(Polylines{ hole.split_at_first_point() },
|
||||||
|
ClipperUtils::clip_clipper_polygons_with_subject_bbox(reach, get_extents(hole).inflated(SCALED_EPSILON))).empty();
|
||||||
|
};
|
||||||
|
// Only Inner/Outer/Inner orders narrow wall holes apart.
|
||||||
|
const bool narrow_holes_ordered = this->config->wall_sequence == WallSequence::InnerOuterInner;
|
||||||
|
const Polygons gap_fill_reach = reach(0.5 * (ext_perimeter_spacing2 + gap_fill_min_width));
|
||||||
|
const Polygons inner_wall_reach = narrow_holes_ordered ? reach(ext_perimeter_spacing2 + min_spacing / 2.) : Polygons();
|
||||||
|
for (PerimeterGeneratorLoop &hole : holes[0]) {
|
||||||
|
hole.is_thin_wall_hole = out_of_reach(hole.polygon, gap_fill_reach);
|
||||||
|
hole.is_narrow_wall_hole = narrow_holes_ordered && out_of_reach(hole.polygon, inner_wall_reach);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//BBS: refer to superslicer
|
//BBS: refer to superslicer
|
||||||
@@ -1835,18 +1852,16 @@ void PerimeterGenerator::process_classic()
|
|||||||
|
|
||||||
// if brim will be printed, reverse the order of perimeters so that
|
// if brim will be printed, reverse the order of perimeters so that
|
||||||
// we continue inwards after having finished the brim
|
// we continue inwards after having finished the brim
|
||||||
// TODO: add test for perimeter order
|
|
||||||
bool is_outer_wall_first = this->config->wall_sequence == WallSequence::OuterInner;
|
bool is_outer_wall_first = this->config->wall_sequence == WallSequence::OuterInner;
|
||||||
if (is_outer_wall_first ||
|
// Orca: a contour with only a narrow wall hole beside it has nothing to sandwich, and prints the hole first,
|
||||||
//BBS: always print outer wall first when there indeed has brim.
|
// then the contour, then the gap fill between them, as Arachne does.
|
||||||
// btLeadingEdgeOnly is an outer brim too (a belt brim at the part's first contact).
|
const PerimeterGeneratorLoops &island_loops = contours.front();
|
||||||
(this->layer_id == 0 &&
|
const bool narrow_wall_hole_island = island_loops.size() == 1 && island_loops.front().children.size() == 1 &&
|
||||||
(this->object_config->brim_type == BrimType::btOuterOnly ||
|
island_loops.front().children.front().is_narrow_wall_hole;
|
||||||
this->object_config->brim_type == BrimType::btLeadingEdgeOnly) &&
|
if (is_outer_wall_first || this->outer_walls_first_for_brim())
|
||||||
this->object_config->brim_width.value > 0))
|
|
||||||
entities.reverse();
|
entities.reverse();
|
||||||
// Orca: sandwich mode. Apply after 1st layer.
|
// Orca: sandwich mode. Apply after 1st layer.
|
||||||
else if ((this->config->wall_sequence == WallSequence::InnerOuterInner) && layer_id > 0){
|
else if ((this->config->wall_sequence == WallSequence::InnerOuterInner) && layer_id > 0 && !narrow_wall_hole_island){
|
||||||
entities.reverse(); // reverse all entities - order them from external to internal
|
entities.reverse(); // reverse all entities - order them from external to internal
|
||||||
if(entities.entities.size()>2){ // 3 walls minimum needed to do inner outer inner ordering
|
if(entities.entities.size()>2){ // 3 walls minimum needed to do inner outer inner ordering
|
||||||
int position = 0; // index to run the re-ordering for multiple external perimeters in a single island.
|
int position = 0; // index to run the re-ordering for multiple external perimeters in a single island.
|
||||||
@@ -2412,11 +2427,25 @@ std::vector<PerimeterGeneratorArachneExtrusion> reorderPerimetersByProximity(std
|
|||||||
|
|
||||||
// Function to reorder perimeters starting from a given reference index
|
// Function to reorder perimeters starting from a given reference index
|
||||||
auto reorderFromReference = [&](int referenceIndex) {
|
auto reorderFromReference = [&](int referenceIndex) {
|
||||||
std::unordered_set<int> firstLevelIndices;
|
// Find first level touching perimeters. The odd centre line of a wall too narrow for two inner walls is shared
|
||||||
firstLevelIndices.insert(referenceIndex);
|
// by the outer walls on both sides, so those join this island and are printed before it too, holes first.
|
||||||
|
std::vector<int> outerIndices{referenceIndex};
|
||||||
// Find first level touching perimeters
|
std::vector<int> firstLevelTouchingIndices;
|
||||||
std::vector<int> firstLevelTouchingIndices = findAllTouchingPerimeters(entities, firstLevelIndices, threshold_external, threshold_internal, 1);
|
for (size_t next = 0; next < outerIndices.size(); ++next)
|
||||||
|
for (int idx : findAllTouchingPerimeters(entities, {outerIndices[next]}, threshold_external, threshold_internal, 1)) {
|
||||||
|
if (std::find(firstLevelTouchingIndices.begin(), firstLevelTouchingIndices.end(), idx) != firstLevelTouchingIndices.end())
|
||||||
|
continue;
|
||||||
|
firstLevelTouchingIndices.push_back(idx);
|
||||||
|
if (!entities[idx].extrusion->is_odd)
|
||||||
|
continue;
|
||||||
|
for (size_t i = 0; i < entities.size(); ++i)
|
||||||
|
if (entities[i].extrusion->inset_idx == 0 && includedIndices.count(int(i)) == 0 &&
|
||||||
|
std::find(outerIndices.begin(), outerIndices.end(), int(i)) == outerIndices.end() &&
|
||||||
|
arachne_lines_touch(*entities[i].extrusion, *entities[idx].extrusion, double(threshold_external)))
|
||||||
|
outerIndices.push_back(int(i));
|
||||||
|
}
|
||||||
|
std::stable_partition(outerIndices.begin(), outerIndices.end(),
|
||||||
|
[&entities](int idx) { return entities[idx].extrusion->is_closed && !entities[idx].is_contour; });
|
||||||
// Bring the largest first level perimeter to the front
|
// Bring the largest first level perimeter to the front
|
||||||
// The longest first neighbour is most likely the dominant proximate perimeter
|
// The longest first neighbour is most likely the dominant proximate perimeter
|
||||||
// hence printing it immediately after the external perimeter should speed things up
|
// hence printing it immediately after the external perimeter should speed things up
|
||||||
@@ -2427,8 +2456,10 @@ std::vector<PerimeterGeneratorArachneExtrusion> reorderPerimetersByProximity(std
|
|||||||
std::iter_swap(maxIt, firstLevelTouchingIndices.end() - 1);
|
std::iter_swap(maxIt, firstLevelTouchingIndices.end() - 1);
|
||||||
}
|
}
|
||||||
// Insert first level perimeters into reordered list
|
// Insert first level perimeters into reordered list
|
||||||
reordered.push_back(entities[referenceIndex]);
|
for (int idx : outerIndices) {
|
||||||
includedIndices.insert(referenceIndex);
|
reordered.push_back(entities[idx]);
|
||||||
|
includedIndices.insert(idx);
|
||||||
|
}
|
||||||
|
|
||||||
for (int idx : firstLevelTouchingIndices) {
|
for (int idx : firstLevelTouchingIndices) {
|
||||||
if (includedIndices.count(idx) == 0) {
|
if (includedIndices.count(idx) == 0) {
|
||||||
@@ -2757,7 +2788,7 @@ void PerimeterGenerator::process_arachne()
|
|||||||
|
|
||||||
if (layer_id == 0){ // disable inner outer inner algorithm after the first layer
|
if (layer_id == 0){ // disable inner outer inner algorithm after the first layer
|
||||||
is_outer_wall_first =
|
is_outer_wall_first =
|
||||||
this->config->wall_sequence == WallSequence::OuterInner;
|
this->config->wall_sequence == WallSequence::OuterInner || this->outer_walls_first_for_brim();
|
||||||
}
|
}
|
||||||
if (is_outer_wall_first) {
|
if (is_outer_wall_first) {
|
||||||
start_perimeter = 0;
|
start_perimeter = 0;
|
||||||
@@ -3042,6 +3073,16 @@ ExPolygons PerimeterGenerator::printable_slices(const ExPolygons &slices) const
|
|||||||
return min_width > EPSILON ? opening_ex(slices, float(scale_(min_width / 2.))) : slices;
|
return min_width > EPSILON ? opening_ex(slices, float(scale_(min_width / 2.))) : slices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool PerimeterGenerator::outer_walls_first_for_brim() const
|
||||||
|
{
|
||||||
|
return layer_id == 0 && brim_puts_outer_walls_first(object_config->brim_type.value, object_config->brim_width.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool brim_puts_outer_walls_first(BrimType brim_type, double brim_width)
|
||||||
|
{
|
||||||
|
return brim_width > 0 && (brim_type == btOuterOnly || brim_type == btOuterAndInner || brim_type == btLeadingEdgeOnly);
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<Polygons> PerimeterGenerator::generate_lower_polygons_series(float width)
|
std::vector<Polygons> PerimeterGenerator::generate_lower_polygons_series(float width)
|
||||||
{
|
{
|
||||||
float nozzle_diameter = print_config->nozzle_diameter.get_at(config->outer_wall_filament_id - 1);
|
float nozzle_diameter = print_config->nozzle_diameter.get_at(config->outer_wall_filament_id - 1);
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ public:
|
|||||||
Polygons lower_slices_polygons() const { return m_lower_slices_polygons; }
|
Polygons lower_slices_polygons() const { return m_lower_slices_polygons; }
|
||||||
// ORCA: the slices less the slivers the wall generator prints nothing for, so they never count as support.
|
// ORCA: the slices less the slivers the wall generator prints nothing for, so they never count as support.
|
||||||
ExPolygons printable_slices(const ExPolygons &slices) const;
|
ExPolygons printable_slices(const ExPolygons &slices) const;
|
||||||
|
// ORCA: whether this layer prints its outer walls first for the brim, see brim_puts_outer_walls_first().
|
||||||
|
bool outer_walls_first_for_brim() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector<Polygons> generate_lower_polygons_series(float width);
|
std::vector<Polygons> generate_lower_polygons_series(float width);
|
||||||
@@ -183,6 +185,10 @@ private:
|
|||||||
Polygons m_lower_slices_polygons;
|
Polygons m_lower_slices_polygons;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ORCA: a brim against the outer walls makes the first layer print them first, whatever the wall sequence, so the
|
||||||
|
// brim runs straight on into the outer wall. Auto, ear and painted brims may leave the outer walls bare.
|
||||||
|
bool brim_puts_outer_walls_first(BrimType brim_type, double brim_width);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -2293,6 +2293,7 @@ void PresetCollection::load_project_embedded_presets(std::vector<Preset*>& proje
|
|||||||
BOOST_LOG_TRIVIAL(error) << boost::format("can not find parent for config %1%!")%preset->file;
|
BOOST_LOG_TRIVIAL(error) << boost::format("can not find parent for config %1%!")%preset->file;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
extend_default_config_length(config, false, {});
|
||||||
preset->config.update_diff_values_to_child_config(config, extruder_id_name, extruder_variant_name, *key_set1, *key_set2);
|
preset->config.update_diff_values_to_child_config(config, extruder_id_name, extruder_variant_name, *key_set1, *key_set2);
|
||||||
//preset->config.apply(std::move(config));
|
//preset->config.apply(std::move(config));
|
||||||
Preset::normalize(preset->config);
|
Preset::normalize(preset->config);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "I18N.hpp"
|
#include "I18N.hpp"
|
||||||
#include "Layer.hpp"
|
#include "Layer.hpp"
|
||||||
#include "MutablePolygon.hpp"
|
#include "MutablePolygon.hpp"
|
||||||
|
#include "PerimeterGenerator.hpp"
|
||||||
#include "PrintBase.hpp"
|
#include "PrintBase.hpp"
|
||||||
#include "PrintConfig.hpp"
|
#include "PrintConfig.hpp"
|
||||||
#include "SLA/IndexedMesh.hpp"
|
#include "SLA/IndexedMesh.hpp"
|
||||||
@@ -1464,15 +1465,13 @@ bool PrintObject::invalidate_state_by_config_options(
|
|||||||
|| opt_key == "top_surface_speed") {
|
|| opt_key == "top_surface_speed") {
|
||||||
// Brim is printed below supports, support invalidates brim and skirt.
|
// Brim is printed below supports, support invalidates brim and skirt.
|
||||||
steps.emplace_back(posSupportMaterial);
|
steps.emplace_back(posSupportMaterial);
|
||||||
if (opt_key == "brim_type") {
|
if (opt_key == "brim_type" || opt_key == "brim_width") {
|
||||||
const auto* old_brim_type = old_config.option<ConfigOptionEnum<BrimType>>(opt_key);
|
// Regenerate the walls when the brim starts or stops putting the first layer's outer walls first.
|
||||||
const auto* new_brim_type = new_config.option<ConfigOptionEnum<BrimType>>(opt_key);
|
auto outer_walls_first = [](const ConfigOptionResolver &config) {
|
||||||
//BBS: When switch to manual brim, the object must have brim, then re-generate perimeter
|
return brim_puts_outer_walls_first(config.option<ConfigOptionEnum<BrimType>>("brim_type")->value,
|
||||||
//to make the wall order of first layer to be outer-first
|
config.option<ConfigOptionFloat>("brim_width")->value);
|
||||||
// btLeadingEdgeOnly is printed as an outer brim (Brim.cpp, BeltBrim.cpp), so it
|
};
|
||||||
// takes part in the same first-layer wall order rule.
|
if (outer_walls_first(old_config) != outer_walls_first(new_config))
|
||||||
if (old_brim_type->value == btOuterOnly || new_brim_type->value == btOuterOnly ||
|
|
||||||
old_brim_type->value == btLeadingEdgeOnly || new_brim_type->value == btLeadingEdgeOnly)
|
|
||||||
steps.emplace_back(posPerimeters);
|
steps.emplace_back(posPerimeters);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -707,10 +707,12 @@ void tree_supports_generate_paths(
|
|||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
if (eec) {
|
if (eec) {
|
||||||
|
if (!eec->empty()) {
|
||||||
std::reverse(eec->entities.begin(), eec->entities.end());
|
std::reverse(eec->entities.begin(), eec->entities.end());
|
||||||
dst.emplace_back(eec.release());
|
dst.emplace_back(eec.release());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void fill_expolygons_with_sheath_generate_paths(
|
void fill_expolygons_with_sheath_generate_paths(
|
||||||
@@ -1825,12 +1827,7 @@ void generate_support_toolpaths(
|
|||||||
no_sort = true;
|
no_sort = true;
|
||||||
} else if (support_params.support_style == SupportMaterialStyle::smsTreeOrganic &&
|
} else if (support_params.support_style == SupportMaterialStyle::smsTreeOrganic &&
|
||||||
(config.support_base_pattern == smpNone || config.support_base_pattern == smpDefault)) {
|
(config.support_base_pattern == smpNone || config.support_base_pattern == smpDefault)) {
|
||||||
// Orca: A special case for the hollow Organic supports
|
tree_supports_generate_paths(base_layer.extrusions, base_layer.polygons_to_extrude(), flow, support_params);
|
||||||
// Orca: If the tree supports are too tall, use a double wall to make it stronger
|
|
||||||
SupportParameters support_params2 = support_params;
|
|
||||||
if (support_layer.print_z > 100.0)
|
|
||||||
support_params2.tree_branch_diameter_double_wall_area_scaled = 0.1;
|
|
||||||
tree_supports_generate_paths(base_layer.extrusions, base_layer.polygons_to_extrude(), flow, support_params2);
|
|
||||||
done = true;
|
done = true;
|
||||||
}
|
}
|
||||||
if (! done)
|
if (! done)
|
||||||
|
|||||||
@@ -2149,7 +2149,9 @@ void TreeSupport::draw_circles()
|
|||||||
const bool with_lightning_infill = m_support_params.base_fill_pattern == ipLightning;
|
const bool with_lightning_infill = m_support_params.base_fill_pattern == ipLightning;
|
||||||
coordf_t support_extrusion_width = m_support_params.support_extrusion_width;
|
coordf_t support_extrusion_width = m_support_params.support_extrusion_width;
|
||||||
const float tree_brim_width = config.tree_support_brim_width.value;
|
const float tree_brim_width = config.tree_support_brim_width.value;
|
||||||
|
// Reinforce branches moving more than half a support-line width between layers.
|
||||||
|
// Square the threshold for comparison with the squared XY movement below.
|
||||||
|
const double max_move_sq = sqr(double(scale_(m_object_config->support_line_width / 2)));
|
||||||
// Belt floor: the first object layer is not on a flat bed — it rests on the
|
// Belt floor: the first object layer is not on a flat bed — it rests on the
|
||||||
// tilted, moving belt. So the first-object-layer adhesion features (the tree
|
// tilted, moving belt. So the first-object-layer adhesion features (the tree
|
||||||
// support brim, the hybrid first-layer base expansion) must be suppressed:
|
// support brim, the hybrid first-layer base expansion) must be suppressed:
|
||||||
@@ -2201,7 +2203,7 @@ void TreeSupport::draw_circles()
|
|||||||
size_t first_base_roof_area = 0;
|
size_t first_base_roof_area = 0;
|
||||||
bool floor_interface_as_base = false;
|
bool floor_interface_as_base = false;
|
||||||
bool has_circle_node = false;
|
bool has_circle_node = false;
|
||||||
bool need_extra_wall = false;
|
ExPolygons extra_wall_areas;
|
||||||
ExPolygons collision_sharp_tails;
|
ExPolygons collision_sharp_tails;
|
||||||
ExPolygons collision_base;
|
ExPolygons collision_base;
|
||||||
auto get_collision = [&](bool sharp_tail) -> ExPolygons &{
|
auto get_collision = [&](bool sharp_tail) -> ExPolygons &{
|
||||||
@@ -2279,7 +2281,6 @@ void TreeSupport::draw_circles()
|
|||||||
// area = diff_clipped({ ExPolygon(circle) }, get_collision(node.is_sharp_tail && node.distance_to_top <= 0));
|
// area = diff_clipped({ ExPolygon(circle) }, get_collision(node.is_sharp_tail && node.distance_to_top <= 0));
|
||||||
|
|
||||||
if (!area.empty()) has_circle_node = true;
|
if (!area.empty()) has_circle_node = true;
|
||||||
if (node.need_extra_wall) need_extra_wall = true;
|
|
||||||
|
|
||||||
// merge overhang to get a smoother interface surface
|
// merge overhang to get a smoother interface surface
|
||||||
// Do not merge when buildplate_only is on, because some underneath nodes may have been deleted.
|
// Do not merge when buildplate_only is on, because some underneath nodes may have been deleted.
|
||||||
@@ -2294,6 +2295,10 @@ void TreeSupport::draw_circles()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the Pythagorean theorem to compare movement magnitude (dx² + dy²) without a square root.
|
||||||
|
if (node.is_processed && node.movement.cast<double>().squaredNorm() > max_move_sq)
|
||||||
|
append(extra_wall_areas, area);
|
||||||
|
|
||||||
if (obj_layer_nr>0 && node.distance_to_top < 0)
|
if (obj_layer_nr>0 && node.distance_to_top < 0)
|
||||||
append(roof_gap_areas, area);
|
append(roof_gap_areas, area);
|
||||||
else if (obj_layer_nr > 0 && node.support_roof_layers_below == 1 &&
|
else if (obj_layer_nr > 0 && node.support_roof_layers_below == 1 &&
|
||||||
@@ -2530,7 +2535,8 @@ void TreeSupport::draw_circles()
|
|||||||
//if (area(expoly) < SQ(scale_(1))) continue;
|
//if (area(expoly) < SQ(scale_(1))) continue;
|
||||||
area_groups.emplace_back(&expoly, SupportLayer::BaseType, max_layers_above_base);
|
area_groups.emplace_back(&expoly, SupportLayer::BaseType, max_layers_above_base);
|
||||||
area_groups.back().need_infill = overlaps({ expoly }, area_poly);
|
area_groups.back().need_infill = overlaps({ expoly }, area_poly);
|
||||||
area_groups.back().need_extra_wall = need_extra_wall && !area_groups.back().need_infill;
|
area_groups.back().need_extra_wall = !area_groups.back().need_infill &&
|
||||||
|
!extra_wall_areas.empty() && overlaps({expoly}, extra_wall_areas);
|
||||||
}
|
}
|
||||||
for (size_t roof_idx = 0; roof_idx < ts_layer->roof_areas.size(); ++roof_idx) {
|
for (size_t roof_idx = 0; roof_idx < ts_layer->roof_areas.size(); ++roof_idx) {
|
||||||
auto &expoly = ts_layer->roof_areas[roof_idx];
|
auto &expoly = ts_layer->roof_areas[roof_idx];
|
||||||
@@ -3433,11 +3439,6 @@ void TreeSupport::smooth_nodes()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float max_move = scale_(m_object_config->support_line_width / 2);
|
|
||||||
// if the branch is very tall, the tip also needs extra wall
|
|
||||||
float thresh_tall_branch = 100;
|
|
||||||
float thresh_dist_to_top = 30;
|
|
||||||
|
|
||||||
for (int layer_nr = 0; layer_nr< contact_nodes.size(); layer_nr++) {
|
for (int layer_nr = 0; layer_nr< contact_nodes.size(); layer_nr++) {
|
||||||
std::vector<SupportNode *> &curr_layer_nodes = contact_nodes[layer_nr];
|
std::vector<SupportNode *> &curr_layer_nodes = contact_nodes[layer_nr];
|
||||||
if (curr_layer_nodes.empty()) continue;
|
if (curr_layer_nodes.empty()) continue;
|
||||||
@@ -3447,20 +3448,17 @@ void TreeSupport::smooth_nodes()
|
|||||||
std::vector<double> radii;
|
std::vector<double> radii;
|
||||||
std::vector<SupportNode *> branch;
|
std::vector<SupportNode *> branch;
|
||||||
SupportNode * p_node = node;
|
SupportNode * p_node = node;
|
||||||
float total_height = 0;
|
|
||||||
// add a fixed head if it's not a polygon node, see STUDIO-4403
|
// add a fixed head if it's not a polygon node, see STUDIO-4403
|
||||||
// Polygon node can't be added because the move distance might be huge, making the nodes in between jump and dangling
|
// Polygon node can't be added because the move distance might be huge, making the nodes in between jump and dangling
|
||||||
if (node->child && node->child->type!=ePolygon) {
|
if (node->child && node->child->type!=ePolygon) {
|
||||||
pts.push_back(p_node->child->position);
|
pts.push_back(p_node->child->position);
|
||||||
radii.push_back(p_node->child->radius);
|
radii.push_back(p_node->child->radius);
|
||||||
branch.push_back(p_node->child);
|
branch.push_back(p_node->child);
|
||||||
total_height += p_node->child->height;
|
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
pts.push_back(p_node->position);
|
pts.push_back(p_node->position);
|
||||||
radii.push_back(p_node->radius);
|
radii.push_back(p_node->radius);
|
||||||
branch.push_back(p_node);
|
branch.push_back(p_node);
|
||||||
total_height += p_node->height;
|
|
||||||
p_node = p_node->parent;
|
p_node = p_node->parent;
|
||||||
} while (p_node && !p_node->is_processed);
|
} while (p_node && !p_node->is_processed);
|
||||||
if (pts.size() < 3) continue;
|
if (pts.size() < 3) continue;
|
||||||
@@ -3479,9 +3477,6 @@ void TreeSupport::smooth_nodes()
|
|||||||
branch[i]->radius = radii1[i];
|
branch[i]->radius = radii1[i];
|
||||||
branch[i]->movement = (pts[i + 1] - pts[i - 1]) / 2;
|
branch[i]->movement = (pts[i + 1] - pts[i - 1]) / 2;
|
||||||
branch[i]->is_processed = true;
|
branch[i]->is_processed = true;
|
||||||
if (branch[i]->parents.size() > 1 || (branch[i]->movement.x() > max_move || branch[i]->movement.y() > max_move) ||
|
|
||||||
(total_height > thresh_tall_branch && branch[i]->dist_mm_to_top < thresh_dist_to_top))
|
|
||||||
branch[i]->need_extra_wall = true;
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "smooth_nodes: layer_nr=" << layer_nr << ", i=" << i << ", pt=" << pt << ", movement=" << branch[i]->movement << ", radius=" << branch[i]->radius;
|
BOOST_LOG_TRIVIAL(info) << "smooth_nodes: layer_nr=" << layer_nr << ", i=" << i << ", pt=" << pt << ", movement=" << branch[i]->movement << ", radius=" << branch[i]->radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3489,13 +3484,6 @@ void TreeSupport::smooth_nodes()
|
|||||||
std::swap(pts, pts1);
|
std::swap(pts, pts1);
|
||||||
std::swap(radii, radii1);
|
std::swap(radii, radii1);
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
// interpolate need_extra_wall in the end
|
|
||||||
for (size_t i = 1; i < branch.size() - 1; i++) {
|
|
||||||
if (branch[i - 1]->need_extra_wall && branch[i + 1]->need_extra_wall)
|
|
||||||
branch[i]->need_extra_wall = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ struct SupportNode
|
|||||||
TreeNodeType type = eCircle;
|
TreeNodeType type = eCircle;
|
||||||
bool is_corner = false;
|
bool is_corner = false;
|
||||||
bool is_processed = false;
|
bool is_processed = false;
|
||||||
bool need_extra_wall = false;
|
|
||||||
bool is_sharp_tail = false;
|
bool is_sharp_tail = false;
|
||||||
bool valid = true;
|
bool valid = true;
|
||||||
ExPolygon overhang; // when type==ePolygon, set this value to get original overhang area
|
ExPolygon overhang; // when type==ePolygon, set this value to get original overhang area
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#include "GUI.hpp"
|
#include "GUI.hpp"
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
#include "I18N.hpp"
|
#include "I18N.hpp"
|
||||||
|
#include "ImGuiWrapper.hpp"
|
||||||
#include "MainFrame.hpp"
|
#include "MainFrame.hpp"
|
||||||
#include "NativeCommands.hpp"
|
#include "NativeCommands.hpp"
|
||||||
#include "Notebook.hpp"
|
#include "Notebook.hpp"
|
||||||
|
#include "NotificationManager.hpp"
|
||||||
#include "OptionsGroup.hpp"
|
#include "OptionsGroup.hpp"
|
||||||
#include "Plater.hpp"
|
#include "Plater.hpp"
|
||||||
#include "SettingsIndex.hpp"
|
#include "SettingsIndex.hpp"
|
||||||
@@ -99,6 +101,45 @@ std::string find_loaded_source_name(PluginManager& manager, const std::string& p
|
|||||||
return plugin_key;
|
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
|
// 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.
|
// (identity), so a plugin display-name change does not re-key the action.
|
||||||
struct PluginScriptAction : AppAction
|
struct PluginScriptAction : AppAction
|
||||||
@@ -124,15 +165,36 @@ struct PluginScriptAction : AppAction
|
|||||||
|
|
||||||
AppActionRunResult run(const std::string& /*param*/) const override
|
AppActionRunResult run(const std::string& /*param*/) const override
|
||||||
{
|
{
|
||||||
std::string error;
|
struct Outcome
|
||||||
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
|
{
|
||||||
if (!error.empty())
|
bool async = false;
|
||||||
return {AppActionRunResult::Level::Error, from_u8(error)};
|
bool completed = false;
|
||||||
|
AppActionRunResult result;
|
||||||
|
};
|
||||||
|
auto outcome = std::make_shared<Outcome>();
|
||||||
|
|
||||||
const bool skipped = result.status == PluginResult::Skipped;
|
auto on_done = [outcome](const ScriptCompletion& completion) {
|
||||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
outcome->result = script_outcome_to_action_result(completion);
|
||||||
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
|
outcome->completed = true;
|
||||||
result.message.empty() ? fallback : from_u8(result.message)};
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ DockPanel::DockPanel(wxWindow* parent,
|
|||||||
const std::string& html,
|
const std::string& html,
|
||||||
MessageHandler on_message,
|
MessageHandler on_message,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed)
|
||||||
const wxString& profile)
|
: WebPanel(parent, web_hosting::orca_bridge_script())
|
||||||
: WebPanel(parent, web_hosting::orca_bridge_script(), profile)
|
|
||||||
, m_html(html)
|
, m_html(html)
|
||||||
, m_on_message(std::move(on_message))
|
, m_on_message(std::move(on_message))
|
||||||
, m_on_close(std::move(on_close))
|
, m_on_close(std::move(on_close))
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <wx/string.h>
|
|
||||||
|
|
||||||
class wxWindow;
|
class wxWindow;
|
||||||
|
|
||||||
namespace Slic3r { namespace GUI {
|
namespace Slic3r { namespace GUI {
|
||||||
@@ -29,8 +27,7 @@ public:
|
|||||||
const std::string& html,
|
const std::string& html,
|
||||||
MessageHandler on_message,
|
MessageHandler on_message,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed);
|
||||||
const wxString& profile = wxEmptyString);
|
|
||||||
~DockPanel() override;
|
~DockPanel() override;
|
||||||
|
|
||||||
// Main thread only.
|
// Main thread only.
|
||||||
|
|||||||
@@ -1332,6 +1332,21 @@ boost::any ConfigOptionsGroup::get_config_value2(const DynamicPrintConfig& confi
|
|||||||
return ret;
|
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* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index)
|
||||||
{
|
{
|
||||||
Field* field = get_field(opt_key);
|
Field* field = get_field(opt_key);
|
||||||
|
|||||||
@@ -119,6 +119,37 @@ using column_t = std::function<wxWindow*(wxWindow* parent, const Line&)>;
|
|||||||
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
||||||
using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
|
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 {
|
class OptionsGroup {
|
||||||
public:
|
public:
|
||||||
const bool staticbox{true};
|
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);
|
boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||||
// BBS: restore all pages in preset
|
// BBS: restore all pages in preset
|
||||||
boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
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);
|
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*/);
|
std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
|
||||||
|
|
||||||
|
|||||||
@@ -1158,10 +1158,7 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
|||||||
{
|
{
|
||||||
PluginManager& manager = PluginManager::instance();
|
PluginManager& manager = PluginManager::instance();
|
||||||
|
|
||||||
std::string error;
|
auto complete_with_error = [this, &manager, plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
||||||
ExecutionResult result;
|
|
||||||
|
|
||||||
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;
|
const std::string normalized_error = plugin_error.empty() ? _u8L("Script plugin failed.") : plugin_error;
|
||||||
if (!manager.set_plugin_error(plugin_key, normalized_error))
|
if (!manager.set_plugin_error(plugin_key, normalized_error))
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
||||||
@@ -1177,20 +1174,29 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
|||||||
show_status(message, "error");
|
show_status(message, "error");
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
|
||||||
wxBusyCursor busy;
|
// the runtime delivers it later; it is called inline here only when the launch itself failed.
|
||||||
result = manager.run_script_capability(plugin_key, capability_name, error);
|
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
|
||||||
}
|
if (!alive->load())
|
||||||
|
return;
|
||||||
|
|
||||||
if (!error.empty()) {
|
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
|
||||||
complete_with_error(error, wxString());
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
const ExecutionResult& result = completion.result;
|
||||||
<< " 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();
|
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) {
|
if (failed) {
|
||||||
complete_with_error(result.message, wxString());
|
complete_with_error(result.message, wxString());
|
||||||
return;
|
return;
|
||||||
@@ -1203,6 +1209,18 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
|||||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
||||||
show_status(message, skipped ? "info" : "success");
|
show_status(message, skipped ? "info" : "success");
|
||||||
|
};
|
||||||
|
|
||||||
|
PluginManager::ScriptLaunchResult launch;
|
||||||
|
{
|
||||||
|
wxBusyCursor busy;
|
||||||
|
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every started invocation — a synchronous `def execute` included — completes later through
|
||||||
|
// `finish`; only a launch failure is reported inline. Return without blocking.
|
||||||
|
if (!launch.launched_async)
|
||||||
|
finish(launch.outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
||||||
|
|||||||
@@ -474,17 +474,14 @@ void Selection::add_curr_plate()
|
|||||||
if (!m_valid)
|
if (!m_valid)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_plate(wxGetApp().plater()->get_partplate_list().get_curr_plate_index());
|
||||||
|
if (volume_idxs.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
wxGetApp().plater()->take_snapshot(std::string("Selection-Add Curr Plate All!"));
|
wxGetApp().plater()->take_snapshot(std::string("Selection-Add Curr Plate All!"));
|
||||||
m_mode = Instance;
|
m_mode = Instance;
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
|
|
||||||
for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) {
|
|
||||||
if (plate && plate->contain_instance_totally(obj_idx, 0)) {
|
|
||||||
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_object(obj_idx);
|
|
||||||
do_add_volumes(volume_idxs);
|
do_add_volumes(volume_idxs);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update_type();
|
update_type();
|
||||||
this->set_bounding_boxes_dirty();
|
this->set_bounding_boxes_dirty();
|
||||||
@@ -511,20 +508,14 @@ void Selection::remove_curr_plate()
|
|||||||
if (!m_valid)
|
if (!m_valid)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
|
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_plate(wxGetApp().plater()->get_partplate_list().get_curr_plate_index());
|
||||||
if (plate->empty())
|
if (volume_idxs.empty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
wxGetApp().plater()->take_snapshot(std::string("Selection-Delete Curr Plate All"));
|
wxGetApp().plater()->take_snapshot(std::string("Selection-Delete Curr Plate All"));
|
||||||
m_mode = Instance;
|
m_mode = Instance;
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) {
|
|
||||||
if (plate && plate->contain_instance(obj_idx, 0)) {
|
|
||||||
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_object(obj_idx);
|
|
||||||
do_add_volumes(volume_idxs);
|
do_add_volumes(volume_idxs);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update_type();
|
update_type();
|
||||||
this->set_bounding_boxes_dirty();
|
this->set_bounding_boxes_dirty();
|
||||||
@@ -2270,6 +2261,27 @@ std::vector<unsigned int> Selection::get_volume_idxs_from_volume(unsigned int ob
|
|||||||
return idxs;
|
return idxs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<unsigned int> Selection::get_volume_idxs_from_plate(const int plate_idx) const
|
||||||
|
{
|
||||||
|
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx);
|
||||||
|
std::vector<unsigned int> idxs;
|
||||||
|
if (!plate)
|
||||||
|
return idxs;
|
||||||
|
|
||||||
|
for (int obj_idx = 0; obj_idx < (int)m_model->objects.size(); obj_idx++) {
|
||||||
|
const ModelObject* obj = m_model->objects[obj_idx];
|
||||||
|
for (int inst_idx = 0; inst_idx < (int)obj->instances.size(); inst_idx++) {
|
||||||
|
// on this plate or overlapping it in some way (fully inside, oversized or partly outside)
|
||||||
|
if (plate->contain_instance(obj_idx, inst_idx) || plate->intersect_instance(obj_idx, inst_idx)) {
|
||||||
|
std::vector<unsigned int> inst_idxs = get_volume_idxs_from_instance(obj_idx, inst_idx);
|
||||||
|
idxs.insert(idxs.end(), inst_idxs.begin(), inst_idxs.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idxs;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<unsigned int> Selection::get_missing_volume_idxs_from(const std::vector<unsigned int>& volume_idxs) const
|
std::vector<unsigned int> Selection::get_missing_volume_idxs_from(const std::vector<unsigned int>& volume_idxs) const
|
||||||
{
|
{
|
||||||
std::vector<unsigned int> idxs;
|
std::vector<unsigned int> idxs;
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ public:
|
|||||||
std::vector<unsigned int> get_volume_idxs_from_instance(unsigned int object_idx, unsigned int instance_idx) const;
|
std::vector<unsigned int> get_volume_idxs_from_instance(unsigned int object_idx, unsigned int instance_idx) const;
|
||||||
// returns the idx of the volume corresponding to the volume with the given idxs
|
// returns the idx of the volume corresponding to the volume with the given idxs
|
||||||
std::vector<unsigned int> get_volume_idxs_from_volume(unsigned int object_idx, unsigned int instance_idx, unsigned int volume_idx) const;
|
std::vector<unsigned int> get_volume_idxs_from_volume(unsigned int object_idx, unsigned int instance_idx, unsigned int volume_idx) const;
|
||||||
|
// returns the list of idxs of the volumes of all instances on the given plate or overlapping it. (fully inside, oversized or partly outside)
|
||||||
|
std::vector<unsigned int> get_volume_idxs_from_plate(const int plate_idx) const;
|
||||||
// returns the list of idxs of the volumes contained in the selection but not in the given list
|
// returns the list of idxs of the volumes contained in the selection but not in the given list
|
||||||
std::vector<unsigned int> get_missing_volume_idxs_from(const std::vector<unsigned int>& volume_idxs) const;
|
std::vector<unsigned int> get_missing_volume_idxs_from(const std::vector<unsigned int>& volume_idxs) const;
|
||||||
// returns the list of idxs of the volumes contained in the given list but not in the selection
|
// returns the list of idxs of the volumes contained in the given list but not in the selection
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
#include <stdexcept>
|
||||||
#include "libslic3r/LifecycleEvents.hpp"
|
#include "libslic3r/LifecycleEvents.hpp"
|
||||||
#include "libslic3r/Point.hpp"
|
#include "libslic3r/Point.hpp"
|
||||||
#include "slic3r/GUI/SettingsIndex.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.
|
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
|
||||||
void Tab::reload_config()
|
void Tab::reload_config()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -424,6 +424,40 @@ public:
|
|||||||
//BBS update plater presets if update_plater_presets = true
|
//BBS update plater presets if update_plater_presets = true
|
||||||
void update_tab_ui(bool update_plater_presets = false);
|
void update_tab_ui(bool update_plater_presets = false);
|
||||||
void load_config(const DynamicPrintConfig& config);
|
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();
|
virtual void reload_config();
|
||||||
void update_mode();
|
void update_mode();
|
||||||
void update_visibility();
|
void update_visibility();
|
||||||
@@ -507,6 +541,12 @@ protected:
|
|||||||
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
|
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
|
||||||
void compatible_widget_reload(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);
|
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
|
//BBS: GUI refactor
|
||||||
// return true if cancelled
|
// return true if cancelled
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ WebDialog::WebDialog(wxWindow* parent,
|
|||||||
SubmitHandler on_submit,
|
SubmitHandler on_submit,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed,
|
||||||
long wx_style,
|
long wx_style)
|
||||||
const wxString& profile)
|
|
||||||
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
|
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
|
||||||
, m_html(html)
|
, m_html(html)
|
||||||
, m_on_message(std::move(on_message))
|
, m_on_message(std::move(on_message))
|
||||||
@@ -38,7 +37,7 @@ WebDialog::WebDialog(wxWindow* parent,
|
|||||||
{
|
{
|
||||||
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML
|
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML
|
||||||
// is swapped in via SetPage once the bootstrap finishes loading.
|
// is swapped in via SetPage once the bootstrap finishes loading.
|
||||||
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240), profile);
|
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
|
||||||
|
|
||||||
// Paint the window/webview in the themed background so there is no white
|
// Paint the window/webview in the themed background so there is no white
|
||||||
// flash before the (transparent) bootstrap page and plugin HTML render.
|
// flash before the (transparent) bootstrap page and plugin HTML render.
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ public:
|
|||||||
SubmitHandler on_submit,
|
SubmitHandler on_submit,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed,
|
||||||
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER,
|
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
|
||||||
const wxString& profile = wxEmptyString);
|
|
||||||
~WebDialog() override;
|
~WebDialog() override;
|
||||||
|
|
||||||
static void post_message(WebDialog* dialog, const nlohmann::json& data);
|
static void post_message(WebDialog* dialog, const nlohmann::json& data);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
namespace Slic3r { namespace GUI {
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile)
|
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script)
|
||||||
: wxPanel(parent, wxID_ANY)
|
: wxPanel(parent, wxID_ANY)
|
||||||
{
|
{
|
||||||
SetBackgroundColour(wxGetApp().get_window_default_clr());
|
SetBackgroundColour(wxGetApp().get_window_default_clr());
|
||||||
@@ -25,7 +25,7 @@ WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString&
|
|||||||
SetSizer(sizer);
|
SetSizer(sizer);
|
||||||
|
|
||||||
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
|
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
|
||||||
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url(), profile);
|
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url());
|
||||||
m_browser->SetBackgroundColour(GetBackgroundColour());
|
m_browser->SetBackgroundColour(GetBackgroundColour());
|
||||||
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
|
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
|
||||||
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
|
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
#include <wx/event.h>
|
#include <wx/event.h>
|
||||||
#include <wx/panel.h>
|
#include <wx/panel.h>
|
||||||
#include <wx/string.h>
|
|
||||||
#include <wx/webview.h>
|
#include <wx/webview.h>
|
||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -23,7 +22,7 @@ namespace Slic3r { namespace GUI {
|
|||||||
class WebPanel : public wxPanel
|
class WebPanel : public wxPanel
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile = wxEmptyString);
|
WebPanel(wxWindow* parent, const char* bridge_script);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
wxWebView* browser() const { return m_browser; }
|
wxWebView* browser() const { return m_browser; }
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
#include "slic3r/Utils/MacDarkMode.hpp"
|
#include "slic3r/Utils/MacDarkMode.hpp"
|
||||||
|
|
||||||
#include <libslic3r/Utils.hpp>
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
@@ -14,7 +12,6 @@
|
|||||||
#include <exception>
|
#include <exception>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
#include <wx/chartype.h>
|
|
||||||
#include <wx/setup.h>
|
#include <wx/setup.h>
|
||||||
#include <wx/webview.h>
|
#include <wx/webview.h>
|
||||||
#include <wx/string.h>
|
#include <wx/string.h>
|
||||||
@@ -84,26 +81,6 @@ webkit_get_minor_version (void);
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Private storage root for an isolated (plugin) profile. Plugin pages must not reach the shared
|
|
||||||
// default store's cookies/session (e.g. the OrcaCloud login), so each profile gets its own dir.
|
|
||||||
// macOS isolates with an in-memory store instead (see CreateWebView), so this is unused there.
|
|
||||||
#if !defined(__WXOSX__)
|
|
||||||
static wxString isolated_profile_path(const wxString& profile)
|
|
||||||
{
|
|
||||||
wxString safe;
|
|
||||||
safe.reserve(profile.size());
|
|
||||||
for (wxString::const_iterator it = profile.begin(); it != profile.end(); ++it) {
|
|
||||||
const wxChar c = *it;
|
|
||||||
const bool keep = (c >= wxT('a') && c <= wxT('z')) || (c >= wxT('A') && c <= wxT('Z')) ||
|
|
||||||
(c >= wxT('0') && c <= wxT('9')) || c == wxT('.') || c == wxT('-') || c == wxT('_');
|
|
||||||
safe += keep ? c : wxT('_');
|
|
||||||
}
|
|
||||||
if (safe.empty())
|
|
||||||
safe = wxT("default");
|
|
||||||
return wxString::FromUTF8((boost::filesystem::path(Slic3r::data_dir()) / "web_profiles" / safe.ToStdString()).string());
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef __WIN32__
|
#ifdef __WIN32__
|
||||||
// Run Download and Install in another thread so we don't block the UI thread
|
// Run Download and Install in another thread so we don't block the UI thread
|
||||||
DWORD DownloadAndInstallWV2RT() {
|
DWORD DownloadAndInstallWV2RT() {
|
||||||
@@ -167,9 +144,6 @@ DWORD DownloadAndInstallWV2RT() {
|
|||||||
class WebViewEdge : public wxWebViewEdge
|
class WebViewEdge : public wxWebViewEdge
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
WebViewEdge() = default;
|
|
||||||
explicit WebViewEdge(const wxWebViewConfiguration& config) : wxWebViewEdge(config) {}
|
|
||||||
|
|
||||||
bool SetUserAgent(const wxString &userAgent) override
|
bool SetUserAgent(const wxString &userAgent) override
|
||||||
{
|
{
|
||||||
bool dark = userAgent.Contains("dark");
|
bool dark = userAgent.Contains("dark");
|
||||||
@@ -248,11 +222,6 @@ public:
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
explicit WebViewWebKit(const wxWebViewConfiguration& config)
|
|
||||||
: wxWebViewWebKit(config)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
~WebViewWebKit() override
|
~WebViewWebKit() override
|
||||||
{
|
{
|
||||||
RemoveScriptMessageHandler("wx");
|
RemoveScriptMessageHandler("wx");
|
||||||
@@ -341,7 +310,7 @@ static wxString linux_user_agent()
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxString const & profile)
|
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url)
|
||||||
{
|
{
|
||||||
#if wxUSE_WEBVIEW_EDGE
|
#if wxUSE_WEBVIEW_EDGE
|
||||||
// Check if a fixed version of edge is present in
|
// Check if a fixed version of edge is present in
|
||||||
@@ -361,36 +330,12 @@ wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxStr
|
|||||||
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
|
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
|
||||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8();
|
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8();
|
||||||
|
|
||||||
const bool isolated = !profile.IsEmpty();
|
|
||||||
|
|
||||||
#ifdef __WIN32__
|
#ifdef __WIN32__
|
||||||
wxWebView* webView = nullptr;
|
wxWebView* webView = new WebViewEdge;
|
||||||
if (isolated) {
|
|
||||||
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendEdge);
|
|
||||||
config.SetDataPath(isolated_profile_path(profile));
|
|
||||||
webView = new WebViewEdge(config);
|
|
||||||
} else {
|
|
||||||
webView = new WebViewEdge;
|
|
||||||
}
|
|
||||||
#elif defined(__WXOSX__)
|
#elif defined(__WXOSX__)
|
||||||
wxWebView* webView = nullptr;
|
wxWebView* webView = new WebViewWebKit;
|
||||||
if (isolated) {
|
|
||||||
// wx has no SetDataPath on macOS; give the view its own in-memory store instead.
|
|
||||||
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
|
|
||||||
Slic3r::GUI::WKWebViewConfiguration_setNonPersistentDataStore(config.GetNativeConfiguration());
|
|
||||||
webView = new WebViewWebKit(config);
|
|
||||||
} else {
|
|
||||||
webView = new WebViewWebKit;
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
wxWebView* webView = nullptr;
|
auto webView = wxWebView::New();
|
||||||
if (isolated) {
|
|
||||||
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
|
|
||||||
config.SetDataPath(isolated_profile_path(profile));
|
|
||||||
webView = wxWebView::New(config);
|
|
||||||
} else {
|
|
||||||
webView = wxWebView::New();
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (webView) {
|
if (webView) {
|
||||||
webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
|
webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ wxDECLARE_EVENT(EVT_WEBVIEW_RECREATED, wxCommandEvent);
|
|||||||
class WebView
|
class WebView
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
// `profile` empty -> the shared default backend store used by all trusted UI.
|
static wxWebView *CreateWebView(wxWindow *parent, wxString const &url);
|
||||||
// `profile` non-empty -> a private store keyed by `profile`, so this view shares no
|
|
||||||
// cookies/storage/session with the shared store or any other profile. Used for
|
|
||||||
// plugin-supplied (untrusted) content.
|
|
||||||
static wxWebView *CreateWebView(wxWindow *parent, wxString const &url, wxString const &profile = wxEmptyString);
|
|
||||||
#if wxUSE_WEBVIEW_EDGE
|
#if wxUSE_WEBVIEW_EDGE
|
||||||
static bool CheckWebViewRuntime();
|
static bool CheckWebViewRuntime();
|
||||||
static bool DownloadAndInstallWebViewRuntime();
|
static bool DownloadAndInstallWebViewRuntime();
|
||||||
|
|||||||
@@ -172,8 +172,7 @@ WebViewHostDialog::WebViewHostDialog(wxWindow* parent,
|
|||||||
bool WebViewHostDialog::create_webview(const std::string& resource_path,
|
bool WebViewHostDialog::create_webview(const std::string& resource_path,
|
||||||
const wxString& title,
|
const wxString& title,
|
||||||
const wxSize& dialog_size,
|
const wxSize& dialog_size,
|
||||||
const wxSize& min_size,
|
const wxSize& min_size)
|
||||||
const wxString& profile)
|
|
||||||
{
|
{
|
||||||
SetTitle(title);
|
SetTitle(title);
|
||||||
SetMinSize(FromDIP(min_size));
|
SetMinSize(FromDIP(min_size));
|
||||||
@@ -182,7 +181,7 @@ bool WebViewHostDialog::create_webview(const std::string& resource_path,
|
|||||||
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
|
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
|
||||||
|
|
||||||
// Created empty and loaded below, after the user scripts are added, so the first load already has them.
|
// Created empty and loaded below, after the user scripts are added, so the first load already has them.
|
||||||
m_browser = WebView::CreateWebView(this, wxEmptyString, profile);
|
m_browser = WebView::CreateWebView(this, wxEmptyString);
|
||||||
if (m_browser == nullptr) {
|
if (m_browser == nullptr) {
|
||||||
wxLogError("Could not init m_browser");
|
wxLogError("Could not init m_browser");
|
||||||
delete topsizer;
|
delete topsizer;
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ public:
|
|||||||
bool create_webview(const std::string& resource_path,
|
bool create_webview(const std::string& resource_path,
|
||||||
const wxString& title,
|
const wxString& title,
|
||||||
const wxSize& dialog_size = wxSize(820, 660),
|
const wxSize& dialog_size = wxSize(820, 660),
|
||||||
const wxSize& min_size = wxSize(640, 640),
|
const wxSize& min_size = wxSize(640, 640));
|
||||||
const wxString& profile = wxEmptyString);
|
|
||||||
|
|
||||||
void load_url(const wxString& url);
|
void load_url(const wxString& url);
|
||||||
bool run_script(const wxString& script);
|
bool run_script(const wxString& script);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ extern double mac_max_scaling_factor();
|
|||||||
extern void set_miniaturizable(void * window);
|
extern void set_miniaturizable(void * window);
|
||||||
void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &));
|
void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &));
|
||||||
void WKWebView_setTransparentBackground(void * web);
|
void WKWebView_setTransparentBackground(void * web);
|
||||||
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration);
|
|
||||||
void WKWebView_force_display(void * web);
|
void WKWebView_force_display(void * web);
|
||||||
void set_tag_when_enter_full_screen(bool isfullscreen);
|
void set_tag_when_enter_full_screen(bool isfullscreen);
|
||||||
void set_title_colour_after_set_title(void * window);
|
void set_title_colour_after_set_title(void * window);
|
||||||
|
|||||||
@@ -102,15 +102,6 @@ void WKWebView_setTransparentBackground(void * web)
|
|||||||
[webView registerForDraggedTypes: @[NSFilenamesPboardType]];
|
[webView registerForDraggedTypes: @[NSFilenamesPboardType]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give a WKWebViewConfiguration its own in-memory website data store, so the view's cookies and
|
|
||||||
// storage neither come from nor leak into the shared default store. Used to isolate plugin web views.
|
|
||||||
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration)
|
|
||||||
{
|
|
||||||
WKWebViewConfiguration * config = (WKWebViewConfiguration*)configuration;
|
|
||||||
if (config)
|
|
||||||
config.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force a WKWebView to re-lay-out and repaint. Needed for chrome-less popups: the window is
|
// Force a WKWebView to re-lay-out and repaint. Needed for chrome-less popups: the window is
|
||||||
// transparent, so a WKWebView whose layer has no pending frame leaves the whole window invisible
|
// transparent, so a WKWebView whose layer has no pending frame leaves the whole window invisible
|
||||||
// until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the
|
// until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ void install_slicing_pipeline_hook()
|
|||||||
ctx.step = step;
|
ctx.step = step;
|
||||||
ctx.print = &print;
|
ctx.print = &print;
|
||||||
ctx.object = object;
|
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);
|
r = cap->execute(ctx);
|
||||||
} catch (const CanceledException&) {
|
} catch (const CanceledException&) {
|
||||||
throw; // cancellation must reach process(), never become a slicing error
|
throw; // cancellation must reach process(), never become a slicing error
|
||||||
|
|||||||
@@ -12,11 +12,16 @@
|
|||||||
|
|
||||||
#include "PluginFsUtils.hpp"
|
#include "PluginFsUtils.hpp"
|
||||||
#include "PluginHooks.hpp"
|
#include "PluginHooks.hpp"
|
||||||
|
#include "PluginAuditManager.hpp"
|
||||||
|
#include "PyPluginTrampoline.hpp"
|
||||||
#include "PythonInterpreter.hpp"
|
#include "PythonInterpreter.hpp"
|
||||||
#include "PythonPluginBridge.hpp"
|
#include "PythonPluginBridge.hpp"
|
||||||
|
|
||||||
#include "OrcaCloudServiceAgent.hpp"
|
#include "OrcaCloudServiceAgent.hpp"
|
||||||
#include "libslic3r/Semver.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/GUI_App.hpp"
|
||||||
#include "slic3r/GUI/I18N.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
#include "slic3r/GUI/NotificationManager.hpp"
|
#include "slic3r/GUI/NotificationManager.hpp"
|
||||||
@@ -41,6 +46,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <wx/app.h>
|
#include <wx/app.h>
|
||||||
#include <wx/thread.h>
|
#include <wx/thread.h>
|
||||||
|
#include <wx/utils.h>
|
||||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||||
#include "slic3r/GUI/Plater.hpp"
|
#include "slic3r/GUI/Plater.hpp"
|
||||||
|
|
||||||
@@ -68,8 +74,10 @@ void Plugin::release_module()
|
|||||||
PluginManager& PluginManager::instance()
|
PluginManager& PluginManager::instance()
|
||||||
{
|
{
|
||||||
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
// 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();
|
PythonInterpreter::instance();
|
||||||
|
ScriptAsyncRuntime::instance();
|
||||||
static PluginManager inst;
|
static PluginManager inst;
|
||||||
return inst;
|
return inst;
|
||||||
}
|
}
|
||||||
@@ -101,6 +109,9 @@ bool PluginManager::initialize()
|
|||||||
m_initialized = true;
|
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
|
// Bring every capability's stored config into memory. Deliberately unconditional and
|
||||||
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
// 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
|
// 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();
|
unload_all_plugins();
|
||||||
PythonPluginBridge::instance().clear_pending_captures();
|
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
|
// 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
|
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
||||||
// plugins above must never discard their stored config.
|
// 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(
|
void PluginManager::cancel_and_wait_for_capabilities(
|
||||||
const std::vector<std::shared_ptr<PluginCapabilityInterface>>& 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) {
|
for (const auto& capability : capabilities) {
|
||||||
if (!capability || !capability->is_enabled())
|
if (!capability || !capability->is_enabled())
|
||||||
continue;
|
continue;
|
||||||
@@ -769,15 +788,37 @@ void PluginManager::cancel_and_wait_for_capabilities(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
for (const auto& capability : capabilities) {
|
||||||
while (std::chrono::steady_clock::now() < deadline) {
|
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) {
|
const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
||||||
return !capability || capability->ref_count() == 0;
|
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;
|
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) {
|
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);
|
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)
|
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()) {
|
ExecutionResult result;
|
||||||
return {};
|
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});
|
auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
|
||||||
if (!cap)
|
if (!cap)
|
||||||
return {};
|
return launch;
|
||||||
|
|
||||||
auto cap_interface = std::dynamic_pointer_cast<ScriptPluginCapability>(cap);
|
if (!std::dynamic_pointer_cast<ScriptPluginCapability>(cap))
|
||||||
if (!cap_interface)
|
return launch;
|
||||||
return {};
|
|
||||||
|
|
||||||
ExecutionResult result;
|
// Every Script body runs off the caller's thread now: the invocation — including detection and
|
||||||
try {
|
// a synchronous `def execute` — is handed to the runtime, which runs it on its own worker and
|
||||||
PythonGILState gil;
|
// reports the outcome through the completion callback. Nothing executes before returning here,
|
||||||
if (!gil) {
|
// so the UI thread is never blocked by a Script invocation.
|
||||||
error = "Python interpreter is shutting down";
|
const bool started = ScriptAsyncRuntime::instance().start(
|
||||||
return {};
|
plugin_key, capability_name, cap,
|
||||||
}
|
[this, plugin_key, on_done](const ScriptCompletion& completion) {
|
||||||
result = cap_interface->execute();
|
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination:
|
||||||
} catch (const std::exception& ex) {
|
// record no plugin error and surface nothing. Genuine user/plugin errors still record.
|
||||||
error = ex.what();
|
if (!completion.cancelled && !completion.error.empty())
|
||||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
|
set_plugin_error(plugin_key, completion.error);
|
||||||
} catch (...) {
|
if (on_done)
|
||||||
error = _u8L("Unknown error");
|
on_done(completion);
|
||||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
|
});
|
||||||
|
|
||||||
|
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()) {
|
launch.launched_async = true;
|
||||||
set_plugin_error(plugin_key, error);
|
return launch;
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {
|
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include "pybind11/pytypes.h"
|
||||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -98,6 +99,19 @@ struct Plugin
|
|||||||
void release_module();
|
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
|
class PluginManager
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -214,6 +228,25 @@ public:
|
|||||||
|
|
||||||
ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error);
|
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);
|
void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -309,6 +342,74 @@ private:
|
|||||||
std::atomic<bool> m_shutting_down{false};
|
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`.
|
// Resolve each configured capability reference to a loaded capability of type T and run `execute`.
|
||||||
template<typename T>
|
template<typename T>
|
||||||
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
|
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
#define slic3r_PyPluginTrampoline_hpp_
|
#define slic3r_PyPluginTrampoline_hpp_
|
||||||
|
|
||||||
#include "libslic3r/LifecycleEvents.hpp"
|
#include "libslic3r/LifecycleEvents.hpp"
|
||||||
|
#include <exception>
|
||||||
#include <pybind11/embed.h>
|
#include <pybind11/embed.h>
|
||||||
|
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -14,9 +16,11 @@
|
|||||||
#include "PythonInterpreter.hpp"
|
#include "PythonInterpreter.hpp"
|
||||||
#include "PluginFsUtils.hpp"
|
#include "PluginFsUtils.hpp"
|
||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
|
#include "pybind11/cast.h"
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
#include <pybind11/gil.h>
|
#include <pybind11/gil.h>
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
|
// 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
|
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
|
||||||
@@ -61,6 +65,78 @@
|
|||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
namespace Slic3r {
|
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
|
template<class Base> class PyPluginCommonTrampoline : public Base
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "PluginHostUi.hpp"
|
#include "PluginHostUi.hpp"
|
||||||
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <pybind11/cast.h>
|
#include <pybind11/cast.h>
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
@@ -11,18 +12,21 @@
|
|||||||
#include <libslic3r/Preset.hpp>
|
#include <libslic3r/Preset.hpp>
|
||||||
#include <libslic3r/PresetBundle.hpp>
|
#include <libslic3r/PresetBundle.hpp>
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
#include <slic3r/GUI/GUI.hpp>
|
#include <slic3r/GUI/GUI.hpp>
|
||||||
#include <slic3r/GUI/GUI_App.hpp>
|
#include <slic3r/GUI/GUI_App.hpp>
|
||||||
#include <slic3r/GUI/MainFrame.hpp>
|
#include <slic3r/GUI/MainFrame.hpp>
|
||||||
#include <slic3r/GUI/Plater.hpp>
|
#include <slic3r/GUI/Plater.hpp>
|
||||||
|
#include <slic3r/GUI/PresetComboBoxes.hpp> // TabPresetComboBox::set_filament_idx
|
||||||
#include <slic3r/GUI/Tab.hpp>
|
#include <slic3r/GUI/Tab.hpp>
|
||||||
|
|
||||||
#include <pybind11/stl.h>
|
#include <pybind11/stl.h>
|
||||||
|
|
||||||
|
#include <wx/string.h>
|
||||||
|
|
||||||
|
#include <set>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace py = pybind11;
|
namespace py = pybind11;
|
||||||
@@ -48,6 +52,543 @@ PresetCollection& printer_presets(PresetBundle& bundle)
|
|||||||
return static_cast<PresetCollection&>(bundle.printers);
|
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)
|
void reload_local_bundle(const std::string& bundle_id)
|
||||||
{
|
{
|
||||||
bool reloaded = false;
|
bool reloaded = false;
|
||||||
@@ -119,6 +660,145 @@ void host_bindings::register_presets(py::module_& host)
|
|||||||
.value("Plate", Preset::TYPE_PLATE)
|
.value("Plate", Preset::TYPE_PLATE)
|
||||||
.value("Model", Preset::TYPE_MODEL);
|
.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")
|
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
|
||||||
.def_readonly("type", &Preset::type)
|
.def_readonly("type", &Preset::type)
|
||||||
.def_readonly("name", &Preset::name)
|
.def_readonly("name", &Preset::name)
|
||||||
|
|||||||
@@ -390,8 +390,7 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
|
|||||||
|
|
||||||
auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html,
|
auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html,
|
||||||
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
|
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
|
||||||
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE,
|
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE);
|
||||||
wxString::FromUTF8(plugin_key));
|
|
||||||
UiRegistry::instance().bind(new_id, dlg, plugin_key);
|
UiRegistry::instance().bind(new_id, dlg, plugin_key);
|
||||||
if (modal) {
|
if (modal) {
|
||||||
dlg->ShowModal();
|
dlg->ShowModal();
|
||||||
@@ -488,7 +487,7 @@ py::object ui_create_dock_panel(const std::string& html, const std::string& titl
|
|||||||
|
|
||||||
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
|
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
|
||||||
auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
|
auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
|
||||||
std::move(on_destroyed), wxString::FromUTF8(plugin_key));
|
std::move(on_destroyed));
|
||||||
UiRegistry::instance().bind(new_id, panel, plugin_key);
|
UiRegistry::instance().bind(new_id, panel, plugin_key);
|
||||||
plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
|
plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
|
||||||
wxSize(w, h), [panel]() { panel->fire_close(); });
|
wxSize(w, h), [panel]() { panel->fire_close(); });
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS(
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
|
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
|
||||||
: GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS,
|
: GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS)
|
||||||
wxString::FromUTF8(capability ? capability->identity().plugin_key : std::string()))
|
|
||||||
, m_cap(std::move(capability))
|
, m_cap(std::move(capability))
|
||||||
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
|
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "libslic3r/ClipperUtils.hpp"
|
#include "libslic3r/ClipperUtils.hpp"
|
||||||
#include "libslic3r/ExPolygon.hpp"
|
#include "libslic3r/ExPolygon.hpp"
|
||||||
#include "libslic3r/Point.hpp"
|
#include "libslic3r/Point.hpp"
|
||||||
|
#include "libslic3r/Polygon.hpp"
|
||||||
#include "libslic3r/Polyline.hpp"
|
#include "libslic3r/Polyline.hpp"
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -994,3 +995,384 @@ TEST_CASE("A hole whose outer wall touches the contour's runs with it", "[Perime
|
|||||||
CHECK(layers[i].holes_ccw.front() == ccw);
|
CHECK(layers[i].holes_ccw.front() == ccw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The outer radius of round_tube().
|
||||||
|
const double tube_radius = 11.5;
|
||||||
|
|
||||||
|
// Every setting the tube walls below are measured against. With 0.5mm walls a 1mm tube wall holds only the two
|
||||||
|
// outer walls, touching, and every further 0.5mm makes room for one more inner wall.
|
||||||
|
DynamicPrintConfig tube_config(const char *wall_generator, int wall_loops)
|
||||||
|
{
|
||||||
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||||
|
config.set_deserialize_strict({
|
||||||
|
{ "wall_generator", wall_generator },
|
||||||
|
{ "wall_loops", wall_loops },
|
||||||
|
{ "wall_sequence", "inner wall/outer wall" },
|
||||||
|
{ "wall_direction", "ccw" },
|
||||||
|
{ "layer_height", 0.2 },
|
||||||
|
{ "initial_layer_print_height", 0.2 },
|
||||||
|
{ "nozzle_diameter", "0.4" },
|
||||||
|
{ "outer_wall_line_width", 0.5 },
|
||||||
|
{ "inner_wall_line_width", 0.5 },
|
||||||
|
{ "precise_outer_wall", true },
|
||||||
|
{ "detect_thin_wall", false },
|
||||||
|
{ "only_one_wall_top", false },
|
||||||
|
{ "only_one_wall_first_layer", false },
|
||||||
|
{ "brim_type", "no_brim" },
|
||||||
|
{ "detect_overhang_wall", true },
|
||||||
|
{ "overhang_reverse", false },
|
||||||
|
{ "overhang_reverse_threshold", "0%" },
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 2mm tall round tube whose wall is `wall` mm thick.
|
||||||
|
Print &round_tube(Print &print, Model &model, const DynamicPrintConfig &config, double wall)
|
||||||
|
{
|
||||||
|
ModelObject *object = model.add_object();
|
||||||
|
object->name = "round_tube.stl";
|
||||||
|
object->add_volume(make_cylinder(tube_radius, 2.), ModelVolumeType::MODEL_PART, false);
|
||||||
|
object->add_volume(make_cylinder(tube_radius - wall, 2.), ModelVolumeType::NEGATIVE_VOLUME, false);
|
||||||
|
object->add_instance();
|
||||||
|
object->ensure_on_bed();
|
||||||
|
|
||||||
|
print.auto_assign_extruders(object);
|
||||||
|
print.apply(model, config);
|
||||||
|
print.validate();
|
||||||
|
print.set_status_silent();
|
||||||
|
print.process();
|
||||||
|
return print;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The side of the tube wall a wall loop lies on. The centre is the middle of the tube wall, where Arachne lays the
|
||||||
|
// inner wall both sides share when the tube wall is too narrow for a pair of them.
|
||||||
|
enum class TubeSide { Contour, Hole, Centre };
|
||||||
|
|
||||||
|
struct TubeLoop {
|
||||||
|
TubeSide side;
|
||||||
|
int inset;
|
||||||
|
bool ccw;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The wall loops of every layer of round_tube() in extrusion order.
|
||||||
|
std::vector<std::vector<TubeLoop>> tube_loops(const Print &print, double wall)
|
||||||
|
{
|
||||||
|
const double centre = tube_radius - 0.5 * wall;
|
||||||
|
std::vector<std::vector<TubeLoop>> out;
|
||||||
|
for (const Layer *layer : print.objects().front()->layers()) {
|
||||||
|
const Vec2d axis = get_extents(layer->lslices).center().cast<double>();
|
||||||
|
std::vector<TubeLoop> &loops = out.emplace_back();
|
||||||
|
for (const LayerRegion *region : layer->regions())
|
||||||
|
for (const ExtrusionEntity *entity : region->perimeters.flatten().entities) {
|
||||||
|
if (! entity->is_loop())
|
||||||
|
continue;
|
||||||
|
const ExtrusionLoop *loop = static_cast<const ExtrusionLoop*>(entity);
|
||||||
|
const Polygon polygon = loop->polygon();
|
||||||
|
double radius = 0.;
|
||||||
|
for (const Point &p : polygon.points)
|
||||||
|
radius += unscaled<double>((p.cast<double>() - axis).norm());
|
||||||
|
radius /= double(polygon.points.size());
|
||||||
|
const TubeSide side = radius > centre + 0.1 ? TubeSide::Contour : radius < centre - 0.1 ? TubeSide::Hole : TubeSide::Centre;
|
||||||
|
loops.push_back({ side, loop->inset_idx, polygon.is_counter_clockwise() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The insets one side of a wall is printed in, given its deepest inset: inside out for Inner/Outer, outside in for
|
||||||
|
// Outer/Inner, and for Inner/Outer/Inner the third wall onwards inside out, then the outer wall, then the first inner
|
||||||
|
// wall. Inner/Outer/Inner prints its first layer Inner/Outer, and outside in when there is no third wall.
|
||||||
|
std::vector<int> wall_sequence_insets(const std::string &wall_sequence, bool first_layer, int deepest)
|
||||||
|
{
|
||||||
|
std::vector<int> insets;
|
||||||
|
if (wall_sequence == "inner wall/outer wall" || (wall_sequence == "inner-outer-inner wall" && first_layer)) {
|
||||||
|
for (int inset = deepest; inset >= 0; -- inset)
|
||||||
|
insets.push_back(inset);
|
||||||
|
} else if (wall_sequence == "outer wall/inner wall" || deepest < 2) {
|
||||||
|
for (int inset = 0; inset <= deepest; ++ inset)
|
||||||
|
insets.push_back(inset);
|
||||||
|
} else {
|
||||||
|
for (int inset = deepest; inset >= 2; -- inset)
|
||||||
|
insets.push_back(inset);
|
||||||
|
insets.push_back(0);
|
||||||
|
insets.push_back(1);
|
||||||
|
}
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a wall of round_tube() runs with the contour: all but the walls of the hole, and those too when the hole
|
||||||
|
// is a thin wall hole, its outer wall touching the contour's with nothing between them.
|
||||||
|
bool runs_with_contour(const TubeLoop &loop, bool thin_wall_hole)
|
||||||
|
{
|
||||||
|
return loop.side != TubeSide::Hole || thin_wall_hole;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The insets of the walls on one side of a round_tube() layer, the shared centre line included, in extrusion order.
|
||||||
|
std::vector<int> side_insets(const std::vector<TubeLoop> &loops, TubeSide side)
|
||||||
|
{
|
||||||
|
std::vector<int> insets;
|
||||||
|
for (const TubeLoop &loop : loops)
|
||||||
|
if (loop.side == side || loop.side == TubeSide::Centre)
|
||||||
|
insets.push_back(loop.inset);
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walls of a round_tube() layer in extrusion order, as side and inset: "c0 c1 h0 h1" is the contour's outer wall,
|
||||||
|
// then its first inner wall, then the same for the hole, and "m" marks the wall in the middle both sides share.
|
||||||
|
std::string wall_order(const std::vector<TubeLoop> &loops)
|
||||||
|
{
|
||||||
|
std::string out;
|
||||||
|
for (const TubeLoop &loop : loops)
|
||||||
|
out += std::string(out.empty() ? "" : " ") + (loop.side == TubeSide::Contour ? "c" : loop.side == TubeSide::Hole ? "h" : "m") +
|
||||||
|
std::to_string(loop.inset);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Each side of a tube wall prints its walls in the order the wall sequence asks for, from two walls a side up to
|
||||||
|
// four. The inner wall Arachne lays in the middle of a wall too narrow for a pair of them is an inner wall of both
|
||||||
|
// sides; Classic fills that middle with gap fill instead, so it has one wall less a side there.
|
||||||
|
TEST_CASE("Each side of a tube wall prints its walls in the wall sequence order", "[Perimeters]")
|
||||||
|
{
|
||||||
|
struct TubeWall {
|
||||||
|
double wall;
|
||||||
|
int wall_loops;
|
||||||
|
// Walls a side, a shared inner wall included.
|
||||||
|
int arachne_walls;
|
||||||
|
int classic_walls;
|
||||||
|
};
|
||||||
|
const TubeWall tube = GENERATE(values<TubeWall>({
|
||||||
|
{ 1.5, 3, 2, 1 }, { 2.0, 2, 2, 2 }, { 2.5, 3, 3, 2 }, { 3.0, 3, 3, 3 }, { 4.0, 4, 4, 4 } }));
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
const std::string wall_sequence = GENERATE("inner wall/outer wall", "outer wall/inner wall", "inner-outer-inner wall");
|
||||||
|
CAPTURE(tube.wall, tube.wall_loops, wall_generator, wall_sequence);
|
||||||
|
|
||||||
|
DynamicPrintConfig config = tube_config(wall_generator, tube.wall_loops);
|
||||||
|
config.set_deserialize_strict("wall_sequence", wall_sequence);
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, config, tube.wall);
|
||||||
|
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, tube.wall);
|
||||||
|
// 2mm at 0.2mm layers.
|
||||||
|
REQUIRE(layers.size() == 10);
|
||||||
|
|
||||||
|
const int walls = std::string(wall_generator) == "arachne" ? tube.arachne_walls : tube.classic_walls;
|
||||||
|
for (size_t i = 0; i < layers.size(); ++ i) {
|
||||||
|
for (const TubeSide side : { TubeSide::Contour, TubeSide::Hole }) {
|
||||||
|
const bool contour_side = side == TubeSide::Contour;
|
||||||
|
CAPTURE(i, contour_side);
|
||||||
|
const std::vector<int> insets = side_insets(layers[i], side);
|
||||||
|
REQUIRE(int(insets.size()) == walls);
|
||||||
|
CHECK(insets == wall_sequence_insets(wall_sequence, i == 0, walls - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The outer wall of a thin wall hole touches the contour's with nothing between them. The hole is printed first, so
|
||||||
|
// the contour leans on it, unless the wall sequence puts the outer walls first.
|
||||||
|
TEST_CASE("A thin wall hole is printed before the contour unless the outer walls go first", "[Perimeters]")
|
||||||
|
{
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
const std::string wall_sequence = GENERATE("inner wall/outer wall", "outer wall/inner wall", "inner-outer-inner wall");
|
||||||
|
CAPTURE(wall_generator, wall_sequence);
|
||||||
|
|
||||||
|
DynamicPrintConfig config = tube_config(wall_generator, 3);
|
||||||
|
config.set_deserialize_strict("wall_sequence", wall_sequence);
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, config, 1.);
|
||||||
|
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, 1.);
|
||||||
|
REQUIRE(layers.size() == 10);
|
||||||
|
|
||||||
|
const TubeSide first = wall_sequence == "outer wall/inner wall" ? TubeSide::Contour : TubeSide::Hole;
|
||||||
|
for (size_t i = 0; i < layers.size(); ++ i) {
|
||||||
|
CAPTURE(i);
|
||||||
|
REQUIRE(layers[i].size() == 2);
|
||||||
|
REQUIRE(layers[i][0].side != layers[i][1].side);
|
||||||
|
CHECK(layers[i].front().side == first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 1.5mm tube wall leaves Inner/Outer/Inner no third wall to sandwich, only the inner wall Arachne lays in the middle
|
||||||
|
// for both sides, or Classic's gap fill. Past the first layer it prints the hole first, then the contour, then the
|
||||||
|
// middle.
|
||||||
|
TEST_CASE("Inner/Outer/Inner prints the hole of a narrow tube wall first and the middle last", "[Perimeters]")
|
||||||
|
{
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
CAPTURE(wall_generator);
|
||||||
|
|
||||||
|
DynamicPrintConfig config = tube_config(wall_generator, 3);
|
||||||
|
config.set_deserialize_strict("wall_sequence", "inner-outer-inner wall");
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, config, 1.5);
|
||||||
|
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, 1.5);
|
||||||
|
REQUIRE(layers.size() == 10);
|
||||||
|
|
||||||
|
// Classic's gap fill is not a wall loop, and goes after the walls.
|
||||||
|
const std::string expected = std::string(wall_generator) == "arachne" ? "h0 c0 m1" : "h0 c0";
|
||||||
|
for (size_t i = 1; i < layers.size(); ++ i) {
|
||||||
|
CAPTURE(i);
|
||||||
|
CHECK(wall_order(layers[i]) == expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contour's walls run in the wall direction and the hole's against it, except that the walls of a thin wall
|
||||||
|
// hole run with the contour, as does the inner wall Arachne shares between both sides of a 1.5mm tube wall.
|
||||||
|
TEST_CASE("Tube walls run in the wall direction and hole walls against it", "[Perimeters]")
|
||||||
|
{
|
||||||
|
const double wall = GENERATE(1.0, 1.5, 2.0, 3.0);
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
const std::string wall_direction = GENERATE("ccw", "cw");
|
||||||
|
CAPTURE(wall, wall_generator, wall_direction);
|
||||||
|
|
||||||
|
DynamicPrintConfig config = tube_config(wall_generator, 3);
|
||||||
|
config.set_deserialize_strict("wall_direction", wall_direction);
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, config, wall);
|
||||||
|
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, wall);
|
||||||
|
REQUIRE(layers.size() == 10);
|
||||||
|
|
||||||
|
const bool thin_wall_hole = wall == 1.;
|
||||||
|
for (size_t i = 0; i < layers.size(); ++ i) {
|
||||||
|
CAPTURE(i);
|
||||||
|
REQUIRE(std::any_of(layers[i].begin(), layers[i].end(), [](const TubeLoop &loop) { return loop.side == TubeSide::Contour; }));
|
||||||
|
REQUIRE(std::any_of(layers[i].begin(), layers[i].end(), [](const TubeLoop &loop) { return loop.side == TubeSide::Hole; }));
|
||||||
|
for (const TubeLoop &loop : layers[i]) {
|
||||||
|
CAPTURE(int(loop.side), loop.inset);
|
||||||
|
CHECK(loop.ccw == (runs_with_contour(loop, thin_wall_hole) == (wall_direction == "ccw")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse on even with no threshold reverses every wall of every other layer, or only the inner walls when it is
|
||||||
|
// restricted to them, and leaves the order of the walls alone.
|
||||||
|
TEST_CASE("Reverse on even reverses the tube walls of every other layer", "[Perimeters]")
|
||||||
|
{
|
||||||
|
const double wall = GENERATE(1.0, 1.5, 3.0);
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
const bool reverse_internal_only = GENERATE(false, true);
|
||||||
|
CAPTURE(wall, wall_generator, reverse_internal_only);
|
||||||
|
|
||||||
|
auto slice = [&](bool overhang_reverse) {
|
||||||
|
DynamicPrintConfig config = tube_config(wall_generator, 3);
|
||||||
|
config.set_deserialize_strict({
|
||||||
|
{ "overhang_reverse", overhang_reverse },
|
||||||
|
{ "overhang_reverse_internal_only", reverse_internal_only },
|
||||||
|
});
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, config, wall);
|
||||||
|
return tube_loops(print, wall);
|
||||||
|
};
|
||||||
|
const std::vector<std::vector<TubeLoop>> plain = slice(false);
|
||||||
|
const std::vector<std::vector<TubeLoop>> reversed = slice(true);
|
||||||
|
REQUIRE(plain.size() == 10);
|
||||||
|
REQUIRE(reversed.size() == plain.size());
|
||||||
|
|
||||||
|
for (size_t i = 0; i < plain.size(); ++ i) {
|
||||||
|
CAPTURE(i);
|
||||||
|
REQUIRE(reversed[i].size() == plain[i].size());
|
||||||
|
for (size_t j = 0; j < plain[i].size(); ++ j) {
|
||||||
|
CAPTURE(j);
|
||||||
|
CHECK(reversed[i][j].side == plain[i][j].side);
|
||||||
|
CHECK(reversed[i][j].inset == plain[i][j].inset);
|
||||||
|
const bool flips = i % 2 == 1 && (! reverse_internal_only || plain[i][j].inset > 0);
|
||||||
|
CHECK(reversed[i][j].ccw == (plain[i][j].ccw != flips));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
DynamicPrintConfig brim_tube_config(const char *wall_generator, const std::string &wall_sequence, const char *brim_type, double brim_width)
|
||||||
|
{
|
||||||
|
DynamicPrintConfig config = tube_config(wall_generator, 3);
|
||||||
|
config.set_deserialize_strict({
|
||||||
|
{ "wall_sequence", wall_sequence },
|
||||||
|
{ "brim_type", brim_type },
|
||||||
|
{ "brim_width", brim_width },
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// A brim against the outer walls makes the first layer print them first, whatever the wall sequence, so the brim runs
|
||||||
|
// straight on into the outer wall: each side of the tube wall outside in, and the contour before a thin wall hole.
|
||||||
|
// Brims that may leave the outer walls bare keep the wall sequence, and so do the layers above.
|
||||||
|
TEST_CASE("A brim against the outer walls makes the first layer print them first", "[Perimeters]")
|
||||||
|
{
|
||||||
|
struct Brim {
|
||||||
|
const char *type;
|
||||||
|
double width;
|
||||||
|
bool outer_walls_first;
|
||||||
|
};
|
||||||
|
const Brim brim = GENERATE(values<Brim>({
|
||||||
|
{ "no_brim", 5., false }, { "inner_only", 5., false }, { "auto_brim", 5., false }, { "outer_only", 0., false },
|
||||||
|
{ "outer_only", 5., true }, { "outer_and_inner", 5., true }, { "leading_edge_only", 5., true } }));
|
||||||
|
const double wall = GENERATE(1.0, 3.0);
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
const std::string wall_sequence = GENERATE("inner wall/outer wall", "inner-outer-inner wall");
|
||||||
|
CAPTURE(brim.type, brim.width, wall, wall_generator, wall_sequence);
|
||||||
|
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, brim_tube_config(wall_generator, wall_sequence, brim.type, brim.width), wall);
|
||||||
|
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, wall);
|
||||||
|
REQUIRE(layers.size() == 10);
|
||||||
|
|
||||||
|
// The thin wall hole has its outer wall a side, the 3mm tube wall three walls a side.
|
||||||
|
const size_t walls = wall == 1. ? 1 : 3;
|
||||||
|
for (const size_t i : { size_t(0), size_t(1) }) {
|
||||||
|
const bool outer_walls_first = brim.outer_walls_first && i == 0;
|
||||||
|
for (const TubeSide side : { TubeSide::Contour, TubeSide::Hole }) {
|
||||||
|
const bool contour_side = side == TubeSide::Contour;
|
||||||
|
CAPTURE(i, contour_side);
|
||||||
|
const std::vector<int> insets = side_insets(layers[i], side);
|
||||||
|
REQUIRE(insets.size() == walls);
|
||||||
|
CHECK(insets == wall_sequence_insets(outer_walls_first ? "outer wall/inner wall" : wall_sequence, i == 0, int(walls) - 1));
|
||||||
|
}
|
||||||
|
if (wall == 1.)
|
||||||
|
CHECK(layers[i].front().side == (outer_walls_first ? TubeSide::Contour : TubeSide::Hole));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing the brim of a sliced print regenerates the walls when the first layer's wall order changes with it, so
|
||||||
|
// the print gets the walls a slice from scratch with the new brim gets.
|
||||||
|
TEST_CASE("Changing the brim updates the wall order of the first layer", "[Perimeters]")
|
||||||
|
{
|
||||||
|
struct BrimChange {
|
||||||
|
const char *from_type;
|
||||||
|
double from_width;
|
||||||
|
const char *to_type;
|
||||||
|
double to_width;
|
||||||
|
};
|
||||||
|
const BrimChange change = GENERATE(values<BrimChange>({
|
||||||
|
{ "outer_only", 0., "outer_only", 5. }, { "outer_only", 5., "outer_only", 0. },
|
||||||
|
{ "no_brim", 5., "outer_and_inner", 5. }, { "outer_and_inner", 5., "inner_only", 5. } }));
|
||||||
|
const char *wall_generator = GENERATE("classic", "arachne");
|
||||||
|
CAPTURE(change.from_type, change.from_width, change.to_type, change.to_width, wall_generator);
|
||||||
|
|
||||||
|
const std::string wall_sequence = "inner wall/outer wall";
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
round_tube(print, model, brim_tube_config(wall_generator, wall_sequence, change.from_type, change.from_width), 3.);
|
||||||
|
const std::string before = wall_order(tube_loops(print, 3.).front());
|
||||||
|
print.apply(model, brim_tube_config(wall_generator, wall_sequence, change.to_type, change.to_width));
|
||||||
|
print.process();
|
||||||
|
const std::string after = wall_order(tube_loops(print, 3.).front());
|
||||||
|
|
||||||
|
Print fresh_print;
|
||||||
|
Model fresh_model;
|
||||||
|
round_tube(fresh_print, fresh_model, brim_tube_config(wall_generator, wall_sequence, change.to_type, change.to_width), 3.);
|
||||||
|
const std::string fresh = wall_order(tube_loops(fresh_print, 3.).front());
|
||||||
|
|
||||||
|
// The change does turn the first layer around.
|
||||||
|
REQUIRE(before != fresh);
|
||||||
|
CHECK(after == fresh);
|
||||||
|
}
|
||||||
@@ -735,6 +735,65 @@ TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
|
|||||||
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
|
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The final print statistics are known only after the whole G-code is generated, yet every custom G-code,
|
||||||
|
// the file header included, gets the same values as filename_format.
|
||||||
|
TEST_CASE("Print statistics in custom G-code match the final print statistics", "[Print][Regression]")
|
||||||
|
{
|
||||||
|
const std::string gcode_key = GENERATE(as<std::string>{}, "file_start_gcode", "machine_start_gcode", "machine_end_gcode");
|
||||||
|
// Bambu printers use another tag table and G-code layout.
|
||||||
|
const bool is_bbl = GENERATE(false, true);
|
||||||
|
CAPTURE(gcode_key, is_bbl);
|
||||||
|
|
||||||
|
const std::vector<std::string> number_keys = { "total_weight", "used_filament", "total_cost", "total_wipe_tower_cost",
|
||||||
|
"total_wipe_tower_filament" };
|
||||||
|
const std::vector<std::string> time_keys = { "print_time", "normal_print_time", "silent_print_time" };
|
||||||
|
std::string templ;
|
||||||
|
for (const std::vector<std::string> *keys : { &number_keys, &time_keys })
|
||||||
|
for (const std::string &key : *keys)
|
||||||
|
templ += "; stat " + key + "={" + key + "}\n";
|
||||||
|
|
||||||
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||||
|
config.set_deserialize_strict({
|
||||||
|
{ gcode_key, templ },
|
||||||
|
{ "filament_density", "1.25" },
|
||||||
|
{ "filament_cost", "20" },
|
||||||
|
{ "time_cost", "2" },
|
||||||
|
});
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
init_print({ cube(20) }, print, model, config);
|
||||||
|
print.is_BBL_printer() = is_bbl;
|
||||||
|
|
||||||
|
std::string gcode;
|
||||||
|
REQUIRE_NOTHROW(gcode = Slic3r::Test::gcode(print));
|
||||||
|
|
||||||
|
// Match whole lines only: the config block repeats the template on a single line, and Bambu printers
|
||||||
|
// write that block before the start G-code.
|
||||||
|
const std::string lines = "\n" + gcode;
|
||||||
|
const auto value_of = [&lines](const std::string &key) {
|
||||||
|
const std::string prefix = "\n; stat " + key + "=";
|
||||||
|
const size_t start = lines.find(prefix);
|
||||||
|
REQUIRE(start != std::string::npos);
|
||||||
|
const size_t begin = start + prefix.size();
|
||||||
|
return lines.substr(begin, lines.find('\n', begin) - begin);
|
||||||
|
};
|
||||||
|
// The same statistics filename_format is expanded with.
|
||||||
|
DynamicConfig stats = print.print_statistics().config();
|
||||||
|
// A 20mm cube with filament_density 1.25 weighs something, unlike the running extruded_weight_total in the header.
|
||||||
|
CHECK(stats.opt_float("total_weight") > 0.);
|
||||||
|
for (const std::string &key : number_keys) {
|
||||||
|
CAPTURE(key);
|
||||||
|
const std::string value = value_of(key);
|
||||||
|
REQUIRE(value.find('@') == std::string::npos);
|
||||||
|
// Written with two decimals.
|
||||||
|
CHECK_THAT(std::stod(value), Catch::Matchers::WithinAbs(stats.opt_float(key), 0.006));
|
||||||
|
}
|
||||||
|
for (const std::string &key : time_keys) {
|
||||||
|
CAPTURE(key);
|
||||||
|
CHECK(value_of(key) == stats.opt_string(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
|
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
|
||||||
{
|
{
|
||||||
Print print;
|
Print print;
|
||||||
|
|||||||
@@ -985,6 +985,37 @@ TEST_CASE("A user filament synced to the cloud and back keeps its values and uns
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A project filament storing one value for a per-variant key applies it to every variant", "[Preset][Bundle][Regression]")
|
||||||
|
{
|
||||||
|
PresetCollection filaments(Preset::TYPE_FILAMENT, Preset::filament_options(),
|
||||||
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||||
|
Preset &parent = add_inmemory_preset(filaments, "Parent Filament");
|
||||||
|
parent.config.set_key_value("filament_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow"}));
|
||||||
|
parent.config.set_key_value("adaptive_pressure_advance_model", new ConfigOptionStrings({"parent standard", "parent high flow"}));
|
||||||
|
parent.config.set_deserialize_strict("pressure_advance", "0.02,0.03");
|
||||||
|
parent.is_system = true;
|
||||||
|
|
||||||
|
// A filament saved before these keys became per-variant lists both variants but stores each key once.
|
||||||
|
const std::string model = "0.025,15.6,5000";
|
||||||
|
DynamicPrintConfig config;
|
||||||
|
config.set_key_value(BBL_JSON_KEY_INHERITS, new ConfigOptionString("Parent Filament"));
|
||||||
|
config.set_key_value("filament_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow"}));
|
||||||
|
config.set_key_value("adaptive_pressure_advance_model", new ConfigOptionStrings({model}));
|
||||||
|
config.set_deserialize_strict("pressure_advance", "0.016");
|
||||||
|
auto embedded = std::make_unique<Preset>(Preset::TYPE_FILAMENT, "Project Filament", false);
|
||||||
|
embedded->config = std::move(config);
|
||||||
|
embedded->is_project_embedded = true;
|
||||||
|
|
||||||
|
std::vector<Preset *> project_presets{embedded.get()};
|
||||||
|
PresetsConfigSubstitutions substitutions;
|
||||||
|
filaments.load_project_embedded_presets(project_presets, PRESET_FILAMENT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||||
|
|
||||||
|
const Preset *loaded = filaments.find_preset("Project Filament");
|
||||||
|
REQUIRE(loaded != nullptr);
|
||||||
|
CHECK(loaded->config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values == std::vector<std::string>{model, model});
|
||||||
|
check_double_vector(loaded->config.option<ConfigOptionFloats>("pressure_advance")->values, {0.016, 0.016});
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Repeated loads of a user preset directory produce the same presets", "[Preset][Bundle]")
|
TEST_CASE("Repeated loads of a user preset directory produce the same presets", "[Preset][Bundle]")
|
||||||
{
|
{
|
||||||
ScopedTemporaryDir temp_dir;
|
ScopedTemporaryDir temp_dir;
|
||||||
|
|||||||
@@ -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 <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -6,8 +23,12 @@
|
|||||||
#include <libslic3r/TriangleMesh.hpp>
|
#include <libslic3r/TriangleMesh.hpp>
|
||||||
#include <slic3r/GUI/DockPanel.hpp>
|
#include <slic3r/GUI/DockPanel.hpp>
|
||||||
#include <slic3r/GUI/AuiPaneLayout.hpp>
|
#include <slic3r/GUI/AuiPaneLayout.hpp>
|
||||||
|
#include <slic3r/GUI/OptionsGroup.hpp>
|
||||||
#include <slic3r/GUI/Widgets/WebHosting.hpp>
|
#include <slic3r/GUI/Widgets/WebHosting.hpp>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
#include <catch2/catch_message.hpp>
|
#include <catch2/catch_message.hpp>
|
||||||
#include <catch2/matchers/catch_matchers_floating_point.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);
|
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]")
|
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
|
||||||
{
|
{
|
||||||
py::object host = import_orca_module().attr("host");
|
py::object host = import_orca_module().attr("host");
|
||||||
|
|||||||
Reference in new issue
Block a user