mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-19 13:41:03 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2615a7816 | ||
|
|
8a4027ce8c | ||
|
|
e57945179b | ||
|
|
d3699dd9f6 | ||
|
|
a315a96c5c | ||
|
|
24cf1c6bcc | ||
|
|
d16ca97380 | ||
|
|
da4d766d46 | ||
|
|
06665d0558 | ||
|
|
0f59cd5d05 | ||
|
|
c1fb801ff3 | ||
|
|
1ee0b730a1 | ||
|
|
72ba7a2b35 | ||
|
|
8160f64cae |
No files matched your search
@@ -574,7 +574,8 @@ DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the
|
||||
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
|
||||
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
|
||||
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
|
||||
DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
|
||||
DMABUF renderer. On a hypervisor guest (`/proc/cpuinfo` `hypervisor` flag) and WebKitGTK >= 2.44 it sets
|
||||
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` alone, since virtual GPUs cannot back the hardware path either. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
|
||||
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
|
||||
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
|
||||
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
|
||||
|
||||
@@ -568,13 +568,18 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
|
||||
## WebKitGTK on Linux sessions
|
||||
|
||||
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
|
||||
sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`:
|
||||
sets the DMABUF workarounds and then branches on `GDK_BACKEND`:
|
||||
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
|
||||
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
|
||||
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
|
||||
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
|
||||
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
|
||||
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
|
||||
- **Hypervisor guests**, same position: when `is_hypervisor_guest()` (the `hypervisor` flag in
|
||||
`/proc/cpuinfo`) and WebKitGTK >= 2.44, it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` non-replacing.
|
||||
Virtual GPUs (VirtualBox VMSVGA, VMware, QEMU) cannot back the hardware DMABUF path either, and
|
||||
FORCE_SHM alone was confirmed to fix web views in VirtualBox; the `WEBKIT_FORCE_DMABUF_RENDERER` pair
|
||||
is not needed without the Debian `isNVIDIA()` patch. The flag is x86-only, so ARM guests are not covered.
|
||||
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
|
||||
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
|
||||
@@ -590,9 +595,9 @@ Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_
|
||||
(see `references/platforms.md`), not these environment variables.
|
||||
|
||||
**Pitfall.**
|
||||
- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware
|
||||
DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
|
||||
- **Rule:** On NVIDIA and hypervisor guests reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver and virtual GPUs cannot back WebKit's
|
||||
hardware DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
|
||||
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
|
||||
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
|
||||
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
|
||||
|
||||
@@ -232,6 +232,31 @@ jobs:
|
||||
mkdir -p ${{ runner.temp }}/profile-check-results
|
||||
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
||||
|
||||
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
|
||||
# one long line per setting, for check_profiles_comment.yml to post.
|
||||
- name: Diff custom G-code
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
|
||||
results=${{ runner.temp }}/profile-check-results
|
||||
diff=${{ runner.temp }}/gcode_diff.md
|
||||
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
|
||||
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
|
||||
if [ -s "$diff" ]; then
|
||||
{
|
||||
echo "<!-- profile-gcode-diff-comment -->"
|
||||
cat "$diff"
|
||||
echo ""
|
||||
# The comment workflow finds this line as the only one starting with <sub>.
|
||||
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
|
||||
} > "$results/gcode_diff.md"
|
||||
fi
|
||||
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
|
||||
touch "$results/gcode_diff_done.txt"
|
||||
|
||||
- name: Prepare comment artifact
|
||||
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||
run: |
|
||||
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
if [ ! -f pr_number.txt ]; then
|
||||
echo "No pr_number.txt in artifact, skipping."
|
||||
@@ -49,18 +50,72 @@ jobs:
|
||||
echo "Invalid PR number: $PR_NUMBER"
|
||||
exit 1
|
||||
fi
|
||||
# PR code wrote the artifact, so only a run for the named PR's current head may post.
|
||||
if [ -z "$HEAD_SHA" ]; then
|
||||
echo "The triggering run names no head commit."
|
||||
exit 1
|
||||
fi
|
||||
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
|
||||
echo "Cannot look up PR #${PR_NUMBER}."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
|
||||
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Lists every bot comment on the PR, one JSON object per line.
|
||||
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
|
||||
|
||||
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
|
||||
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
||||
OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
||||
for comment_id in $OLD_IDS; do
|
||||
echo "Deleting outdated profile-validation comment ${comment_id}"
|
||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||
done
|
||||
|
||||
# Post a new comment only when validation failed (pr_comment.md present).
|
||||
# A failed post fails the job only after the G-code diff is handled.
|
||||
failed=0
|
||||
if [ -f pr_comment.md ]; then
|
||||
gh pr comment "$PR_NUMBER" --body-file pr_comment.md
|
||||
gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
|
||||
else
|
||||
echo "Validation succeeded; cleaned up prior comments without posting."
|
||||
fi
|
||||
|
||||
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
|
||||
update_gcode_diff_comment() {
|
||||
if [ ! -f gcode_diff_done.txt ]; then
|
||||
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
|
||||
return 0
|
||||
fi
|
||||
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
|
||||
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
|
||||
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
|
||||
rm gcode_diff.md
|
||||
fi
|
||||
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
|
||||
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
|
||||
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
|
||||
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
|
||||
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
|
||||
return 0
|
||||
fi
|
||||
# Edits in place when only the <sub> line differs, since edits notify nobody.
|
||||
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
|
||||
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
|
||||
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
for comment_id in $GCODE_IDS; do
|
||||
echo "Deleting outdated G-code diff comment ${comment_id}"
|
||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||
done
|
||||
if [ -f gcode_diff.md ]; then
|
||||
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
|
||||
fi
|
||||
}
|
||||
update_gcode_diff_comment
|
||||
exit "$failed"
|
||||
@@ -53,6 +53,13 @@ Space counts as typing. The speed dial's default is the one bare Space, and
|
||||
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
|
||||
combo boxes), so it opens the dial from the canvases and the tab strip only.
|
||||
|
||||
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
|
||||
while a text field has the focus (a wx text entry, spin control, web view or ImGui
|
||||
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
|
||||
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
|
||||
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
|
||||
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
|
||||
|
||||
## Which event a chord matches
|
||||
|
||||
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,351 +0,0 @@
|
||||
# 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.
|
||||
@@ -10657,6 +10657,9 @@ msgstr ""
|
||||
msgid "Send Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "No available printers"
|
||||
msgstr ""
|
||||
|
||||
msgid "The name length exceeds the limit."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Snapmaker",
|
||||
"version": "02.04.00.17",
|
||||
"version": "02.04.00.18",
|
||||
"force_update": "0",
|
||||
"description": "Snapmaker configurations",
|
||||
"machine_model_list": [
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
],
|
||||
"pressure_advance": [
|
||||
"0.02"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
@@ -46,5 +46,8 @@
|
||||
],
|
||||
"fan_min_speed": [
|
||||
"0"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
"250"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"178"
|
||||
"70"
|
||||
],
|
||||
"fan_max_speed": [
|
||||
"20"
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"inherits": "fdm_filament_petg",
|
||||
"from": "system",
|
||||
"filament_id": "OFW8lqnb",
|
||||
"instantiation": "false"
|
||||
"instantiation": "false",
|
||||
"temperature_vitrification": [
|
||||
"70"
|
||||
]
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
"250"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"178"
|
||||
"70"
|
||||
],
|
||||
"fan_max_speed": [
|
||||
"20"
|
||||
|
||||
@@ -34,5 +34,8 @@
|
||||
],
|
||||
"nozzle_temperature": [
|
||||
"220"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -34,5 +34,8 @@
|
||||
],
|
||||
"filament_cooling_final_speed": [
|
||||
"60"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -34,5 +34,8 @@
|
||||
],
|
||||
"nozzle_temperature": [
|
||||
"220"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -40,5 +40,8 @@
|
||||
],
|
||||
"nozzle_temperature": [
|
||||
"220"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -31,5 +31,8 @@
|
||||
],
|
||||
"slow_down_layer_time": [
|
||||
"8"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -50,5 +50,8 @@
|
||||
"additional_cooling_fan_speed": [
|
||||
"70",
|
||||
"70"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
@@ -137,7 +137,7 @@
|
||||
"240"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"60"
|
||||
"30"
|
||||
],
|
||||
"nozzle_temperature_range_low": [
|
||||
"200"
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
"-1"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"60"
|
||||
"30"
|
||||
],
|
||||
"textured_cool_plate_temp": [
|
||||
"40"
|
||||
|
||||
@@ -29,5 +29,8 @@
|
||||
"additional_cooling_fan_speed": [
|
||||
"70",
|
||||
"70"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
@@ -49,5 +49,8 @@
|
||||
"additional_cooling_fan_speed": [
|
||||
"70",
|
||||
"70"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.14",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.32",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.42",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.56",
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# /// 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)
|
||||
@@ -1,188 +0,0 @@
|
||||
# /// 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)
|
||||
@@ -12,6 +12,7 @@ commands:
|
||||
trim delete profile files no <vendor>.json list references
|
||||
update-index regenerate the *_list sections of <vendor>.json
|
||||
legacy-compat write the printers user presets can be migrated back to
|
||||
gcode-diff print a readable markdown diff of changed custom G-code
|
||||
|
||||
options shared by several commands:
|
||||
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
|
||||
@@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import functools
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from collections import Counter, defaultdict
|
||||
@@ -192,6 +196,7 @@ VECTOR_KEYS = {
|
||||
|
||||
PRINT_CONFIG_CPP = os.path.normpath(
|
||||
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
|
||||
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
@@ -313,11 +318,11 @@ REMEDY_HINTS = {
|
||||
}
|
||||
|
||||
|
||||
def print_error(msg):
|
||||
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
|
||||
def print_error(msg, file=None):
|
||||
print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
|
||||
|
||||
def print_warning(msg):
|
||||
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
|
||||
def print_warning(msg, file=None):
|
||||
print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
|
||||
|
||||
def print_info(msg):
|
||||
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
||||
@@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gcode-diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
|
||||
GCODE_DIFF_BUDGET = 60000
|
||||
# The file names a section's summary shows before "and N more".
|
||||
GCODE_DIFF_SUMMARY_FILES = 3
|
||||
|
||||
GCODE_DIFF_HEADER = (
|
||||
"## Custom G-code changes\n"
|
||||
"\n"
|
||||
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
|
||||
"diff of each changed G-code or other text setting.\n")
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _text_option_keys():
|
||||
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
|
||||
(every *_gcode option, and every one it marks multiline, such as notes), plus the
|
||||
ones Tab.cpp edits as code, such as filename_format."""
|
||||
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
keys = set()
|
||||
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
|
||||
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
|
||||
if match and (match.group(1).endswith("_gcode")
|
||||
or re.search(r"def->multiline\s*=\s*true", body)):
|
||||
keys.add(match.group(1))
|
||||
if "machine_start_gcode" not in keys:
|
||||
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
|
||||
# Tab.cpp only adds keys, so a missing file is not an error.
|
||||
try:
|
||||
with open(TAB_CPP, encoding="utf-8") as f:
|
||||
gui = f.read()
|
||||
except OSError:
|
||||
gui = ""
|
||||
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
|
||||
gui, re.DOTALL))
|
||||
return frozenset(keys)
|
||||
|
||||
|
||||
def _git(cwd, *args, stdin=None):
|
||||
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
|
||||
capture_output=True).stdout
|
||||
|
||||
|
||||
def _blobs_at(profiles_dir, specs):
|
||||
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
|
||||
profiles_dir) pairs, all read by one git cat-file process."""
|
||||
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
|
||||
out = _git(profiles_dir, "cat-file", "--batch",
|
||||
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
|
||||
blobs, pos = {}, 0
|
||||
for spec in specs:
|
||||
end = out.index(b"\n", pos)
|
||||
header = out[pos:end]
|
||||
pos = end + 1
|
||||
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
|
||||
if header.endswith((b" missing", b" ambiguous")):
|
||||
blobs[spec] = None
|
||||
continue
|
||||
_oid, kind, size = header.split()
|
||||
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
|
||||
pos += int(size) + 1
|
||||
return blobs
|
||||
|
||||
|
||||
def _parse_profile(raw, rel, where):
|
||||
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
|
||||
if raw is None:
|
||||
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8-sig"))
|
||||
except ValueError as e:
|
||||
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _changed_profiles(profiles_dir, base, head):
|
||||
"""(old path, new path) of each JSON file under profiles_dir that differs between
|
||||
base and head (the working tree, untracked files included, when None); the absent
|
||||
side of an added or deleted file is None, and so is a side that is not JSON. Renames
|
||||
are paired."""
|
||||
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
|
||||
*([head] if head else []), "--", ".")
|
||||
fields = out.decode("utf-8").split("\0")
|
||||
pairs, i = [], 0
|
||||
while i + 1 < len(fields):
|
||||
status = fields[i]
|
||||
old = new = fields[i + 1]
|
||||
if status[0] in "RC":
|
||||
new = fields[i + 2]
|
||||
i += 3
|
||||
else:
|
||||
i += 2
|
||||
old = None if status[0] == "A" or not old.endswith(".json") else old
|
||||
new = None if status[0] == "D" or not new.endswith(".json") else new
|
||||
if old or new:
|
||||
pairs.append((old, new))
|
||||
if head is None:
|
||||
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
|
||||
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
|
||||
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
|
||||
return pairs
|
||||
|
||||
|
||||
def _text_lines(value):
|
||||
return [line.rstrip() for line in value.split("\n")] if value else []
|
||||
|
||||
|
||||
def _text_changes(old, new):
|
||||
"""(setting, unified diff) for every G-code or other text value that differs
|
||||
between two profiles; a vector option with several values gives one per value."""
|
||||
changes = []
|
||||
for key in list(new) + [k for k in old if k not in new]:
|
||||
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
|
||||
if a == b or not all(isinstance(v, str) for v in a + b):
|
||||
continue
|
||||
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
|
||||
continue
|
||||
width = max(len(a), len(b))
|
||||
for i in range(width):
|
||||
diff = list(difflib.unified_diff(
|
||||
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
|
||||
lineterm=""))[2:] # drops the ---/+++ file header
|
||||
if diff:
|
||||
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
|
||||
return changes
|
||||
|
||||
|
||||
def _gcode_diff_section(files, setting, diff):
|
||||
# A fence longer than any backtick run in the G-code cannot be closed by it.
|
||||
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
|
||||
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
|
||||
def code(text):
|
||||
return f"<code>{html.escape(text, quote=False)}</code>"
|
||||
names, listing = ", ".join(map(code, files)), ""
|
||||
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
|
||||
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
|
||||
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
|
||||
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
|
||||
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
|
||||
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
|
||||
|
||||
|
||||
def gcode_diff(profiles_dir, base, head=None, vendors=None):
|
||||
"""Markdown with one collapsed diff per changed G-code setting between base and
|
||||
head, or "" when none changed. Files whose diff of a setting is identical share
|
||||
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
|
||||
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
|
||||
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
|
||||
specs = ([(base, old) for old, _ in pairs if old]
|
||||
+ [(head, new) for _, new in pairs if new and head])
|
||||
blobs = _blobs_at(profiles_dir, specs) if specs else {}
|
||||
|
||||
def load(ref, rel):
|
||||
if rel is None:
|
||||
return {}
|
||||
if ref:
|
||||
return _parse_profile(blobs[(ref, rel)], rel, ref)
|
||||
try:
|
||||
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
|
||||
except OSError as e:
|
||||
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
groups = {} # (setting, diff) -> files, in the order git lists them
|
||||
for old_rel, new_rel in pairs:
|
||||
old, new = load(base, old_rel), load(head, new_rel)
|
||||
if not isinstance(old, dict) or not isinstance(new, dict):
|
||||
continue
|
||||
for setting, diff in _text_changes(old, new):
|
||||
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
|
||||
if not groups:
|
||||
return ""
|
||||
|
||||
sections = [_gcode_diff_section(files, setting, diff)
|
||||
for (setting, diff), files in groups.items()]
|
||||
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
|
||||
shown = 0
|
||||
for section in sections:
|
||||
budget -= len(section.encode("utf-8"))
|
||||
if budget < 0:
|
||||
break
|
||||
shown += 1
|
||||
note, hidden = "", len(sections) - shown
|
||||
if hidden:
|
||||
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
|
||||
"shown because the comment would be too long for GitHub. See the Files "
|
||||
"changed tab for the rest.\n")
|
||||
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
|
||||
|
||||
|
||||
def _default_gcode_diff_base(profiles_dir, head):
|
||||
for main in ("upstream/main", "origin/main"):
|
||||
try:
|
||||
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
|
||||
|
||||
|
||||
def run_gcode_diff(profiles_dir, vendors, base, head):
|
||||
try:
|
||||
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
|
||||
head, vendors)
|
||||
# UnicodeDecodeError means git listed a file name that is not UTF-8.
|
||||
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
|
||||
stderr = getattr(e, "stderr", None)
|
||||
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
|
||||
return 1
|
||||
sys.stdout.write(markdown)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3233,6 +3455,9 @@ examples:
|
||||
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
|
||||
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
|
||||
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
|
||||
orca_profile_tool.py gcode-diff > gcode.md
|
||||
the changed custom G-code of the working tree since it left upstream/main,
|
||||
as markdown with one collapsed diff per setting
|
||||
|
||||
after adding, renaming or deleting profile files, run in this order:
|
||||
normalize -> update-index -> generate-id -> check
|
||||
@@ -3387,6 +3612,25 @@ def build_parser():
|
||||
legacy_cmd.add_argument("--check", action="store_true",
|
||||
help="fail if the map differs from what --base implies; write nothing")
|
||||
|
||||
gcode_cmd = add(
|
||||
"gcode-diff", [vendor_opt, profiles_opt],
|
||||
"print a readable markdown diff of changed custom G-code",
|
||||
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
|
||||
"setting that differs between --base and --head in each changed profile\n"
|
||||
"file, one collapsed section per setting. Files whose diff of a setting is\n"
|
||||
"identical share a section. Trailing spaces are removed from every line, and\n"
|
||||
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
|
||||
"when no such setting changed. CI posts it on profile PRs.\n"
|
||||
"\n"
|
||||
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
|
||||
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
|
||||
"string value that spans several lines.")
|
||||
gcode_cmd.add_argument("--base", metavar="REF",
|
||||
help="compare against this git ref (default: the merge base "
|
||||
"with upstream/main, else origin/main)")
|
||||
gcode_cmd.add_argument("--head", metavar="REF",
|
||||
help="compare this git ref (default: the working tree)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -3423,6 +3667,9 @@ def main(argv=None):
|
||||
if args.command == "legacy-compat":
|
||||
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
|
||||
|
||||
if args.command == "gcode-diff":
|
||||
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
|
||||
|
||||
if args.command == "fix-variant":
|
||||
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
|
||||
strict=args.strict)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
||||
normalize, trim, update-index, fix-variant and check commands, and the subcommand
|
||||
dispatch that reaches them (stdlib unittest, no external deps).
|
||||
normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
|
||||
subcommand dispatch that reaches them (stdlib unittest, no external deps).
|
||||
|
||||
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
||||
|
||||
@@ -14,9 +14,12 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
@@ -41,7 +44,24 @@ class Tree:
|
||||
os.makedirs(self.profiles)
|
||||
|
||||
def cleanup(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
root = os.path.abspath(self.dir)
|
||||
|
||||
def writable_retry(func, path, _exc):
|
||||
# Windows refuses to delete read-only files such as git's objects, and POSIX
|
||||
# refuses to delete the entries of a read-only directory.
|
||||
if not os.path.lexists(path): # already removed, as on a second cleanup
|
||||
return
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
try:
|
||||
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
|
||||
os.chmod(parent, stat.S_IRWXU)
|
||||
if not os.path.islink(path): # chmod would follow it out of the tree
|
||||
os.chmod(path, stat.S_IRWXU)
|
||||
func(path)
|
||||
except (OSError, ValueError): # commonpath raises ValueError across drives
|
||||
pass
|
||||
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
|
||||
shutil.rmtree(self.dir, **{hook: writable_retry})
|
||||
|
||||
def index_path(self, vendor):
|
||||
return os.path.join(self.profiles, vendor + ".json")
|
||||
@@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase):
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gcode-diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(shutil.which("git"), "git not installed")
|
||||
class TestGcodeDiff(TreeCase):
|
||||
START = "G28\nM104 S200\nG1 Z5"
|
||||
|
||||
def git(self, *args):
|
||||
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
|
||||
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
|
||||
|
||||
def printer(self, name, **extra):
|
||||
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
|
||||
|
||||
def commit(self):
|
||||
self.git("add", "-A")
|
||||
self.git("commit", "-q", "--allow-empty", "-m", "x")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# git's list of the variables that tie a process to a repository, asked with no
|
||||
# GIT_* set since a stray GIT_DIR fails the query.
|
||||
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
cls.repo_env_vars = set(subprocess.run(
|
||||
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
|
||||
capture_output=True, text=True).stdout.split())
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
|
||||
# tool's included, at the real repository instead of the temp tree.
|
||||
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
|
||||
patcher = mock.patch.dict(os.environ, env, clear=True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.git("init", "-q")
|
||||
for name in ("A", "B", "C"):
|
||||
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
||||
self.printer("Gone", machine_end_gcode="M84")
|
||||
self.commit()
|
||||
|
||||
def diff(self):
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
return out
|
||||
|
||||
def test_identical_edits_share_one_section(self):
|
||||
for name in ("A", "B"):
|
||||
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
|
||||
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
|
||||
out = self.diff()
|
||||
self.assertEqual(out.count("<details>"), 2)
|
||||
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
|
||||
"<code>machine_start_gcode</code>", out)
|
||||
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
|
||||
# Trailing spaces are removed.
|
||||
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
|
||||
self.assertIn("\n\n```diff\n@@ ", out)
|
||||
self.assertIn("\n```\n\n</details>", out)
|
||||
|
||||
def test_nothing_is_printed_without_a_text_change(self):
|
||||
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
|
||||
self.assertEqual(self.diff(), "")
|
||||
|
||||
def test_added_and_deleted_files_show_the_whole_value(self):
|
||||
# An untracked file counts as added; the validator's user dir is not a profile.
|
||||
self.printer("New", machine_start_gcode="G28\nG29")
|
||||
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
|
||||
os.remove(self.t.path("V", "machine/Gone.json"))
|
||||
out = self.diff()
|
||||
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
|
||||
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
||||
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
||||
self.assertNotIn("U.json", out)
|
||||
|
||||
def test_a_multi_line_value_counts_whatever_its_key(self):
|
||||
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
|
||||
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
|
||||
|
||||
def test_a_file_name_cannot_mention_a_user(self):
|
||||
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
|
||||
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
|
||||
|
||||
def test_head_compares_two_commits(self):
|
||||
self.printer("A", machine_start_gcode="G28")
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
||||
|
||||
def test_a_renamed_file_diffs_against_its_old_self(self):
|
||||
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
|
||||
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
|
||||
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
|
||||
|
||||
def test_a_file_renamed_off_json_loses_its_values(self):
|
||||
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
||||
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
||||
|
||||
def test_a_file_renamed_onto_json_is_new(self):
|
||||
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
|
||||
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
|
||||
self.commit()
|
||||
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
|
||||
|
||||
def test_a_batch_read_survives_paths_that_are_not_files(self):
|
||||
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
|
||||
("HEAD", "V/machine/Gone.json")])
|
||||
self.assertIsNone(blobs[("HEAD", "V/machine")])
|
||||
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
|
||||
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
|
||||
"M84")
|
||||
|
||||
def test_each_value_of_a_vector_option_is_its_own_setting(self):
|
||||
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
|
||||
self.commit()
|
||||
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
|
||||
out = self.diff()
|
||||
self.assertEqual(out.count("<details>"), 1)
|
||||
self.assertIn("<code>filament_start_gcode[1]</code>", out)
|
||||
|
||||
def test_vendor_limits_the_files(self):
|
||||
self.printer("A", machine_start_gcode="G28")
|
||||
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("V/machine/A.json", out)
|
||||
self.assertNotIn("W/machine/X.json", out)
|
||||
|
||||
def test_vendor_includes_files_moved_out_of_it(self):
|
||||
self.t.add_vendor("W")
|
||||
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
|
||||
data = self.t.read("W", "machine/A.json")
|
||||
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
|
||||
"--vendor", "V")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
||||
|
||||
def same_edit(self, names):
|
||||
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
|
||||
for name in names:
|
||||
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
||||
self.commit()
|
||||
for name in names:
|
||||
self.printer(name, machine_start_gcode="G28")
|
||||
out = self.diff()
|
||||
return re.search(r"<summary>(.*)</summary>", out).group(1), out
|
||||
|
||||
def test_a_long_file_list_moves_into_the_section(self):
|
||||
summary, out = self.same_edit("ABCDE")
|
||||
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
|
||||
self.assertNotIn("D.json", summary)
|
||||
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
|
||||
out)
|
||||
|
||||
def test_four_files_are_all_named_in_the_summary(self):
|
||||
summary, _out = self.same_edit("ABCD")
|
||||
self.assertIn("<code>V/machine/D.json</code> - ", summary)
|
||||
self.assertNotIn("more", summary)
|
||||
|
||||
def test_an_error_goes_to_stderr_only(self):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(out, "")
|
||||
self.assertIn("no-such-ref", err.getvalue())
|
||||
|
||||
def test_a_file_name_that_is_not_utf8_is_an_error(self):
|
||||
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
|
||||
contextlib.redirect_stderr(err):
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
||||
self.assertEqual((rc, out), (1, ""))
|
||||
self.assertIn("invalid continuation byte", err.getvalue())
|
||||
|
||||
def test_output_is_cut_to_whole_sections(self):
|
||||
for name in ("A", "B", "C"):
|
||||
self.printer(name, machine_start_gcode=name * 300)
|
||||
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
|
||||
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
|
||||
out = self.diff()
|
||||
self.assertEqual(out.count("<details>"), 2)
|
||||
self.assertEqual(out.count("</details>"), 2)
|
||||
self.assertIn("\n1 of 3 sections is not shown", out)
|
||||
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
|
||||
self.assertIn("\n3 of 3 sections are not shown", self.diff())
|
||||
|
||||
def test_cleanup_removes_the_repo(self):
|
||||
# setUp's commit leaves read-only git objects, and a read-only directory blocks
|
||||
# deletion on POSIX.
|
||||
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
|
||||
self.t.cleanup()
|
||||
self.assertFalse(os.path.exists(self.t.dir))
|
||||
with mock.patch.object(os, "chmod") as chmod:
|
||||
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
|
||||
chmod.assert_not_called()
|
||||
|
||||
def test_only_repository_variables_are_stripped(self):
|
||||
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
|
||||
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
|
||||
|
||||
def test_cleanup_leaves_symlink_targets_alone(self):
|
||||
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
|
||||
self.addCleanup(shutil.rmtree, outside, True)
|
||||
target = os.path.join(outside, "f")
|
||||
open(target, "w").close()
|
||||
os.chmod(target, stat.S_IRUSR)
|
||||
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
|
||||
machine = self.t.path("V", "machine")
|
||||
try:
|
||||
os.symlink(target, os.path.join(machine, "link"))
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("cannot create symlinks here")
|
||||
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
|
||||
self.t.cleanup()
|
||||
self.assertFalse(os.path.exists(self.t.dir))
|
||||
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
|
||||
|
||||
def test_a_git_hook_environment_is_ignored(self):
|
||||
scripts = os.path.join(REPO_ROOT, "scripts")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "unittest",
|
||||
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
|
||||
cwd=scripts, capture_output=True, text=True,
|
||||
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_the_options_are_read_from_the_engine(self):
|
||||
keys = apt._text_option_keys()
|
||||
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
|
||||
"notes", "post_process"} <= keys)
|
||||
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
|
||||
|
||||
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
|
||||
|
||||
def keys_from(self, print_config, tab=None):
|
||||
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
|
||||
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
|
||||
for path, text in zip(paths, (print_config, tab)):
|
||||
if text is not None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
apt._text_option_keys.cache_clear()
|
||||
self.addCleanup(apt._text_option_keys.cache_clear)
|
||||
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
|
||||
mock.patch.object(apt, "TAB_CPP", paths[1]):
|
||||
return apt._text_option_keys()
|
||||
|
||||
def test_multiline_is_read_whatever_its_spacing(self):
|
||||
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
|
||||
' def->multiline = true;\n')
|
||||
self.assertIn("printer_notes", keys)
|
||||
|
||||
def test_the_code_options_of_tab_cpp_are_read(self):
|
||||
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
|
||||
'option.opt.is_code = true;\n'
|
||||
'option = optgroup->get_option("layer_height");\n')
|
||||
self.assertIn("filename_format", keys)
|
||||
self.assertNotIn("layer_height", keys)
|
||||
|
||||
def test_a_missing_tab_cpp_is_not_an_error(self):
|
||||
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
|
||||
|
||||
def test_markdown_in_a_file_name_stays_literal(self):
|
||||
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
|
||||
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
|
||||
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1126,6 +1126,19 @@ static void load_downward_settings_list_from_config(std::string config_file, std
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __WXGTK__
|
||||
// Virtual GPUs (VirtualBox, VMware, QEMU) cannot back WebKit's hardware DMABUF renderer.
|
||||
static bool is_hypervisor_guest()
|
||||
{
|
||||
std::ifstream cpuinfo("/proc/cpuinfo");
|
||||
std::string line;
|
||||
while (std::getline(cpuinfo, line))
|
||||
if (boost::starts_with(line, "flags"))
|
||||
return line.find(" hypervisor") != std::string::npos;
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
int CLI::run(int argc, char **argv)
|
||||
{
|
||||
// Mark the main thread for the debugger and for runtime checks.
|
||||
@@ -1176,6 +1189,9 @@ int CLI::run(int argc, char **argv)
|
||||
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
|
||||
}
|
||||
|
||||
if (is_hypervisor_guest() && WebView::SupportsForceSharedMemory())
|
||||
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
|
||||
|
||||
if (x11_opt_in) {
|
||||
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
|
||||
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
|
||||
|
||||
@@ -756,7 +756,14 @@ public:
|
||||
Preset& get_edited_preset() { return m_edited_preset; }
|
||||
const Preset& get_edited_preset() const { return m_edited_preset; }
|
||||
|
||||
const Preset& get_selected_preset_base() const { return *get_preset_base(m_presets[m_idx_selected]); }
|
||||
// The root of the selected preset's inherits chain, or the selected preset itself when
|
||||
// the chain names a preset that is not loaded (e.g. a vendor that is not installed).
|
||||
const Preset& get_selected_preset_base() const
|
||||
{
|
||||
const Preset &selected = m_presets[m_idx_selected];
|
||||
const Preset *base = get_preset_base(selected);
|
||||
return base ? *base : selected;
|
||||
}
|
||||
|
||||
// Return the last saved preset.
|
||||
// const Preset& get_saved_preset() const { return m_saved_preset; }
|
||||
|
||||
@@ -75,7 +75,7 @@ std::vector<float> Layers::get_zs() const
|
||||
|
||||
size_t Layers::get_layer_id_at(float z) const
|
||||
{
|
||||
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z < z; });
|
||||
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z > z; });
|
||||
return std::distance(m_items.begin(), iter);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
#include "GUI.hpp"
|
||||
#include "GUI_App.hpp"
|
||||
#include "I18N.hpp"
|
||||
#include "ImGuiWrapper.hpp"
|
||||
#include "MainFrame.hpp"
|
||||
#include "NativeCommands.hpp"
|
||||
#include "Notebook.hpp"
|
||||
#include "NotificationManager.hpp"
|
||||
#include "OptionsGroup.hpp"
|
||||
#include "Plater.hpp"
|
||||
#include "SettingsIndex.hpp"
|
||||
@@ -101,45 +99,6 @@ std::string find_loaded_source_name(PluginManager& manager, const std::string& p
|
||||
return plugin_key;
|
||||
}
|
||||
|
||||
// Translates a Script capability outcome into the action layer's result vocabulary. Shared by
|
||||
// the synchronous run path and the async completion notification.
|
||||
AppActionRunResult script_outcome_to_action_result(const ScriptCompletion& completion)
|
||||
{
|
||||
// Cancelled by PluginManager (unload/shutdown): expected termination, nothing to show.
|
||||
if (completion.cancelled)
|
||||
return {};
|
||||
|
||||
if (!completion.error.empty())
|
||||
return {AppActionRunResult::Level::Error, from_u8(completion.error)};
|
||||
|
||||
const ExecutionResult& result = completion.result;
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
|
||||
result.message.empty() ? fallback : from_u8(result.message)};
|
||||
}
|
||||
|
||||
// Surfaces the eventual outcome of an async script launched from the speed dial. That launch
|
||||
// already returned (the UI was not blocked), so the finish is reported as a notification. A
|
||||
// cancellation is silent.
|
||||
void notify_script_outcome(const ScriptCompletion& completion)
|
||||
{
|
||||
if (completion.cancelled)
|
||||
return;
|
||||
if (wxTheApp == nullptr || wxGetApp().is_closing())
|
||||
return;
|
||||
|
||||
Plater* plater = wxGetApp().plater();
|
||||
if (plater == nullptr || wxGetApp().imgui() == nullptr || !wxGetApp().imgui()->display_initialized())
|
||||
return;
|
||||
|
||||
const AppActionRunResult action_result = script_outcome_to_action_result(completion);
|
||||
const auto level = action_result.level == AppActionRunResult::Level::Error
|
||||
? NotificationManager::NotificationLevel::ErrorNotificationLevel
|
||||
: NotificationManager::NotificationLevel::RegularNotificationLevel;
|
||||
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, level, into_u8(action_result.message));
|
||||
}
|
||||
|
||||
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
|
||||
// (identity), so a plugin display-name change does not re-key the action.
|
||||
struct PluginScriptAction : AppAction
|
||||
@@ -165,36 +124,15 @@ struct PluginScriptAction : AppAction
|
||||
|
||||
AppActionRunResult run(const std::string& /*param*/) const override
|
||||
{
|
||||
struct Outcome
|
||||
{
|
||||
bool async = false;
|
||||
bool completed = false;
|
||||
AppActionRunResult result;
|
||||
};
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
std::string error;
|
||||
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
|
||||
if (!error.empty())
|
||||
return {AppActionRunResult::Level::Error, from_u8(error)};
|
||||
|
||||
auto on_done = [outcome](const ScriptCompletion& completion) {
|
||||
outcome->result = script_outcome_to_action_result(completion);
|
||||
outcome->completed = true;
|
||||
if (outcome->async)
|
||||
notify_script_outcome(completion);
|
||||
};
|
||||
|
||||
const PluginManager::ScriptLaunchResult launch = PluginManager::instance().launch_script_capability(plugin_key, capability, on_done);
|
||||
if (launch.launched_async) {
|
||||
// The completion is delivered on this (GUI) thread once the (sync or async) invocation
|
||||
// finishes, so marking `async` here cannot race with on_done. The speed dial has already
|
||||
// shown a result by then; the final outcome arrives as a notification (a cancellation is
|
||||
// silent).
|
||||
outcome->async = true;
|
||||
if (outcome->completed)
|
||||
return outcome->result;
|
||||
return {AppActionRunResult::Level::Info, _L("Script plugin started.")};
|
||||
}
|
||||
|
||||
// The launch failed inline; on_done produced the (error) result.
|
||||
on_done(launch.outcome);
|
||||
return outcome->result;
|
||||
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)};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
|
||||
m_printer_name_to_preset.clear();
|
||||
for (const Preset &printer_preset : printer_presets) {
|
||||
if (!printer_preset.is_visible) continue;
|
||||
if (preset_bundle->printers.get_preset_base(printer_preset)->name != printer_preset.name) continue;
|
||||
if (preset_bundle->printers.get_preset_base(printer_preset) != &printer_preset) continue;
|
||||
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
|
||||
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
|
||||
printer_choice.push_back(from_u8(printer_model->value));
|
||||
@@ -4962,7 +4962,7 @@ void CreatePresetForPrinterDialog::get_visible_printer_and_compatible_filament_p
|
||||
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
|
||||
ConfigOptionStrings *filament_types;
|
||||
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
|
||||
if (filament_preset_base == &filament_preset) {
|
||||
if (filament_preset_base == &filament_preset || filament_preset_base == nullptr) {
|
||||
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
|
||||
} else {
|
||||
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
#include "Search.hpp"
|
||||
#include "BitmapCache.hpp"
|
||||
#include "GUI_App.hpp"
|
||||
#include "KeyChord.hpp"
|
||||
|
||||
#include <nanosvg/nanosvg.h>
|
||||
#include <nanosvg/nanosvgrast.h>
|
||||
@@ -2134,6 +2135,27 @@ bool ImGuiWrapper::want_any_input() const
|
||||
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::text_input_active() const
|
||||
{
|
||||
const ImGuiID id = ImGui::GetActiveID();
|
||||
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::modal_popup_open() const
|
||||
{
|
||||
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
|
||||
// frame drew it, which NewFrame() moves from Active to WasActive.
|
||||
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
|
||||
if (modal == nullptr)
|
||||
return false;
|
||||
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
|
||||
{
|
||||
return modal_popup_open() || (chord.edits_text() && text_input_active());
|
||||
}
|
||||
|
||||
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
|
||||
{
|
||||
auto item = m_custom_glyph_rects_ids.find(tex_id);
|
||||
|
||||
@@ -35,6 +35,7 @@ struct ImRect;
|
||||
namespace Slic3r {
|
||||
namespace GUI {
|
||||
|
||||
struct KeyChord;
|
||||
|
||||
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
|
||||
|
||||
@@ -219,6 +220,12 @@ public:
|
||||
bool want_keyboard() const;
|
||||
bool want_text_input() const;
|
||||
bool want_any_input() const;
|
||||
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
|
||||
bool text_input_active() const;
|
||||
// True while an ImGui modal popup is on screen.
|
||||
bool modal_popup_open() const;
|
||||
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
|
||||
bool owns_key(const KeyChord& chord) const;
|
||||
|
||||
// Optional inputs are used for set up value inside of an optional, with default value
|
||||
//
|
||||
|
||||
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
|
||||
using Section = ShortcutSection;
|
||||
|
||||
if (wxGetApp().is_editor()) {
|
||||
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, {
|
||||
page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
|
||||
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
|
||||
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
|
||||
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
|
||||
@@ -438,17 +438,16 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
|
||||
SetBackgroundColour(*wxWHITE);
|
||||
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use.
|
||||
// A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
|
||||
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
|
||||
const wxString advice = global_key_advice();
|
||||
const wxString typing = _L("Global shortcuts also apply while typing.");
|
||||
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
|
||||
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms.");
|
||||
m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
|
||||
m_rejection = rule + "\n" + advice;
|
||||
|
||||
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
|
||||
int width = FromDIP(450);
|
||||
for (const wxString& sentence : { typing, rule, advice }) {
|
||||
for (const wxString& sentence : { rule, advice }) {
|
||||
int extent = 0;
|
||||
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
|
||||
width = std::max(width, extent);
|
||||
|
||||
@@ -187,6 +187,40 @@ bool KeyChord::is_menu_accelerator() const
|
||||
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
|
||||
}
|
||||
|
||||
bool KeyChord::edits_text() const
|
||||
{
|
||||
if (!valid())
|
||||
return false;
|
||||
if (!is_menu_accelerator())
|
||||
return true;
|
||||
const int mods = modifiers & ~wxMOD_SHIFT;
|
||||
switch (key) {
|
||||
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
|
||||
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
|
||||
#ifdef __APPLE__
|
||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
|
||||
#else
|
||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
||||
#endif
|
||||
case WXK_INSERT:
|
||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
||||
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
|
||||
return mods == wxMOD_NONE;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (!is_printable(key))
|
||||
return false;
|
||||
#ifdef __APPLE__
|
||||
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
|
||||
return true; // Option types a character, Control runs the Cocoa text key bindings
|
||||
#else
|
||||
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
|
||||
return true; // AltGr types a character
|
||||
#endif
|
||||
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
|
||||
}
|
||||
|
||||
// Only a chord the desktop acts on while still delivering it to the app belongs here.
|
||||
bool KeyChord::is_system_shortcut() const
|
||||
{
|
||||
|
||||
@@ -35,6 +35,9 @@ struct KeyChord
|
||||
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
|
||||
// swallowing typing in text fields.
|
||||
bool is_menu_accelerator() const;
|
||||
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
|
||||
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
|
||||
bool edits_text() const;
|
||||
// True for a chord the desktop acts on although the app receives it, so a binding would
|
||||
// take it from the system.
|
||||
bool is_system_shortcut() const;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
#include <wx/panel.h>
|
||||
#include <wx/settings.h>
|
||||
#include <wx/textentry.h>
|
||||
#include <wx/spinctrl.h>
|
||||
#include <wx/notebook.h>
|
||||
#include <wx/listbook.h>
|
||||
#include <wx/simplebook.h>
|
||||
@@ -107,6 +108,8 @@
|
||||
#include "../Utils/PrintHost.hpp"
|
||||
|
||||
#include "GUI_App.hpp"
|
||||
#include "ImGuiWrapper.hpp"
|
||||
#include "OpenGLManager.hpp"
|
||||
#include "Shortcuts.hpp"
|
||||
#include "UnsavedChangesDialog.hpp"
|
||||
#include "PublishSettingsDialog.hpp"
|
||||
@@ -184,18 +187,31 @@ enum class ERescaleTarget
|
||||
|
||||
namespace {
|
||||
|
||||
bool focus_is_text_field(wxWindow* focus)
|
||||
{
|
||||
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
|
||||
return true; // a web page can have its own text fields
|
||||
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
|
||||
return true; // the native Windows spin control is not a wxTextEntry
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
|
||||
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
|
||||
{
|
||||
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
|
||||
return nullptr;
|
||||
return wxGetApp().imgui();
|
||||
}
|
||||
|
||||
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
|
||||
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
|
||||
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
|
||||
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
|
||||
// controls, so only take Space when the focused window has no keyboard-activation meaning of its
|
||||
// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
|
||||
// itself does too, so Space still opens the dial on any page.
|
||||
bool focus_keeps_space(wxWindow* focus)
|
||||
{
|
||||
if (!focus)
|
||||
return false;
|
||||
if (dynamic_cast<wxTextEntryBase*>(focus))
|
||||
return true; // typing a space into a text field
|
||||
if (dynamic_cast<wxWebView*>(focus))
|
||||
return true; // web content scrolls and hosts its own text fields
|
||||
if (dynamic_cast<::Button*>(focus))
|
||||
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
|
||||
if (dynamic_cast<StaticBox*>(focus))
|
||||
@@ -816,16 +832,12 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
|
||||
|
||||
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
|
||||
{
|
||||
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so
|
||||
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame:
|
||||
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
|
||||
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
|
||||
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
|
||||
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
|
||||
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
|
||||
// CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
|
||||
// the keys ImGui owns to the focused canvas.
|
||||
wxWindow* const focus = wxWindow::FindFocus();
|
||||
ImGuiWrapper* const imgui = wxGetApp().imgui();
|
||||
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr)
|
||||
if (chord.edits_text() && focus_is_text_field(focus))
|
||||
return false;
|
||||
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
|
||||
return false;
|
||||
|
||||
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
|
||||
|
||||
@@ -427,6 +427,8 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
|
||||
return m_context;
|
||||
}
|
||||
|
||||
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
|
||||
|
||||
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
||||
{
|
||||
int antialiasing_samples = 4;
|
||||
@@ -470,12 +472,17 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
||||
attribList[samples_value_idx] = 0;
|
||||
}
|
||||
|
||||
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS);
|
||||
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
|
||||
// The GL canvas paints its entire surface, so background erasing is unnecessary.
|
||||
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
|
||||
{
|
||||
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
|
||||
}
|
||||
|
||||
void OpenGLManager::detect_multisample(int* attribList)
|
||||
{
|
||||
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
|
||||
|
||||
@@ -101,6 +101,8 @@ public:
|
||||
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
|
||||
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
|
||||
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
|
||||
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
|
||||
static bool is_scene_canvas(const wxWindow* window);
|
||||
static const GLInfo& get_gl_info() { return s_gl_info; }
|
||||
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
|
||||
|
||||
|
||||
@@ -1332,21 +1332,6 @@ boost::any ConfigOptionsGroup::get_config_value2(const DynamicPrintConfig& confi
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool ConfigOptionsGroup::commit_config_value(const t_config_option_key& opt_key, int opt_index)
|
||||
{
|
||||
if (m_config == nullptr)
|
||||
return false;
|
||||
|
||||
// m_opt_map is filled while the option lines are created, so it still resolves the option when
|
||||
// the group's controls (and thus its fields) have not been built yet.
|
||||
const std::string opt_id = option_id_in_map(m_opt_map, opt_key, opt_index);
|
||||
if (opt_id.empty())
|
||||
return false;
|
||||
|
||||
on_change_OG(opt_id, get_config_value2(*m_config, opt_key, opt_index));
|
||||
return true;
|
||||
}
|
||||
|
||||
Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index)
|
||||
{
|
||||
Field* field = get_field(opt_key);
|
||||
|
||||
@@ -119,37 +119,6 @@ using column_t = std::function<wxWindow*(wxWindow* parent, const Line&)>;
|
||||
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
||||
using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
|
||||
|
||||
// The option id a group's option map holds for opt_key, and the index it carries. `opt_index` is
|
||||
// an in/out parameter: a negative value asks for the option's single entry, an explicit one selects
|
||||
// that index. Returns an empty string when the map does not own the option, or when a negative index
|
||||
// matches several entries -- a vector option the GUI edits one index at a time, which has no single
|
||||
// value to commit. A group fills the map while its option lines are created (OptionsGroup::append_line
|
||||
// / ConfigOptionsGroup::get_option), so this resolves an option even before its controls (and its
|
||||
// field) have been built.
|
||||
inline std::string option_id_in_map(const t_opt_map& options, const t_config_option_key& opt_key, int& opt_index)
|
||||
{
|
||||
if (opt_index >= 0) {
|
||||
for (const auto& opt : options) {
|
||||
if (opt.second.first == opt_key && opt.second.second == opt_index) {
|
||||
opt_index = opt.second.second;
|
||||
return opt.first;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string opt_id;
|
||||
for (const auto& opt : options) {
|
||||
if (opt.second.first != opt_key)
|
||||
continue;
|
||||
if (!opt_id.empty())
|
||||
return {}; // several entries: a vector option, not one value
|
||||
opt_id = opt.first;
|
||||
opt_index = opt.second.second;
|
||||
}
|
||||
return opt_id;
|
||||
}
|
||||
|
||||
class OptionsGroup {
|
||||
public:
|
||||
const bool staticbox{true};
|
||||
@@ -363,15 +332,6 @@ public:
|
||||
boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||
// BBS: restore all pages in preset
|
||||
boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||
// Re-run this group's committed-change path for one of its options without a widget event:
|
||||
// build the option's value from the config this group edits (get_config_value2, the same form
|
||||
// the option's field reports) and dispatch on_change_OG(), so the group's option-specific
|
||||
// conversion (e.g. the printer_agent canonicalization) and its registered change handler run
|
||||
// exactly as for a hand edit. The option map is filled while the option lines are created, so
|
||||
// this resolves an option whose field is not built yet (a lazy page, or an option the GUI has
|
||||
// no field for). Returns false when this group does not own the option, or when opt_index < 0
|
||||
// matches several entries (a vector option the GUI edits index by index has no single value).
|
||||
bool commit_config_value(const t_config_option_key& opt_key, int opt_index = -1);
|
||||
Field* get_fieldc(const t_config_option_key& opt_key, int opt_index);
|
||||
std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
|
||||
|
||||
|
||||
@@ -21178,7 +21178,12 @@ void Plater::print_job_finished(wxCommandEvent &evt)
|
||||
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
|
||||
if (!dev) return;
|
||||
|
||||
dev->set_selected_machine(evt.GetString().ToStdString());
|
||||
const std::string dev_id = evt.GetString().ToStdString();
|
||||
MachineObject* selected = dev->get_selected_machine();
|
||||
// After a LAN print, the completion event reselects the same printer.
|
||||
// Keep the active session while asynchronous status traffic may still be in flight.
|
||||
if (!selected || selected->get_dev_id() != dev_id || selected->connection_type() != "lan")
|
||||
dev->set_selected_machine(dev_id);
|
||||
p->main_frame->request_select_tab(TAB_ID_MONITOR);
|
||||
// Selects the status page on a built Device tab; one built by the switch starts there.
|
||||
MonitorPanel* curr_monitor = MonitorPanel::if_built();
|
||||
|
||||
@@ -1158,7 +1158,10 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
||||
{
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
|
||||
auto complete_with_error = [this, &manager, plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
||||
std::string error;
|
||||
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;
|
||||
if (!manager.set_plugin_error(plugin_key, normalized_error))
|
||||
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
||||
@@ -1174,53 +1177,32 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
||||
show_status(message, "error");
|
||||
};
|
||||
|
||||
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
|
||||
// the runtime delivers it later; it is called inline here only when the launch itself failed.
|
||||
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
|
||||
if (!alive->load())
|
||||
return;
|
||||
|
||||
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
|
||||
// error nor a normal success, and do not touch the plugin's error/state.
|
||||
if (completion.cancelled)
|
||||
return;
|
||||
|
||||
if (!completion.error.empty()) {
|
||||
complete_with_error(completion.error, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
const ExecutionResult& result = completion.result;
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
||||
<< " status=" << static_cast<int>(result.status) << " message=" << result.message
|
||||
<< " data=" << result.data;
|
||||
|
||||
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
|
||||
if (failed) {
|
||||
complete_with_error(result.message, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
manager.clear_plugin_error(plugin_key);
|
||||
send_plugins();
|
||||
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
||||
show_status(message, skipped ? "info" : "success");
|
||||
};
|
||||
|
||||
PluginManager::ScriptLaunchResult launch;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
|
||||
result = manager.run_script_capability(plugin_key, capability_name, error);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!error.empty()) {
|
||||
complete_with_error(error, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
||||
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
|
||||
|
||||
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
|
||||
if (failed) {
|
||||
complete_with_error(result.message, wxString());
|
||||
return;
|
||||
}
|
||||
|
||||
manager.clear_plugin_error(plugin_key);
|
||||
send_plugins();
|
||||
|
||||
const bool skipped = result.status == PluginResult::Skipped;
|
||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
||||
show_status(message, skipped ? "info" : "success");
|
||||
}
|
||||
|
||||
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
||||
|
||||
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
|
||||
{
|
||||
m_printer_last_select = "";
|
||||
update_select_layout(nullptr);
|
||||
m_printer_box->GetPrinterComboBox()->SetTextLabel("");
|
||||
m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||
|
||||
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
|
||||
}
|
||||
else {
|
||||
m_printer_last_select = "";
|
||||
m_comboBox_printer->SetTextLabel("");
|
||||
m_comboBox_printer->SetValue(_L("No available printers"));
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include "libslic3r/LifecycleEvents.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "slic3r/GUI/SettingsIndex.hpp"
|
||||
@@ -1646,138 +1645,6 @@ void Tab::load_config(const DynamicPrintConfig& config)
|
||||
}
|
||||
}
|
||||
|
||||
// The option keys a preset of `type` may hold. The tab's config shares the global
|
||||
// print_config_def with every other preset type, so membership has to be checked against the
|
||||
// preset type's own key list instead of the config definition, or a printer setting could be
|
||||
// written into the process preset.
|
||||
static const std::vector<std::string>& preset_type_options(Preset::Type type)
|
||||
{
|
||||
static const std::vector<std::string> none;
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return Preset::print_options();
|
||||
case Preset::TYPE_FILAMENT: return Preset::filament_options();
|
||||
case Preset::TYPE_PRINTER: return Preset::printer_options();
|
||||
default: return none;
|
||||
}
|
||||
}
|
||||
|
||||
static const char* preset_type_name(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return "process";
|
||||
case Preset::TYPE_FILAMENT: return "filament";
|
||||
case Preset::TYPE_PRINTER: return "printer";
|
||||
default: return "preset";
|
||||
}
|
||||
}
|
||||
|
||||
// Commit one option whose new value already sits in m_config through the same path a hand edit
|
||||
// runs. The key-specific behaviour (ConfigOptionsGroup::on_change_OG, including the printer_agent
|
||||
// canonicalization, the group's pre-checks and Tab::on_value_change()) runs exactly as for a hand
|
||||
// edit. A built field supplies the field's typed/clamped value; otherwise the owning
|
||||
// ConfigOptionsGroup builds the value from the config it edits and dispatches the same
|
||||
// on_change_OG(). The option's page is reloaded from the config just committed first, so a built
|
||||
// field reports the committed value rather than a stale one. No tab-wide refresh happens here: the
|
||||
// caller does it once for the whole batch.
|
||||
bool Tab::commit_preset_config_value(const std::string& opt_key)
|
||||
{
|
||||
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
|
||||
field_page->reload_config();
|
||||
field->field_changed();
|
||||
return true;
|
||||
}
|
||||
for (const PageShp& page : m_pages)
|
||||
for (const ConfigOptionsGroupShp& group : page->m_optgroups)
|
||||
if (group->commit_config_value(opt_key))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set one option of the currently edited preset from its serialized string form, then run the
|
||||
// same update path a hand edit runs. The edited preset is not saved: it only becomes dirty,
|
||||
// exactly as if the field had been edited by hand.
|
||||
void Tab::apply_preset_config_value(const std::string& opt_key, const std::string& value)
|
||||
{
|
||||
apply_preset_config_values({{opt_key, value}});
|
||||
}
|
||||
|
||||
// Apply a batch of settings as one transaction: parse and validate the whole batch first, then
|
||||
// commit it through the same per-setting path apply_preset_config_value() uses, restoring the edited
|
||||
// preset and the state derived from it if a setting fails. See the header for the contract and its
|
||||
// limits.
|
||||
void Tab::apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values)
|
||||
{
|
||||
if (m_config == nullptr)
|
||||
throw std::runtime_error("The preset configuration is not available");
|
||||
if (wxGetApp().plater() == nullptr)
|
||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
||||
|
||||
if (values.empty())
|
||||
return;
|
||||
|
||||
const std::vector<std::string>& options = preset_type_options(m_type);
|
||||
|
||||
// Preparation: reject every batch that cannot be applied before touching the edited preset, so a
|
||||
// bad key or value fails without a partial write. The key must belong to this preset type, it must
|
||||
// not repeat, and each value is strict-parsed into a scratch copy (the strict deserializer can
|
||||
// already have mutated an option when it rejects a bad value, so the check has to run somewhere
|
||||
// the edited preset is not affected). Callback failures cannot be prepared this way: a setting's
|
||||
// on_change callback is a GUI method, not a pure function of the config, so whether it throws is
|
||||
// only known by running it. That residual case is handled by the rollback below.
|
||||
std::set<std::string> seen;
|
||||
DynamicPrintConfig scratch(*m_config);
|
||||
for (const auto& kv : values) {
|
||||
const std::string& opt_key = kv.first;
|
||||
if (std::find(options.begin(), options.end(), opt_key) == options.end())
|
||||
throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key));
|
||||
if (!seen.insert(opt_key).second)
|
||||
throw std::invalid_argument(format("%1% preset setting \"%2%\" is set more than once in the same update",
|
||||
preset_type_name(m_type), opt_key));
|
||||
try {
|
||||
scratch.set_deserialize_strict(opt_key, kv.second);
|
||||
} catch (const ConfigurationError& err) {
|
||||
throw std::invalid_argument(err.what());
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback boundary: snapshot the whole edited config, not just the keys the batch names, because a
|
||||
// key-specific callback can adjust related settings of the edited preset (mutually exclusive
|
||||
// options, compatibility, and so on). A failure restores this config and then recomputes the state
|
||||
// derived from it.
|
||||
DynamicPrintConfig before(*m_config);
|
||||
try {
|
||||
for (const auto& kv : values) {
|
||||
m_config->set_deserialize_strict(kv.first, kv.second);
|
||||
if (!commit_preset_config_value(kv.first))
|
||||
// The GUI edits this setting through a custom widget rather than a group (e.g.
|
||||
// compatible_printers), so take the same shared no-field commit path the widget uses.
|
||||
// The value was written above, so saved mode skips the write and keeps the path's
|
||||
// compatibility recompute, dirty flag, preset-combo and plater updates.
|
||||
load_key_value(kv.first, boost::any(), /*saved_value=*/true);
|
||||
}
|
||||
} catch (...) {
|
||||
*m_config = before;
|
||||
// A callback can rewrite state derived from the config (compatibility marks, the preset
|
||||
// selector, dependent tabs, the Plater and, on the printer tab, the printer agent) before it
|
||||
// throws. Recompute that derived state from the restored config so none of it is left
|
||||
// reflecting the failed batch. Effects that are not a function of the config (a dialog the
|
||||
// user already accepted, an undo snapshot, a command already sent to a device, the target
|
||||
// selection) cannot be reversed here; see the header.
|
||||
m_preset_bundle->update_compatible(PresetSelectCompatibleType::Never);
|
||||
reload_config();
|
||||
update_dirty();
|
||||
update();
|
||||
throw;
|
||||
}
|
||||
|
||||
// Apply the batch's final state once. The commit paths above already maintained the dirty flag,
|
||||
// preset combo and, on the printer tab, the printer-agent switch; this reloads the visible page
|
||||
// and pushes the config to the Plater.
|
||||
reload_config();
|
||||
update_dirty();
|
||||
update();
|
||||
}
|
||||
|
||||
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
|
||||
void Tab::reload_config()
|
||||
{
|
||||
|
||||
@@ -424,40 +424,6 @@ public:
|
||||
//BBS update plater presets if update_plater_presets = true
|
||||
void update_tab_ui(bool update_plater_presets = false);
|
||||
void load_config(const DynamicPrintConfig& config);
|
||||
// Host/plugin API: set one option of this tab's currently edited preset from its serialized
|
||||
// string value, then run the same committed-change path a field edit runs. `opt_key` must be a
|
||||
// setting of this tab's preset type, so a key of another preset type (e.g. a printer setting
|
||||
// passed to the process tab) is rejected instead of written silently. `value` is parsed by the
|
||||
// strict config deserializer, which raises std::invalid_argument for an unknown key or a value
|
||||
// the option cannot parse, and leaves the preset unchanged. The key-specific behaviour runs
|
||||
// through its ConfigOptionsGroup::on_change_OG(): with the option's built field it uses the
|
||||
// field's typed value, otherwise the owning group builds the value and dispatches the same call,
|
||||
// so a not-yet-built field still gets it. A key the GUI edits through a custom widget instead
|
||||
// takes the shared no-field commit path, Tab::load_key_value() in saved mode.
|
||||
void apply_preset_config_value(const std::string& opt_key, const std::string& value);
|
||||
|
||||
// Host/plugin API: apply a batch of settings to this tab's currently edited preset as one
|
||||
// transaction. Each pair is a setting of this tab's preset type and its serialized string value.
|
||||
// The whole batch is parsed and validated before the edited preset is touched; each setting then
|
||||
// runs the same committed-change path apply_preset_config_value() uses, so setting-specific
|
||||
// validation, correction and on_change callbacks stay authoritative. Duplicate keys are rejected,
|
||||
// because the transaction must not resolve an incidental write order, and the batch order is
|
||||
// preserved. If applying the batch fails, the edited preset's config is restored to its pre-batch
|
||||
// state and the state derived from it (compatibility marks, the preset selector, dependent tabs,
|
||||
// the Plater and the printer agent) is recomputed, so the edited preset never keeps a subset of
|
||||
// the staged settings.
|
||||
//
|
||||
// Only the edited preset is restored. A setting's on_change callback is a GUI method, not a pure
|
||||
// function of the config, so effects outside the edited preset cannot be prepared or undone: the
|
||||
// transaction does not reverse preset/slot selection made while resolving the target, a dialog
|
||||
// decision the user already accepted, an undo snapshot, a dependent preset's rewritten config, or
|
||||
// anything already sent to a device. Exact all-or-nothing across those is not achievable while the
|
||||
// authoritative GUI callbacks run; this method guarantees the edited preset's own settings, not
|
||||
// those external effects.
|
||||
//
|
||||
// Throws std::invalid_argument for an unknown key, a value the option cannot parse, or a duplicate
|
||||
// key; std::runtime_error when the preset or main window is unavailable.
|
||||
void apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values);
|
||||
virtual void reload_config();
|
||||
void update_mode();
|
||||
void update_visibility();
|
||||
@@ -541,12 +507,6 @@ protected:
|
||||
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
|
||||
void compatible_widget_reload(PresetDependencies &deps);
|
||||
void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false);
|
||||
// Commit one option whose new value already sits in m_config through the same path a field edit
|
||||
// runs (throwing field, otherwise the owning group's on_change_OG()). Reloads the option's page
|
||||
// so a built field reports the committed value, but performs no tab-wide refresh. Returns true
|
||||
// when a built field or a group owns the option, false when the GUI edits it through a custom
|
||||
// widget with no group entry.
|
||||
bool commit_preset_config_value(const std::string& opt_key);
|
||||
|
||||
//BBS: GUI refactor
|
||||
// return true if cancelled
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
#endif
|
||||
|
||||
#ifdef __LINUX__
|
||||
#include "LinuxDisplayBackend.hpp"
|
||||
#include <wx/textfile.h>
|
||||
#include <wx/tokenzr.h>
|
||||
#include <cstdlib>
|
||||
@@ -201,7 +202,11 @@ TroubleshootDialog::TroubleshootDialog()
|
||||
return std::vector<wxString>{GetOStype()};
|
||||
return std::vector<wxString>{
|
||||
GetOSinfo(),
|
||||
#ifdef __LINUX__
|
||||
GetPackageType() + GetAppDisplayBackend(),
|
||||
#else
|
||||
GetPackageType(),
|
||||
#endif
|
||||
GetCPUinfo(),
|
||||
GetRAMinfo() + " RAM",
|
||||
GetGPUinfo(),
|
||||
@@ -434,10 +439,15 @@ wxString TroubleshootDialog::GetTimestamp()
|
||||
|
||||
wxString TroubleshootDialog::GetSysInfoAll()
|
||||
{
|
||||
wxString package = GetPackageType();
|
||||
#ifdef __LINUX__
|
||||
package += GetAppDisplayBackend();
|
||||
#endif
|
||||
|
||||
wxString info;
|
||||
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
|
||||
+ "Build : " + wxString(build_commit_label) + "\n"
|
||||
+ "Package : " + GetPackageType() + "\n"
|
||||
+ "Package : " + package + "\n"
|
||||
+ "Platform : " + GetOSinfo() + "\n"
|
||||
+ "Processor : " + GetCPUinfo() + "\n"
|
||||
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
|
||||
@@ -751,6 +761,18 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
wxString TroubleshootDialog::GetAppDisplayBackend()
|
||||
{
|
||||
// Display backend actually used by the app (GDK), not the session type
|
||||
wxString display;
|
||||
switch (get_linux_display_backend()) {
|
||||
case LinuxDisplayBackend::X11: display = "X11"; break;
|
||||
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
|
||||
default: break;
|
||||
}
|
||||
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
|
||||
}
|
||||
#endif
|
||||
|
||||
wxString TroubleshootDialog::GetPackageType()
|
||||
|
||||
@@ -68,6 +68,7 @@ protected:
|
||||
#elif defined(__LINUX__)
|
||||
wxString GetLinuxDistroName();
|
||||
wxString GetLinuxDisplayServer();
|
||||
wxString GetAppDisplayBackend();
|
||||
#endif
|
||||
wxString GetPackageType();
|
||||
wxString GetCPUinfo();
|
||||
|
||||
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
|
||||
|
||||
void ComboBox::ForceDropdownOpen()
|
||||
{
|
||||
if (!IsEnabled())
|
||||
if (!IsEnabled() || items.empty())
|
||||
return;
|
||||
|
||||
if (!drop_down) {
|
||||
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
|
||||
SetFocus();
|
||||
if (drop_down) {
|
||||
drop.Hide();
|
||||
} else if (drop.HasDismissLongTime()) {
|
||||
} else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
|
||||
drop.autoPosition();
|
||||
drop_down = true;
|
||||
drop.Popup(&drop);
|
||||
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
|
||||
case WXK_SPACE:
|
||||
if (drop_down) {
|
||||
drop.DismissAndNotify();
|
||||
} else if (drop.HasDismissLongTime()) {
|
||||
} else if (!items.empty() && drop.HasDismissLongTime()) {
|
||||
drop.autoPosition();
|
||||
drop_down = true;
|
||||
drop.Popup();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "bambu_networking.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/DeviceManager.hpp"
|
||||
@@ -19,14 +20,19 @@
|
||||
#include <boost/algorithm/string/trim.hpp>
|
||||
#include <boost/asio/buffer.hpp>
|
||||
#include <boost/asio/connect.hpp>
|
||||
#include <boost/asio/error.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/beast/core.hpp>
|
||||
#include <boost/beast/core/tcp_stream.hpp>
|
||||
#include <boost/beast/http/field.hpp>
|
||||
#include <boost/beast/core/flat_buffer.hpp>
|
||||
#include <boost/beast/core/error.hpp>
|
||||
#include <boost/beast/core/buffers_to_string.hpp>
|
||||
#include <boost/beast/core/stream_traits.hpp>
|
||||
#include <boost/beast/core/string_type.hpp>
|
||||
#include <boost/beast/websocket.hpp>
|
||||
#include <boost/beast/websocket/stream.hpp>
|
||||
#include <boost/beast/websocket/stream_base.hpp>
|
||||
@@ -37,6 +43,7 @@
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cctype>
|
||||
@@ -120,6 +127,31 @@ std::string map_moonraker_state(std::string state)
|
||||
return "IDLE";
|
||||
}
|
||||
|
||||
// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
|
||||
template<class Initiate>
|
||||
beast::error_code run_until_done(net::io_context& ioc, const std::atomic<bool>& stop, Initiate&& initiate)
|
||||
{
|
||||
// An abort that runs before the operation starts finds nothing to cancel
|
||||
if (stop.load()) {
|
||||
return net::error::operation_aborted;
|
||||
}
|
||||
beast::error_code result;
|
||||
bool done = false;
|
||||
initiate([&](beast::error_code ec, auto&&...) {
|
||||
result = ec;
|
||||
done = true;
|
||||
});
|
||||
ioc.restart();
|
||||
while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
|
||||
return done ? result : beast::error_code{net::error::operation_aborted};
|
||||
}
|
||||
|
||||
uint64_t steady_now_ms()
|
||||
{
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -1298,6 +1330,8 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
|
||||
}
|
||||
http.timeout_connect(5)
|
||||
.timeout_max(10)
|
||||
// Gives up once the status stream, the only caller, stops
|
||||
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
|
||||
.on_complete([&](std::string body, unsigned status) {
|
||||
if (status == 200) {
|
||||
response_body = body;
|
||||
@@ -1468,19 +1502,47 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
ws_stop.store(false);
|
||||
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); });
|
||||
ws_thread = std::thread(
|
||||
[this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::stop_status_stream()
|
||||
{
|
||||
ws_stop.store(true);
|
||||
if (ws_thread.joinable()) {
|
||||
ws_thread.join();
|
||||
std::thread stopped;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
ws_stop.store(true);
|
||||
if (ws_abort) {
|
||||
ws_abort();
|
||||
}
|
||||
// A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
|
||||
if (ws_thread.get_id() != std::this_thread::get_id()) {
|
||||
stopped = std::move(ws_thread);
|
||||
}
|
||||
}
|
||||
if (stopped.joinable()) {
|
||||
stopped.join();
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key)
|
||||
void MoonrakerPrinterAgent::join_stopped_status_stream()
|
||||
{
|
||||
std::thread stopped;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
// A running stream belongs to a newer connection, so only one that is already stopping is joined
|
||||
if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
|
||||
stopped = std::move(ws_thread);
|
||||
}
|
||||
}
|
||||
if (stopped.joinable()) {
|
||||
stopped.join();
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
|
||||
{
|
||||
WsEndpoint endpoint;
|
||||
if (!parse_ws_endpoint(base_url, endpoint)) {
|
||||
@@ -1492,25 +1554,108 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
return;
|
||||
}
|
||||
|
||||
net::io_context ioc;
|
||||
net::steady_timer backoff{ioc};
|
||||
std::function<void()> abort_connection; // Set while a connection attempt runs
|
||||
const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
|
||||
|
||||
// Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
if (ws_stop.load()) {
|
||||
return;
|
||||
}
|
||||
ws_abort = [&] {
|
||||
net::post(ioc, [&] {
|
||||
backoff.cancel();
|
||||
if (abort_connection) {
|
||||
abort_connection();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
const ScopeGuard clear_abort([this] {
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
ws_abort = nullptr;
|
||||
});
|
||||
|
||||
// Reconnection logic
|
||||
ws_reconnect_requested.store(false); // Reset reconnect flag
|
||||
int retry_count = 0;
|
||||
const int max_retries = 10;
|
||||
const int base_delay_ms = 1000;
|
||||
int retry_count = 0;
|
||||
|
||||
while (!ws_stop.load() && retry_count < max_retries) {
|
||||
while (!ws_stop.load()) {
|
||||
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
|
||||
|
||||
tcp::resolver resolver{ioc};
|
||||
websocket::stream<beast::tcp_stream> ws{ioc};
|
||||
beast::tcp_stream& stream = beast::get_lowest_layer(ws);
|
||||
net::steady_timer close_deadline{ioc};
|
||||
bool closing = false;
|
||||
bool deadline_pending = false;
|
||||
auto last_message = std::chrono::steady_clock::now();
|
||||
// Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
|
||||
const auto close_politely = [&](websocket::close_code code) {
|
||||
if (!ws.is_open() || closing) {
|
||||
return;
|
||||
}
|
||||
closing = true;
|
||||
deadline_pending = true;
|
||||
close_deadline.expires_after(timings.close);
|
||||
close_deadline.async_wait([&](beast::error_code ec) {
|
||||
deadline_pending = false;
|
||||
if (!ec) {
|
||||
beast::error_code ignored;
|
||||
stream.socket().close(ignored);
|
||||
}
|
||||
});
|
||||
ws.async_close(code, [&](beast::error_code) {
|
||||
closing = false;
|
||||
close_deadline.cancel();
|
||||
});
|
||||
};
|
||||
abort_connection = [&] {
|
||||
// Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
if (ws.is_open()) {
|
||||
close_politely(websocket::close_code::going_away);
|
||||
} else {
|
||||
beast::error_code ignored;
|
||||
stream.socket().close(ignored);
|
||||
}
|
||||
};
|
||||
const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
|
||||
|
||||
try {
|
||||
net::io_context ioc;
|
||||
tcp::resolver resolver{ioc};
|
||||
beast::tcp_stream stream{ioc};
|
||||
beast::error_code connect_ec = net::error::host_not_found;
|
||||
for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
|
||||
beast::error_code ignored;
|
||||
stream.socket().close(ignored); // A failed attempt leaves the socket open
|
||||
stream.expires_after(std::chrono::seconds(5));
|
||||
connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
|
||||
if (!connect_ec || ws_stop.load()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (connect_ec) {
|
||||
throw beast::system_error{connect_ec};
|
||||
}
|
||||
// Clears the connect deadline, which would otherwise close the socket 5 s after connecting
|
||||
stream.expires_never();
|
||||
|
||||
stream.expires_after(std::chrono::seconds(10));
|
||||
auto const results = resolver.resolve(endpoint.host, endpoint.port);
|
||||
stream.connect(results);
|
||||
|
||||
websocket::stream<beast::tcp_stream> ws{std::move(stream)};
|
||||
websocket::stream_base::timeout timeouts{};
|
||||
timeouts.handshake_timeout = timings.handshake;
|
||||
timeouts.idle_timeout = timings.idle;
|
||||
timeouts.keep_alive_pings = true;
|
||||
ws.set_option(timeouts);
|
||||
// Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
|
||||
ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
|
||||
if (kind == websocket::frame_type::pong && !closing &&
|
||||
std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
|
||||
send_heartbeat_if_due(dev_id, timings.heartbeat);
|
||||
}
|
||||
});
|
||||
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
|
||||
req.set(http::field::user_agent, "OrcaSlicer");
|
||||
if (!api_key.empty()) {
|
||||
@@ -1522,7 +1667,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
if (!endpoint.port.empty() && endpoint.port != "80") {
|
||||
host_header += ":" + endpoint.port;
|
||||
}
|
||||
ws.handshake(host_header, endpoint.target);
|
||||
if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
|
||||
throw beast::system_error{ec};
|
||||
}
|
||||
ws.text(true);
|
||||
|
||||
// Send client identification
|
||||
@@ -1534,7 +1681,10 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
identify["params"]["type"] = "agent";
|
||||
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
|
||||
identify["id"] = 0;
|
||||
ws.write(net::buffer(identify.dump()));
|
||||
const std::string identify_payload = identify.dump();
|
||||
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
|
||||
throw beast::system_error{ec};
|
||||
}
|
||||
|
||||
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
|
||||
std::set<std::string> available_objects;
|
||||
@@ -1586,28 +1736,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
}
|
||||
subscribe["params"]["objects"] = std::move(objects);
|
||||
subscribe["id"] = 1;
|
||||
ws.write(net::buffer(subscribe.dump()));
|
||||
const std::string subscribe_payload = subscribe.dump();
|
||||
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
|
||||
throw beast::system_error{ec};
|
||||
}
|
||||
|
||||
// Read loop
|
||||
while (!ws_stop.load()) {
|
||||
ws.next_layer().expires_after(std::chrono::seconds(2));
|
||||
beast::flat_buffer buffer;
|
||||
beast::error_code ec;
|
||||
ws.read(buffer, ec);
|
||||
if (ec == beast::error::timeout) {
|
||||
const auto now_ms = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
const auto last_ms = ws_last_emit_ms.load();
|
||||
if (last_ms == 0 || now_ms - last_ms >= 10000) {
|
||||
nlohmann::json message;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
|
||||
message = build_print_payload_locked();
|
||||
}
|
||||
dispatch_message(dev_id, message.dump());
|
||||
ws_last_emit_ms.store(now_ms);
|
||||
}
|
||||
continue;
|
||||
const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
|
||||
if (ws_stop.load()) {
|
||||
break;
|
||||
}
|
||||
if (ec == websocket::error::closed) {
|
||||
connection_lost = true;
|
||||
@@ -1618,6 +1757,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
connection_lost = true;
|
||||
break;
|
||||
}
|
||||
last_message = std::chrono::steady_clock::now();
|
||||
// A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
|
||||
retry_count = 0;
|
||||
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
|
||||
// Check if handle_ws_message triggered reconnection request
|
||||
if (ws_reconnect_requested.exchange(false)) {
|
||||
@@ -1625,20 +1767,18 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
beast::error_code ec;
|
||||
ws.close(websocket::close_code::normal, ec);
|
||||
|
||||
// Only reset retry count on clean shutdown (not connection_lost)
|
||||
if (!connection_lost && !ws_stop.load()) {
|
||||
retry_count = 0;
|
||||
}
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
||||
if (!ws_stop.load()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
||||
}
|
||||
connection_lost = true;
|
||||
}
|
||||
|
||||
close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
|
||||
ioc.restart();
|
||||
// Both handlers refer to this connection, so neither may be left queued for the next one
|
||||
while ((closing || deadline_pending) && ioc.run_one() != 0) {}
|
||||
|
||||
// Exit loop on clean shutdown
|
||||
if (!connection_lost) {
|
||||
break;
|
||||
@@ -1649,17 +1789,13 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff before reconnection
|
||||
int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5));
|
||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")";
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
|
||||
// Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
|
||||
const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
|
||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
|
||||
backoff.expires_after(delay);
|
||||
run([&](auto&& handler) { backoff.async_wait(handler); });
|
||||
retry_count++;
|
||||
}
|
||||
|
||||
if (retry_count >= max_retries) {
|
||||
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
|
||||
dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
|
||||
@@ -1733,8 +1869,7 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
const auto now_ms = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
const auto now_ms = steady_now_ms();
|
||||
const auto last_dispatch_ms = ws_last_dispatch_ms.load();
|
||||
|
||||
// Dispatch if: critical change OR throttle interval elapsed
|
||||
@@ -1755,6 +1890,24 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
|
||||
{
|
||||
const auto now_ms = steady_now_ms();
|
||||
const auto last_ms = ws_last_emit_ms.load();
|
||||
// Re-sends only a status the printer has reported at least once
|
||||
if (last_ms == 0 || now_ms - last_ms < static_cast<uint64_t>(interval.count())) {
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json message;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
|
||||
message = build_print_payload_locked();
|
||||
}
|
||||
dispatch_message(dev_id, message.dump());
|
||||
ws_last_emit_ms.store(now_ms);
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
|
||||
{
|
||||
if (!updates.is_object()) {
|
||||
@@ -2139,8 +2292,15 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
|
||||
}
|
||||
|
||||
// Start WebSocket status stream
|
||||
start_status_stream(dev_id, base_url, api_key);
|
||||
join_stopped_status_stream();
|
||||
// Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
|
||||
if (is_stale()) {
|
||||
return;
|
||||
}
|
||||
start_status_stream(dev_id, base_url, api_key);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Success!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "bambu_networking.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
@@ -85,6 +86,16 @@ protected:
|
||||
bool use_ssl = false;
|
||||
} device_info;
|
||||
|
||||
// Copied when the stream starts, so changes apply to the next start_status_stream()
|
||||
struct StatusStreamTimings
|
||||
{
|
||||
std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
|
||||
std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
|
||||
std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
|
||||
std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
|
||||
std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
|
||||
} ws_timings;
|
||||
|
||||
// Tray data for AMS payload building
|
||||
struct AmsTrayData {
|
||||
int slot_index = 0; // 0-based slot index
|
||||
@@ -102,6 +113,8 @@ protected:
|
||||
// Methods that derived classes may need to override or access
|
||||
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
|
||||
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
|
||||
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
||||
void stop_status_stream();
|
||||
|
||||
// State access for derived classes
|
||||
mutable std::recursive_mutex state_mutex;
|
||||
@@ -133,10 +146,11 @@ private:
|
||||
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
|
||||
void dispatch_printer_connected(const std::string& dev_id);
|
||||
void dispatch_message(const std::string& dev_id, const std::string& payload);
|
||||
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
||||
void stop_status_stream();
|
||||
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
|
||||
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
|
||||
void join_stopped_status_stream();
|
||||
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
|
||||
void handle_ws_message(const std::string& dev_id, const std::string& payload);
|
||||
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
|
||||
void update_status_cache(const nlohmann::json& updates);
|
||||
nlohmann::json build_print_payload_locked() const;
|
||||
|
||||
@@ -196,6 +210,8 @@ private:
|
||||
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
|
||||
std::atomic<uint64_t> ws_last_emit_ms{0};
|
||||
std::thread ws_thread;
|
||||
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
|
||||
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
|
||||
|
||||
// Throttling configuration for WebSocket updates
|
||||
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
|
||||
|
||||
@@ -102,12 +102,6 @@ void install_slicing_pipeline_hook()
|
||||
ctx.step = step;
|
||||
ctx.print = &print;
|
||||
ctx.object = object;
|
||||
// Slicing hooks are synchronous by design and never await: the context
|
||||
// borrows Print/PrintObject that are only valid during this call, and the
|
||||
// UI thread may itself be blocked waiting on this slicing worker. An async
|
||||
// override is rejected here rather than scheduled -- cap->execute() casts
|
||||
// its Python result to ExecutionResult and a coroutine object cannot be
|
||||
// cast, so it surfaces as the SlicingError below.
|
||||
r = cap->execute(ctx);
|
||||
} catch (const CanceledException&) {
|
||||
throw; // cancellation must reach process(), never become a slicing error
|
||||
|
||||
@@ -12,16 +12,11 @@
|
||||
|
||||
#include "PluginFsUtils.hpp"
|
||||
#include "PluginHooks.hpp"
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PyPluginTrampoline.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PythonPluginBridge.hpp"
|
||||
|
||||
#include "OrcaCloudServiceAgent.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
#include "pybind11/pytypes.h"
|
||||
#include "pybind11/gil.h"
|
||||
#include "pybind11/pybind11.h"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/NotificationManager.hpp"
|
||||
@@ -46,7 +41,6 @@
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/thread.h>
|
||||
#include <wx/utils.h>
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
#include "slic3r/GUI/Plater.hpp"
|
||||
|
||||
@@ -74,10 +68,8 @@ void Plugin::release_module()
|
||||
PluginManager& PluginManager::instance()
|
||||
{
|
||||
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
||||
// release module, sys.modules, and sys.path ownership safely. Touch the async Script runtime
|
||||
// next, so ~PluginManager can still drain it during its own shutdown().
|
||||
// release module, sys.modules, and sys.path ownership safely.
|
||||
PythonInterpreter::instance();
|
||||
ScriptAsyncRuntime::instance();
|
||||
static PluginManager inst;
|
||||
return inst;
|
||||
}
|
||||
@@ -109,9 +101,6 @@ bool PluginManager::initialize()
|
||||
m_initialized = true;
|
||||
}
|
||||
|
||||
// Re-arm the async Script runtime in case shutdown() ran earlier in this process.
|
||||
ScriptAsyncRuntime::instance().reset();
|
||||
|
||||
// Bring every capability's stored config into memory. Deliberately unconditional and
|
||||
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
||||
// plugin that comes back later finds its settings intact. A missing or malformed file just
|
||||
@@ -178,10 +167,6 @@ void PluginManager::shutdown()
|
||||
unload_all_plugins();
|
||||
PythonPluginBridge::instance().clear_pending_captures();
|
||||
|
||||
// Every async Script run has been drained by unload_all_plugins above; wait for every
|
||||
// invocation worker before the interpreter is allowed to finalize.
|
||||
ScriptAsyncRuntime::instance().shutdown();
|
||||
|
||||
// Every config write already goes to disk as it happens (store_capability_config), so this
|
||||
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
||||
// plugins above must never discard their stored config.
|
||||
@@ -769,10 +754,6 @@ void PluginManager::release_load_slot(const std::string& plugin_key)
|
||||
void PluginManager::cancel_and_wait_for_capabilities(
|
||||
const std::vector<std::shared_ptr<PluginCapabilityInterface>>& capabilities)
|
||||
{
|
||||
// Ask the plugin to cancel first, then schedule the async cancellation: on_cancelled() can
|
||||
// observe/record state while the run is still intact, and only afterwards is its asyncio task
|
||||
// cancelled. Draining of capability references still follows, so the module is not unloaded
|
||||
// until the run has settled.
|
||||
for (const auto& capability : capabilities) {
|
||||
if (!capability || !capability->is_enabled())
|
||||
continue;
|
||||
@@ -788,37 +769,15 @@ void PluginManager::cancel_and_wait_for_capabilities(
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& capability : capabilities) {
|
||||
if (capability)
|
||||
ScriptAsyncRuntime::instance().cancel_plugin(capability->audit_plugin_key());
|
||||
}
|
||||
|
||||
// Script rows are bounded only by their own worker: a cancelled or stalled invocation of another
|
||||
// plugin can no longer couple to this one (each invocation owns a thread). A Script worker is
|
||||
// drained without a deadline — forcing module unload while one is outstanding would leave Python
|
||||
// executing against a freed module, or let a blocking preset selection mutate presets after the
|
||||
// plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On the GUI
|
||||
// thread the event loop is pumped so a modal selection can settle instead of being starved by a
|
||||
// blocking wait.
|
||||
const auto grace_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
for (;;) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
||||
return !capability || capability->ref_count() == 0;
|
||||
});
|
||||
// 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)
|
||||
if (all_released)
|
||||
return;
|
||||
|
||||
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));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
|
||||
for (const auto& capability : capabilities) {
|
||||
@@ -2111,385 +2070,41 @@ bool PluginManager::delete_and_unsubscribe_cloud_plugin(const std::string& plugi
|
||||
return finalize_cloud_plugin_removal(descriptor, false, error);
|
||||
}
|
||||
|
||||
// ── Async Script runtime ────────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
// The asyncio loop of the Script invocation running on the calling thread. Owned +1 while set;
|
||||
// always cleared under the GIL before the worker thread exits.
|
||||
thread_local PyObject* tl_script_loop = nullptr;
|
||||
|
||||
} // namespace
|
||||
|
||||
struct ScriptAsyncRuntime::Impl
|
||||
{
|
||||
struct Run
|
||||
{
|
||||
std::string plugin_key;
|
||||
std::string capability_name;
|
||||
std::shared_ptr<PluginCapabilityInterface> capability;
|
||||
std::function<void(const ScriptCompletion&)> on_done;
|
||||
std::atomic<bool> cancel_requested{false};
|
||||
// True while an asyncio loop/task is live for this run. Read without the GIL by
|
||||
// cancel_plugin() to avoid taking the GIL for a purely synchronous invocation.
|
||||
std::atomic<bool> has_loop{false};
|
||||
|
||||
// Live asyncio loop/task for cancellation. Guarded by handle_mutex and only touched
|
||||
// with the GIL held.
|
||||
std::mutex handle_mutex;
|
||||
pybind11::object loop;
|
||||
pybind11::object task;
|
||||
};
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::vector<std::shared_ptr<Run>> active;
|
||||
bool stopping = false;
|
||||
|
||||
// Runs one invocation to completion on the calling (worker) thread.
|
||||
void run_invocation(const std::shared_ptr<Run>& run);
|
||||
};
|
||||
|
||||
void ScriptAsyncRuntime::Impl::run_invocation(const std::shared_ptr<Run>& run)
|
||||
{
|
||||
ScriptCompletion completion;
|
||||
|
||||
{
|
||||
PythonRuntimeLease lease = PythonInterpreter::instance().acquire_runtime_lease();
|
||||
if (!lease) {
|
||||
completion.error = _u8L("Python interpreter is shutting down");
|
||||
} else {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
|
||||
if (run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
// Cancelled before it ran: expected termination, never an error.
|
||||
completion.cancelled = true;
|
||||
} else {
|
||||
// Hold the capability reference, the audit scope and the GIL for the whole
|
||||
// invocation, so unload/shutdown cannot tear the module out from under it.
|
||||
PluginCapabilityInterface::RefCounter ref_counter(*run->capability);
|
||||
|
||||
std::optional<ScopedPluginAuditContext> audit_scope;
|
||||
if (!run->capability->audit_plugin_key().empty())
|
||||
audit_scope.emplace(run->capability->audit_plugin_key(), run->capability->name());
|
||||
|
||||
// Detection and execution both happen here, on the worker: a synchronous
|
||||
// `def execute` runs to completion off the UI thread, exactly like an `async def`.
|
||||
ScriptExecuteInvocation invocation = invoke_script_execute(run->capability);
|
||||
if (!invocation.error.empty()) {
|
||||
completion.error = invocation.error;
|
||||
} else if (!invocation.is_async) {
|
||||
completion.result = invocation.result;
|
||||
} else if (run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
completion.cancelled = true;
|
||||
} else {
|
||||
try {
|
||||
pybind11::module_ asyncio = pybind11::module_::import("asyncio");
|
||||
pybind11::object loop = asyncio.attr("new_event_loop")();
|
||||
asyncio.attr("set_event_loop")(loop);
|
||||
|
||||
tl_script_loop = loop.ptr();
|
||||
Py_INCREF(tl_script_loop);
|
||||
|
||||
pybind11::object task = loop.attr("create_task")(std::move(invocation.awaitable));
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
run->loop = loop;
|
||||
run->task = task;
|
||||
}
|
||||
run->has_loop.store(true, std::memory_order_release);
|
||||
// A cancellation that arrived before the loop came up must still land.
|
||||
if (run->cancel_requested.load(std::memory_order_acquire))
|
||||
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
|
||||
|
||||
try {
|
||||
pybind11::object returned = loop.attr("run_until_complete")(task);
|
||||
completion.result = returned.cast<ExecutionResult>();
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
// Only our own cancel_plugin() cancellation is expected termination; a
|
||||
// CancelledError the plugin raised on its own stays a real error.
|
||||
if (err.matches(asyncio.attr("CancelledError")) &&
|
||||
run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
completion.cancelled = true;
|
||||
} else {
|
||||
log_python_exception_keep(err);
|
||||
completion.error = err.what();
|
||||
}
|
||||
}
|
||||
|
||||
run->has_loop.store(false, std::memory_order_release);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
run->task = pybind11::object();
|
||||
run->loop = pybind11::object();
|
||||
}
|
||||
Py_CLEAR(tl_script_loop);
|
||||
loop.attr("close")();
|
||||
asyncio.attr("set_event_loop")(pybind11::none());
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
log_python_exception_keep(err);
|
||||
completion.error = err.what();
|
||||
} catch (const std::exception& ex) {
|
||||
completion.error = ex.what();
|
||||
}
|
||||
|
||||
// Covers an exception thrown after the loop came up but before the clear above.
|
||||
if (run->has_loop.load(std::memory_order_acquire)) {
|
||||
run->has_loop.store(false, std::memory_order_release);
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
run->task = pybind11::object();
|
||||
run->loop = pybind11::object();
|
||||
}
|
||||
|
||||
if (tl_script_loop != nullptr)
|
||||
Py_CLEAR(tl_script_loop);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the capability reference while the GIL is held: releasing a pybind holder from
|
||||
// a non-Python thread would DECREF the capability without the GIL. Runs on every
|
||||
// lease-valid path, including an invocation cancelled before it ran.
|
||||
run->capability.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination and is
|
||||
// authoritative: whatever the body did — finished, swallowed CancelledError, or failed — nothing
|
||||
// is surfaced as an error or as a normal success.
|
||||
if (run->cancel_requested.load(std::memory_order_acquire)) {
|
||||
completion.cancelled = true;
|
||||
completion.error.clear();
|
||||
}
|
||||
|
||||
auto on_done = std::move(run->on_done);
|
||||
auto deliver = [on_done, completion]() {
|
||||
if (on_done)
|
||||
on_done(completion);
|
||||
};
|
||||
|
||||
if (wxTheApp == nullptr || wxIsMainThread())
|
||||
deliver();
|
||||
else
|
||||
GUI::wxGetApp().CallAfter(deliver);
|
||||
|
||||
// Leave `active` last, so a drain cannot observe the run as finished before its completion was
|
||||
// queued/delivered and its capability reference released.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
active.erase(std::remove(active.begin(), active.end(), run), active.end());
|
||||
}
|
||||
cv.notify_all();
|
||||
}
|
||||
|
||||
ScriptAsyncRuntime& ScriptAsyncRuntime::instance()
|
||||
{
|
||||
static ScriptAsyncRuntime inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
ScriptAsyncRuntime::ScriptAsyncRuntime() : m_impl(std::make_unique<Impl>()) {}
|
||||
|
||||
ScriptAsyncRuntime::~ScriptAsyncRuntime() { shutdown(); }
|
||||
|
||||
bool ScriptAsyncRuntime::start(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
std::shared_ptr<PluginCapabilityInterface> capability,
|
||||
std::function<void(const ScriptCompletion&)> on_done)
|
||||
{
|
||||
if (!PythonInterpreter::instance().is_initialized())
|
||||
return false;
|
||||
|
||||
auto run = std::make_shared<Impl::Run>();
|
||||
run->plugin_key = plugin_key;
|
||||
run->capability_name = capability_name;
|
||||
run->capability = std::move(capability);
|
||||
run->on_done = std::move(on_done);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
if (m_impl->stopping)
|
||||
return false;
|
||||
m_impl->active.push_back(run);
|
||||
}
|
||||
|
||||
// One worker thread per invocation. A stalled invocation of one plugin can never delay another
|
||||
// plugin's unload or another invocation, and no invocation body ever runs on the caller (UI)
|
||||
// thread. The thread holds `run` (and so the capability) until it finishes; the runtime tracks
|
||||
// it in `active` and waits for it on shutdown.
|
||||
Impl* impl = m_impl.get();
|
||||
try {
|
||||
std::thread([impl, run] { impl->run_invocation(run); }).detach();
|
||||
} catch (...) {
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->active.erase(std::remove(m_impl->active.begin(), m_impl->active.end(), run), m_impl->active.end());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScriptAsyncRuntime::cancel_plugin(const std::string& plugin_key)
|
||||
{
|
||||
std::vector<std::shared_ptr<Impl::Run>> runs;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
for (const auto& run : m_impl->active)
|
||||
if (run->plugin_key == plugin_key)
|
||||
runs.push_back(run);
|
||||
}
|
||||
|
||||
for (const auto& run : runs)
|
||||
run->cancel_requested.store(true, std::memory_order_release);
|
||||
|
||||
for (const auto& run : runs) {
|
||||
// A synchronous invocation has no loop to cancel; skip it so the caller never blocks on
|
||||
// the GIL while that invocation is (uninterruptibly) running.
|
||||
if (!run->has_loop.load(std::memory_order_acquire))
|
||||
continue;
|
||||
|
||||
PythonGILState gil;
|
||||
if (!gil)
|
||||
continue;
|
||||
|
||||
pybind11::object loop;
|
||||
pybind11::object task;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
||||
loop = run->loop;
|
||||
task = run->task;
|
||||
}
|
||||
if (!loop || !task)
|
||||
continue;
|
||||
|
||||
try {
|
||||
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
|
||||
} catch (const std::exception& ex) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Script async cancel failed for plugin '" << plugin_key << "': " << ex.what();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ScriptAsyncRuntime::has_active_run(const PluginCapabilityInterface* capability) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
for (const auto& run : m_impl->active)
|
||||
if (run->capability.get() == capability)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void ScriptAsyncRuntime::shutdown()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->stopping = true;
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->cv.wait(lock, [this] { return m_impl->active.empty(); });
|
||||
}
|
||||
|
||||
void ScriptAsyncRuntime::reset()
|
||||
{
|
||||
shutdown();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->stopping = false;
|
||||
}
|
||||
|
||||
pybind11::object ScriptAsyncRuntime::current_loop()
|
||||
{
|
||||
if (tl_script_loop == nullptr)
|
||||
return pybind11::object();
|
||||
return pybind11::reinterpret_borrow<pybind11::object>(pybind11::handle(tl_script_loop));
|
||||
}
|
||||
|
||||
ExecutionResult PluginManager::run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error)
|
||||
{
|
||||
ExecutionResult result;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool done = false;
|
||||
|
||||
ScriptLaunchResult launch = launch_script_capability(
|
||||
plugin_key, capability_name, [&](const ScriptCompletion& completion) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
result = completion.result;
|
||||
// A cancel_plugin() cancellation is expected termination, not an error to report.
|
||||
error = completion.cancelled ? std::string() : completion.error;
|
||||
done = true;
|
||||
cv.notify_all();
|
||||
});
|
||||
|
||||
if (launch.launched_async) {
|
||||
// Async run: the completion is delivered on the GUI thread. When this call is itself on
|
||||
// the GUI thread it must pump the event loop, or the completion can never be delivered.
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
if (wxTheApp != nullptr && wxIsMainThread()) {
|
||||
while (!done) {
|
||||
cv.wait_for(lock, std::chrono::milliseconds(10));
|
||||
lock.unlock();
|
||||
wxYield();
|
||||
lock.lock();
|
||||
}
|
||||
} else {
|
||||
cv.wait(lock, [&] { return done; });
|
||||
}
|
||||
} else {
|
||||
result = launch.outcome.result;
|
||||
error = launch.outcome.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
PluginManager::ScriptLaunchResult PluginManager::launch_script_capability(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
ScriptCompletionFn on_done)
|
||||
{
|
||||
ScriptLaunchResult launch;
|
||||
|
||||
// Mirror the historical synchronous behavior for a dangling reference: nothing to run and
|
||||
// no error (the caller's guarded UI normally prevents this).
|
||||
if (plugin_key.empty() || capability_name.empty())
|
||||
return launch;
|
||||
|
||||
if (m_shutting_down.load(std::memory_order_acquire)) {
|
||||
launch.outcome.error = _u8L("Plugin system is shutting down.");
|
||||
return launch;
|
||||
if (plugin_key.empty() || capability_name.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
|
||||
if (!cap)
|
||||
return launch;
|
||||
return {};
|
||||
|
||||
if (!std::dynamic_pointer_cast<ScriptPluginCapability>(cap))
|
||||
return launch;
|
||||
auto cap_interface = std::dynamic_pointer_cast<ScriptPluginCapability>(cap);
|
||||
if (!cap_interface)
|
||||
return {};
|
||||
|
||||
// Every Script body runs off the caller's thread now: the invocation — including detection and
|
||||
// a synchronous `def execute` — is handed to the runtime, which runs it on its own worker and
|
||||
// reports the outcome through the completion callback. Nothing executes before returning here,
|
||||
// so the UI thread is never blocked by a Script invocation.
|
||||
const bool started = ScriptAsyncRuntime::instance().start(
|
||||
plugin_key, capability_name, cap,
|
||||
[this, plugin_key, on_done](const ScriptCompletion& completion) {
|
||||
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination:
|
||||
// record no plugin error and surface nothing. Genuine user/plugin errors still record.
|
||||
if (!completion.cancelled && !completion.error.empty())
|
||||
set_plugin_error(plugin_key, completion.error);
|
||||
if (on_done)
|
||||
on_done(completion);
|
||||
});
|
||||
|
||||
if (!started) {
|
||||
launch.outcome.error = _u8L("Plugin system is shutting down.");
|
||||
set_plugin_error(plugin_key, launch.outcome.error);
|
||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution failed. plugin_key=" << plugin_key
|
||||
<< " error=" << launch.outcome.error;
|
||||
return launch;
|
||||
ExecutionResult result;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
if (!gil) {
|
||||
error = "Python interpreter is shutting down";
|
||||
return {};
|
||||
}
|
||||
result = cap_interface->execute();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
|
||||
} catch (...) {
|
||||
error = _u8L("Unknown error");
|
||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
|
||||
}
|
||||
|
||||
launch.launched_async = true;
|
||||
return launch;
|
||||
if (!error.empty()) {
|
||||
set_plugin_error(plugin_key, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include "pybind11/pytypes.h"
|
||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <string>
|
||||
@@ -99,19 +98,6 @@ struct Plugin
|
||||
void release_module();
|
||||
};
|
||||
|
||||
// Outcome of one Script invocation, delivered to a script completion callback.
|
||||
//
|
||||
// `cancelled` marks a run terminated by PluginManager's own cancellation (plugin unload / shutdown):
|
||||
// expected termination, not a failure. Callers must not surface it as an error nor as a normal
|
||||
// success. `error` carries only genuine user/plugin exceptions; `result` is meaningful when neither
|
||||
// `cancelled` nor a non-empty `error` is set.
|
||||
struct ScriptCompletion
|
||||
{
|
||||
ExecutionResult result;
|
||||
std::string error;
|
||||
bool cancelled = false;
|
||||
};
|
||||
|
||||
class PluginManager
|
||||
{
|
||||
public:
|
||||
@@ -228,25 +214,6 @@ public:
|
||||
|
||||
ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error);
|
||||
|
||||
// Outcome of launching a Script capability.
|
||||
struct ScriptLaunchResult
|
||||
{
|
||||
bool launched_async = false; // true => a run started off-thread; wait for on_done
|
||||
ScriptCompletion outcome; // meaningful when !launched_async (start failure only)
|
||||
};
|
||||
// Invoked on the GUI thread when a Script invocation finishes (or inline, on the calling thread,
|
||||
// when the run could not be started at all).
|
||||
using ScriptCompletionFn = std::function<void(const ScriptCompletion&)>;
|
||||
|
||||
// Starts a Script capability off the caller's thread. Both a synchronous `def execute` and an
|
||||
// `async def execute` run on a ScriptAsyncRuntime worker, so this never blocks the UI thread and
|
||||
// no invocation runs before returning. `launched_async` is true exactly when a run started; the
|
||||
// caller then waits for on_done instead of a returned result. When it is false, `outcome.error`
|
||||
// explains why (the caller's guarded UI normally prevents this).
|
||||
ScriptLaunchResult launch_script_capability(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
ScriptCompletionFn on_done = {});
|
||||
|
||||
void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx);
|
||||
|
||||
private:
|
||||
@@ -342,74 +309,6 @@ private:
|
||||
std::atomic<bool> m_shutting_down{false};
|
||||
};
|
||||
|
||||
// ── Async Script execution ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A Script capability whose Python `execute` is an `async def` (or otherwise returns a
|
||||
// coroutine) must not run on the UI thread, and cannot use the synchronous trampoline path,
|
||||
// which casts the return value to ExecutionResult and would reject a coroutine.
|
||||
//
|
||||
// ScriptAsyncRuntime drives such executions off the calling (UI) thread. Each invocation gets its
|
||||
// own worker thread and, for an `async def`, its own asyncio event loop. Every invocation body —
|
||||
// detection, a synchronous `def execute`, or an `async def` coroutine — runs on that worker, so no
|
||||
// Script invocation ever blocks the UI thread and no invocation is queued behind another plugin's.
|
||||
// For the whole invocation the worker holds:
|
||||
// * a shared_ptr to the capability, so the capability and its module stay alive across awaits;
|
||||
// * a PythonRuntimeLease plus the GIL, so the interpreter cannot finalize underneath it;
|
||||
// * the capability's audit scope (ScopedPluginAuditContext),
|
||||
// which keeps plugin unload and interpreter shutdown from tearing the module out from under a
|
||||
// suspended run. Unload cancels the run and drains it (it never force-unloads after a timeout).
|
||||
//
|
||||
// Interface for host awaitables (see plugin/host/): a host awaitable is constructed inside the
|
||||
// Script coroutine, i.e. on the worker thread with that invocation's loop running, so it can
|
||||
// read that loop with asyncio.get_running_loop() or ScriptAsyncRuntime::current_loop(). To
|
||||
// resume from another thread (typically the UI thread) it must resolve its future with
|
||||
// loop.call_soon_threadsafe(...) and must never touch plugin Python objects, or the loop,
|
||||
// directly from that other thread. The loop only runs while the coroutine is suspended inside
|
||||
// run_until_complete; once Script execute returns the loop is closed and the module may unload.
|
||||
class ScriptAsyncRuntime
|
||||
{
|
||||
public:
|
||||
static ScriptAsyncRuntime& instance();
|
||||
|
||||
~ScriptAsyncRuntime();
|
||||
ScriptAsyncRuntime(const ScriptAsyncRuntime&) = delete;
|
||||
ScriptAsyncRuntime& operator=(const ScriptAsyncRuntime&) = delete;
|
||||
|
||||
// Starts one Script invocation on its own worker thread. The invocation body (Python `execute`,
|
||||
// whether `def` or `async def`) and its detection both run on that worker, never on the calling
|
||||
// thread. `on_done` is invoked on the GUI thread (or inline when there is no wx app) when the
|
||||
// invocation finishes; a `cancelled` completion is expected termination from cancel_plugin().
|
||||
// Returns false when the runtime is shutting down.
|
||||
bool start(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
std::shared_ptr<PluginCapabilityInterface> capability,
|
||||
std::function<void(const ScriptCompletion&)> on_done);
|
||||
|
||||
// Requests cancellation of every active run owned by plugin_key: marks the run and cancels
|
||||
// its asyncio task via loop.call_soon_threadsafe (a synchronous invocation has no loop).
|
||||
void cancel_plugin(const std::string& plugin_key);
|
||||
|
||||
// True while a run for `capability` is active.
|
||||
bool has_active_run(const PluginCapabilityInterface* capability) const;
|
||||
|
||||
// Stops accepting runs and waits for every in-flight invocation worker. Nothing is torn down
|
||||
// by force.
|
||||
void shutdown();
|
||||
|
||||
// Re-arms the runtime after a shutdown() so a later initialize() in the same process can
|
||||
// start runs again.
|
||||
void reset();
|
||||
|
||||
// The asyncio loop of the Script invocation running on the calling thread, or an empty
|
||||
// object when the calling thread is not running one. Must be called with the GIL held.
|
||||
static pybind11::object current_loop();
|
||||
|
||||
private:
|
||||
ScriptAsyncRuntime();
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> m_impl;
|
||||
};
|
||||
|
||||
// Resolve each configured capability reference to a loaded capability of type T and run `execute`.
|
||||
template<typename T>
|
||||
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
#define slic3r_PyPluginTrampoline_hpp_
|
||||
|
||||
#include "libslic3r/LifecycleEvents.hpp"
|
||||
#include <exception>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
@@ -16,11 +14,9 @@
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PluginFsUtils.hpp"
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "pybind11/cast.h"
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <utility>
|
||||
|
||||
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
|
||||
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
|
||||
@@ -65,78 +61,6 @@
|
||||
} while (0)
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// True when `object` is a coroutine object (the value produced by calling an `async def`).
|
||||
// Match inspect.iscoroutine() without importing Python modules inside the plugin audit scope.
|
||||
// Must be called with the GIL held.
|
||||
inline bool py_is_coroutine(const pybind11::handle& object)
|
||||
{
|
||||
return object && PyCoro_CheckExact(object.ptr());
|
||||
}
|
||||
|
||||
// Result of invoking a Script capability's Python `execute` exactly once.
|
||||
//
|
||||
// A synchronous override (`def execute`) runs to completion here and yields `result`.
|
||||
// An asynchronous override (`async def execute`, or any override that returns a coroutine)
|
||||
// yields the coroutine object in `awaitable` WITHOUT running it: the cast to ExecutionResult
|
||||
// that the trampoline would otherwise perform is impossible for a coroutine, so the caller
|
||||
// chooses how to drive it. Whoever drives it must keep the capability referenced and re-open
|
||||
// this call's GIL + runtime lease + audit scope for the coroutine's whole lifetime (see
|
||||
// ScriptAsyncRuntime in PluginManager.hpp).
|
||||
struct ScriptExecuteInvocation
|
||||
{
|
||||
bool is_async = false;
|
||||
pybind11::object awaitable; // valid iff is_async; a coroutine object created under the GIL
|
||||
ExecutionResult result; // valid iff !is_async
|
||||
std::string error; // non-empty when the call or coroutine creation raised
|
||||
};
|
||||
|
||||
// Invokes ScriptPluginCapability::execute() under the GIL, replicating the audited trampoline
|
||||
// call (capability ref counter, audit scope, traceback-preserving rethrow) but returning a
|
||||
// coroutine object instead of forcing a cast when the plugin's `execute` is `async def`.
|
||||
//
|
||||
// `capability` must be the concrete, Python-bound capability instance (obtainable from
|
||||
// get_plugin_capability()); it is resolved back to its Python object through pybind's instance
|
||||
// map. Never throws: a Python exception is logged (full traceback) and reported in `error`.
|
||||
inline ScriptExecuteInvocation invoke_script_execute(const std::shared_ptr<PluginCapabilityInterface>& capability)
|
||||
{
|
||||
ScriptExecuteInvocation invocation;
|
||||
if (!capability)
|
||||
return invocation;
|
||||
|
||||
try {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
PluginCapabilityInterface::RefCounter ref_counter(*capability);
|
||||
|
||||
std::optional<ScopedPluginAuditContext> audit_scope;
|
||||
if (const std::string& audit_key = capability->audit_plugin_key(); !audit_key.empty())
|
||||
audit_scope.emplace(audit_key, capability->name());
|
||||
|
||||
pybind11::object py_capability = pybind11::cast(capability);
|
||||
pybind11::object returned = py_capability.attr("execute")();
|
||||
|
||||
if (py_is_coroutine(returned)) {
|
||||
invocation.is_async = true;
|
||||
invocation.awaitable = std::move(returned);
|
||||
return invocation;
|
||||
}
|
||||
|
||||
invocation.result = returned.cast<ExecutionResult>();
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
const bool audit_denial = PluginAuditManager::instance().audit_denial_pending();
|
||||
if (audit_denial)
|
||||
PluginAuditManager::instance().clear_current_plugin();
|
||||
invocation.error = err.what();
|
||||
log_python_exception_keep(err);
|
||||
if (audit_denial)
|
||||
PluginAuditManager::instance().clear_audit_denial();
|
||||
} catch (const std::exception& ex) {
|
||||
invocation.error = ex.what();
|
||||
}
|
||||
|
||||
return invocation;
|
||||
}
|
||||
|
||||
template<class Base> class PyPluginCommonTrampoline : public Base
|
||||
{
|
||||
public:
|
||||
|
||||
@@ -584,6 +584,19 @@ bool PythonInterpreter::initialize()
|
||||
}
|
||||
BOOST_LOG_TRIVIAL(debug) << "Program name set successfully";
|
||||
|
||||
// Keep bytecode out of the Python home. The shipped .pyc files are stale against their
|
||||
// sources, so a writable install would rewrite them inside the app (breaking a signed
|
||||
// macOS bundle's seal) and a read-only one would recompile them on every launch.
|
||||
const std::string pycache_prefix = data_dir() + "/python/pycache";
|
||||
#ifdef _WIN32
|
||||
status = PyConfig_SetString(&config, &config.pycache_prefix, boost::nowide::widen(pycache_prefix).c_str());
|
||||
#else
|
||||
status = PyConfig_SetBytesString(&config, &config.pycache_prefix, pycache_prefix.c_str());
|
||||
#endif
|
||||
if (PyStatus_Exception(status))
|
||||
BOOST_LOG_TRIVIAL(warning) << "Failed to set the Python bytecode cache to " << pycache_prefix << ": "
|
||||
<< (status.err_msg ? status.err_msg : "unknown error");
|
||||
|
||||
// Use pybind11's scoped_interpreter which properly initializes
|
||||
// pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig
|
||||
// does not set up thread state tracking that pybind11 requires.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "PluginHostUi.hpp"
|
||||
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/cast.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
@@ -12,21 +11,18 @@
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <slic3r/GUI/GUI.hpp>
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/GUI/MainFrame.hpp>
|
||||
#include <slic3r/GUI/Plater.hpp>
|
||||
#include <slic3r/GUI/PresetComboBoxes.hpp> // TabPresetComboBox::set_filament_idx
|
||||
#include <slic3r/GUI/Tab.hpp>
|
||||
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <wx/string.h>
|
||||
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace py = pybind11;
|
||||
@@ -52,543 +48,6 @@ PresetCollection& printer_presets(PresetBundle& bundle)
|
||||
return static_cast<PresetCollection&>(bundle.printers);
|
||||
}
|
||||
|
||||
// Handle to one category of currently edited preset. It is a category, not a snapshot: it always
|
||||
// resolves the preset the matching tab edits now, so it stays valid across preset switches.
|
||||
struct PresetEditor
|
||||
{
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
};
|
||||
|
||||
const char* preset_tab_name(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return "process";
|
||||
case Preset::TYPE_FILAMENT: return "filament";
|
||||
case Preset::TYPE_PRINTER: return "printer";
|
||||
default: return "preset";
|
||||
}
|
||||
}
|
||||
|
||||
PresetCollection& collection_for(PresetBundle& bundle, Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return bundle.prints;
|
||||
case Preset::TYPE_FILAMENT: return bundle.filaments;
|
||||
case Preset::TYPE_PRINTER: return printer_presets(bundle);
|
||||
default: throw std::invalid_argument("Unsupported preset category");
|
||||
}
|
||||
}
|
||||
|
||||
// Marker for a user-declined selection: Tab::select_preset() returned false. It derives from
|
||||
// std::runtime_error (pybind11 maps the base to RuntimeError), so callers see it as an ordinary
|
||||
// failed transaction and no staged setting or slot assignment is created.
|
||||
struct SelectionCancelled : std::runtime_error
|
||||
{
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// The staged preset update lifecycle described in docs/HLSD/preset-plugin-api.md.
|
||||
//
|
||||
// begin_update() resolves and binds one target (selecting it through the normal UI flow when
|
||||
// the caller asked for a preset that is not already active), returns a PresetTransaction that
|
||||
// stages changes with set(), and applies them only on commit(). The transaction holds names,
|
||||
// never preset pointers, so it stays valid across reloads; a target or filament slot that changed
|
||||
// between begin_update() and commit() is rejected instead of silently editing the new mapping.
|
||||
//
|
||||
// begin_update() is synchronous: it runs on the background Script worker, marshals the target
|
||||
// selection to the UI thread with PluginHostUi::run_on_ui_thread(), and blocks that worker until
|
||||
// the selection and any unsaved-edit dialog settle. The UI thread stays responsive because the
|
||||
// Script capability owns its worker thread. No GUI or preset pointer outlives the call; the
|
||||
// returned transaction holds copied category/name/slot values. The update() shorthand shares the
|
||||
// same target resolution and is safe only from that worker, never from the UI thread or a slicing
|
||||
// pipeline hook.
|
||||
//
|
||||
// Known limits of this implementation, kept explicit rather than hidden:
|
||||
// * A requested preset_name is resolved by the exact name its dropdown entry shows: the item's
|
||||
// display label or its stored item alias (the internal preset name). A name two entries carry
|
||||
// is rejected as ambiguous instead of being resolved by position. Process and printer resolve
|
||||
// against the tab dropdown; a filament preset_name resolves against the requested slot's own
|
||||
// sidebar selector, not the tab's, so its compatibility filter is the one that applies. A
|
||||
// target the selector does not offer fails before the flow's unsaved-edit prompt. The flow
|
||||
// itself is unchanged and the target is still confirmed as the edited preset afterwards.
|
||||
// * A filament preset_name that names a different preset for a slot selects it through
|
||||
// Tab::select_preset() (the normal flow, honouring unsaved-edit prompts) and then updates the
|
||||
// slot mapping with PresetBundle::set_filament_preset() plus every dependent side effect the
|
||||
// sidebar selection path performs: the project-dirty/selection refresh, the dynamic filament
|
||||
// list, the slot's own combo (re-run so it lists and selects the assigned target), the
|
||||
// conditional support-filament flushing recalc and the slot's AMS sync badge (read from the
|
||||
// slot combo's own flag). The private Plater::priv::on_select_preset() path cannot be reused
|
||||
// from here, so this mirrors its filament branch rather than calling it.
|
||||
// * A user-declined selection does not undo decisions the user already accepted in an earlier
|
||||
// dialog. It aborts before any slot assignment or transaction is created, so no staged
|
||||
// setting is applied.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Outcome of one committed transaction. Identifies the target, whether selecting it switched
|
||||
// anything, and for filament (whose preset may be shared) which slots share the edited preset.
|
||||
struct PresetUpdateResult
|
||||
{
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
std::string target_name;
|
||||
bool selection_changed = false;
|
||||
int slot_number = -1; // 1-based for filament, -1 otherwise
|
||||
bool slot_assignment_changed = false;
|
||||
std::vector<int> shared_slots;
|
||||
std::vector<std::string> keys;
|
||||
bool dirty = false;
|
||||
};
|
||||
|
||||
struct PresetTransaction
|
||||
{
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
std::string target_name;
|
||||
bool filament = false;
|
||||
int slot_number = 0; // 1-based, filament only
|
||||
std::string slot_assignment_snapshot; // slot's assigned preset as of begin_update()
|
||||
bool selection_changed = false;
|
||||
bool slot_assignment_changed = false;
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> staged;
|
||||
std::set<std::string> staged_keys;
|
||||
bool committed = false;
|
||||
|
||||
void set(const std::string& key, const std::string& value);
|
||||
PresetUpdateResult commit();
|
||||
};
|
||||
|
||||
GUI::GUI_App& require_gui_app()
|
||||
{
|
||||
GUI::GUI_App& app = GUI::wxGetApp();
|
||||
if (app.is_closing())
|
||||
throw std::runtime_error("OrcaSlicer is closing");
|
||||
if (app.preset_bundle == nullptr)
|
||||
throw std::runtime_error("Preset bundle is not available");
|
||||
if (app.plater() == nullptr)
|
||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
||||
return app;
|
||||
}
|
||||
|
||||
GUI::Tab* require_tab(GUI::GUI_App& app, Preset::Type type)
|
||||
{
|
||||
GUI::Tab* tab = app.get_tab(type);
|
||||
if (tab == nullptr)
|
||||
throw std::runtime_error(std::string("The ") + preset_tab_name(type) + " preset tab is not available");
|
||||
// The tab for this preset type exists, but the selected printer's technology may not use it
|
||||
// (the print tab no-ops under SLA); refuse instead of writing a value the UI cannot apply.
|
||||
if (!tab->supports_printer_technology(app.preset_bundle->printers.get_selected_preset().printer_technology()))
|
||||
throw std::runtime_error(std::string("The ") + preset_tab_name(type)
|
||||
+ " preset is not available for the selected printer technology");
|
||||
return tab;
|
||||
}
|
||||
|
||||
// True for a combo row that is a group header, a marker or a disabled row: it carries a marker in
|
||||
// its client data and is not a selectable preset.
|
||||
bool combo_item_is_marker(GUI::PresetComboBox& combo, int item)
|
||||
{
|
||||
const auto marker = reinterpret_cast<std::size_t>(combo.GetClientData(item));
|
||||
return marker >= GUI::PresetComboBox::LABEL_ITEM_DISABLED && marker < GUI::PresetComboBox::LABEL_ITEM_MAX;
|
||||
}
|
||||
|
||||
// How a caller-supplied name resolved against a combo's actual items.
|
||||
struct ComboPresetMatch
|
||||
{
|
||||
int item = -1; // first matching item, or -1
|
||||
bool ambiguous = false; // two distinct items carry this name
|
||||
std::string internal_name; // internal preset name of the match (empty when none)
|
||||
};
|
||||
|
||||
// Resolve `requested` by the name each combo item shows: its display label, or its stored item
|
||||
// alias (the internal preset name, or the printer model for a system printer preset). Names are
|
||||
// compared with the modified-dirty suffix removed, i.e. exactly what the dropdown shows. Reading
|
||||
// the combo's own items means this respects the combo's real selectable model (visibility, its
|
||||
// show-incompatible state and, for a slot combo, that slot's compatibility). A name that two
|
||||
// distinct items carry is reported ambiguous instead of being resolved by position.
|
||||
ComboPresetMatch resolve_combo_preset(GUI::PresetComboBox& combo, const std::string& requested)
|
||||
{
|
||||
ComboPresetMatch match;
|
||||
const std::string want = Preset::remove_suffix_modified(requested);
|
||||
const int item_count = combo.GetCount();
|
||||
for (int item = 0; item < item_count; ++item) {
|
||||
if (combo_item_is_marker(combo, item))
|
||||
continue;
|
||||
const wxString alias = combo.GetItemAlias(item);
|
||||
const std::string alias_name = alias.empty() ? std::string()
|
||||
: Preset::remove_suffix_modified(alias.ToUTF8().data());
|
||||
const std::string label_name = Preset::remove_suffix_modified(combo.GetString(item).ToUTF8().data());
|
||||
if ((alias_name.empty() || alias_name != want) && label_name != want)
|
||||
continue;
|
||||
if (match.item >= 0 && match.item != item) {
|
||||
match.ambiguous = true;
|
||||
return match;
|
||||
}
|
||||
match.item = item;
|
||||
match.internal_name = alias_name.empty() ? label_name : alias_name;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
// Index of the item whose internal name is `preset_name`, or -1. Used for per-item flags (the AMS
|
||||
// sync badge); the internal name is unique, so no ambiguity handling is needed here.
|
||||
int combo_item_for_preset(GUI::PresetComboBox& combo, const std::string& preset_name)
|
||||
{
|
||||
const std::string want = Preset::remove_suffix_modified(preset_name);
|
||||
const int item_count = combo.GetCount();
|
||||
for (int item = 0; item < item_count; ++item) {
|
||||
if (combo_item_is_marker(combo, item))
|
||||
continue;
|
||||
const wxString alias = combo.GetItemAlias(item);
|
||||
const std::string alias_name = alias.empty() ? std::string()
|
||||
: Preset::remove_suffix_modified(alias.ToUTF8().data());
|
||||
if (!alias_name.empty() && alias_name == want)
|
||||
return item;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Resolve `requested` to the internal preset name its dropdown entry names, or throw. `category`
|
||||
// and `selector` are used only for the error text. A name the selector does not offer, or that two
|
||||
// entries share, raises std::invalid_argument.
|
||||
std::string resolve_preset_in_combo(GUI::PresetComboBox& combo, const std::string& requested,
|
||||
const std::string& category, const std::string& selector)
|
||||
{
|
||||
const ComboPresetMatch match = resolve_combo_preset(combo, requested);
|
||||
if (match.ambiguous)
|
||||
throw std::invalid_argument("The " + category + " name \"" + requested + "\" is ambiguous in the " + selector
|
||||
+ "; more than one entry shows it, so name the preset by its own entry");
|
||||
if (match.item < 0)
|
||||
throw std::invalid_argument("The " + category + " preset \"" + requested + "\" is not offered by the " + selector);
|
||||
return match.internal_name;
|
||||
}
|
||||
|
||||
// The preset a filament slot currently resolves to, by its internal name. A slot whose stored
|
||||
// name is empty or does not resolve yields an error identifying the slot.
|
||||
std::string resolved_slot_preset(PresetBundle& bundle, int slot_number)
|
||||
{
|
||||
if (slot_number < 1 || static_cast<size_t>(slot_number) > bundle.filament_presets.size())
|
||||
throw std::out_of_range("Filament slot " + std::to_string(slot_number)
|
||||
+ " is out of range; slot numbers are 1-based and this printer has "
|
||||
+ std::to_string(bundle.filament_presets.size()) + " filament slot(s)");
|
||||
const std::string& assigned_name = bundle.filament_presets[static_cast<size_t>(slot_number) - 1];
|
||||
if (assigned_name.empty())
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset");
|
||||
Preset* preset = bundle.filaments.find_preset(assigned_name);
|
||||
if (preset == nullptr)
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " is assigned \"" + assigned_name
|
||||
+ "\", which does not resolve to a filament preset");
|
||||
return preset->name;
|
||||
}
|
||||
|
||||
void PresetTransaction::set(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (committed)
|
||||
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
|
||||
if (key.empty())
|
||||
throw std::invalid_argument("A setting key is required");
|
||||
// Conflicting duplicate writes to one setting are rejected rather than resolved by order.
|
||||
if (!staged_keys.insert(key).second)
|
||||
throw std::invalid_argument("Setting \"" + key + "\" is already staged in this transaction; "
|
||||
"conflicting duplicate writes to one setting are rejected");
|
||||
staged.emplace_back(key, value);
|
||||
}
|
||||
|
||||
PresetUpdateResult PresetTransaction::commit()
|
||||
{
|
||||
if (committed)
|
||||
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
|
||||
if (type == Preset::TYPE_INVALID)
|
||||
throw std::runtime_error("This preset transaction has no target");
|
||||
|
||||
PresetUpdateResult result;
|
||||
PluginHostUi::run_on_ui_thread([&] {
|
||||
GUI::GUI_App& app = require_gui_app();
|
||||
GUI::Tab* tab = require_tab(app, type);
|
||||
PresetBundle& bundle = *app.preset_bundle;
|
||||
PresetCollection& collection = collection_for(bundle, type);
|
||||
|
||||
// The target must still be the edited preset: the user may have switched away while the
|
||||
// transaction was staged, and editing a different preset would break "explicit target".
|
||||
const std::string edited = collection.get_edited_preset().name;
|
||||
if (edited != target_name)
|
||||
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \"" + edited
|
||||
+ "\", not this transaction's target \"" + target_name
|
||||
+ "\"; nothing was applied");
|
||||
|
||||
if (filament) {
|
||||
// Reject a stale slot mapping instead of editing whatever the slot now points at.
|
||||
const std::string now_assigned = resolved_slot_preset(bundle, slot_number);
|
||||
if (now_assigned != slot_assignment_snapshot)
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " now holds \"" + now_assigned
|
||||
+ "\", not \"" + slot_assignment_snapshot
|
||||
+ "\" as when this transaction was created; the slot mapping changed, so nothing was applied");
|
||||
}
|
||||
|
||||
// Apply the whole batch through the tab's batch path: it validates every key and value before
|
||||
// touching the preset, rejects duplicate keys, runs the same per-setting commit path a hand
|
||||
// edit uses (validation, on_change, dirty state) and refreshes once for the batch. A failure
|
||||
// restores the edited preset, so no staged setting survives a failed commit.
|
||||
tab->apply_preset_config_values(staged);
|
||||
|
||||
result.type = type;
|
||||
result.target_name = target_name;
|
||||
result.selection_changed = selection_changed;
|
||||
result.slot_number = filament ? slot_number : -1;
|
||||
result.slot_assignment_changed = slot_assignment_changed;
|
||||
result.keys.reserve(staged.size());
|
||||
for (const auto& entry : staged)
|
||||
result.keys.push_back(entry.first);
|
||||
result.dirty = collection.get_edited_preset().is_dirty;
|
||||
|
||||
if (filament) {
|
||||
// Every slot whose assigned preset resolves to the edited one is affected: the preset
|
||||
// is shared, not a per-slot override.
|
||||
for (std::size_t index = 0; index < bundle.filament_presets.size(); ++index) {
|
||||
Preset* preset = bundle.filaments.find_preset(bundle.filament_presets[index]);
|
||||
if (preset != nullptr && preset->name == target_name)
|
||||
result.shared_slots.push_back(static_cast<int>(index) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
committed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Process/printer target resolution: the active preset when no name is given, otherwise the named
|
||||
// preset selected through the normal tab-selection flow (which enforces the dropdown/compatibility
|
||||
// rules and the unsaved-edit prompt). Binds only after confirming the requested preset is edited.
|
||||
// Runs on the UI thread with the GIL released, called from begin_preset_update().
|
||||
void bind_preset_target(Preset::Type type, const std::string& preset_name, PresetTransaction& transaction)
|
||||
{
|
||||
GUI::GUI_App& app = require_gui_app();
|
||||
GUI::Tab* tab = require_tab(app, type);
|
||||
PresetCollection& collection = collection_for(*app.preset_bundle, type);
|
||||
|
||||
const std::string current = collection.get_edited_preset().name;
|
||||
std::string target = current;
|
||||
bool selection_changed = false;
|
||||
|
||||
if (!preset_name.empty()) {
|
||||
// Resolve by the name the tab's dropdown actually shows (display label or item alias), so a
|
||||
// plugin can pass exactly what the user sees. With no populated dropdown there is nothing
|
||||
// to resolve against, so fall back to collection internal names.
|
||||
GUI::TabPresetComboBox* combo = tab->get_combo_box();
|
||||
std::string resolved;
|
||||
if (combo != nullptr && combo->GetCount() > 0)
|
||||
resolved = resolve_preset_in_combo(*combo, preset_name, preset_tab_name(type),
|
||||
"preset selector for the selected printer");
|
||||
else
|
||||
resolved = preset_name;
|
||||
|
||||
Preset* requested = collection.find_preset(resolved);
|
||||
if (requested == nullptr)
|
||||
throw std::invalid_argument("No " + std::string(preset_tab_name(type)) + " preset named \"" + resolved
|
||||
+ "\" is available to select");
|
||||
target = requested->name;
|
||||
if (target != current) {
|
||||
// Normal UI selection: honours the unsaved-edit prompt; a declined prompt returns false
|
||||
// and is a user cancellation, distinct from any other resolution failure.
|
||||
if (!tab->select_preset(target))
|
||||
throw SelectionCancelled("Switching the edited " + std::string(preset_tab_name(type)) + " preset to \""
|
||||
+ target + "\" was canceled; nothing was staged");
|
||||
selection_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// select_preset() can still leave a different preset edited (e.g. the target is not an
|
||||
// allowed dropdown choice), so confirm before binding.
|
||||
if (collection.get_edited_preset().name != target)
|
||||
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \""
|
||||
+ collection.get_edited_preset().name + "\", not \"" + target
|
||||
+ "\"; the transaction was not created");
|
||||
|
||||
transaction.type = type;
|
||||
transaction.target_name = target;
|
||||
transaction.selection_changed = selection_changed;
|
||||
}
|
||||
|
||||
// Blocking form used by begin_update() and update().
|
||||
PresetTransaction begin_preset_update(Preset::Type type, const std::string& preset_name)
|
||||
{
|
||||
PresetTransaction transaction;
|
||||
PluginHostUi::run_on_ui_thread([&] { bind_preset_target(type, preset_name, transaction); });
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Filament target resolution: slot_number identifies the slot; an omitted preset_name keeps the
|
||||
// preset assigned to it. A supplied preset_name selects that preset for the slot through the normal
|
||||
// tab-selection flow, then records the slot assignment and its dependent Plater side effects (the
|
||||
// ones the sidebar selection path performs) only after that flow succeeds, so a canceled prompt
|
||||
// changes neither. A mixed-color virtual slot is rejected before any of this, with or without a
|
||||
// preset_name. A requested preset the requested slot's own dropdown does not offer is rejected
|
||||
// before the selection flow, so no dialog is raised for it; the one exception is the slot's own
|
||||
// current assignment, which stays targetable when the dropdown hides it. A different preset the
|
||||
// tab happens to edit is a globally edited target, not an offered choice for this slot, and is
|
||||
// rejected.
|
||||
// Runs on the UI thread with the GIL released, called from begin_filament_update().
|
||||
void bind_filament_target(int slot_number, const std::string& preset_name, PresetTransaction& transaction)
|
||||
{
|
||||
GUI::GUI_App& app = require_gui_app();
|
||||
PresetBundle& bundle = *app.preset_bundle;
|
||||
GUI::Plater* plater = app.plater();
|
||||
|
||||
// A mixed-color virtual slot has no single filament preset behind it, so every form of this
|
||||
// call is rejected: with or without a preset_name there is no one preset to edit or assign.
|
||||
// Range-guarded so an out-of-range slot still gets resolved_slot_preset()'s clear error.
|
||||
if (slot_number >= 1 && static_cast<size_t>(slot_number) <= bundle.filament_presets.size()
|
||||
&& bundle.is_mixed_filament(static_cast<size_t>(slot_number) - 1))
|
||||
throw std::invalid_argument("Filament slot " + std::to_string(slot_number)
|
||||
+ " is a mixed-color virtual slot; it has no single filament preset to edit or assign");
|
||||
|
||||
// Resolve and validate before any selection, so a bad slot fails without a prompt.
|
||||
const std::string assigned_internal = resolved_slot_preset(bundle, slot_number);
|
||||
|
||||
GUI::Tab* tab = require_tab(app, Preset::TYPE_FILAMENT);
|
||||
|
||||
const size_t slot_index = static_cast<size_t>(slot_number) - 1;
|
||||
|
||||
std::string target = assigned_internal;
|
||||
if (!preset_name.empty()) {
|
||||
// Resolve against the requested sidebar slot's own selector, not the tab's: that is the
|
||||
// model the user picks this slot's filament from, including its compatibility filter. The
|
||||
// selector matches the exact name shown (display label or item alias).
|
||||
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
|
||||
GUI::PlaterPresetComboBox* slot_combo = slot_index < slot_combos.size() ? slot_combos[slot_index] : nullptr;
|
||||
std::string resolved;
|
||||
if (slot_combo != nullptr && slot_combo->GetCount() > 0) {
|
||||
const ComboPresetMatch match = resolve_combo_preset(*slot_combo, preset_name);
|
||||
if (match.ambiguous)
|
||||
throw std::invalid_argument("The filament name \"" + preset_name
|
||||
+ "\" is ambiguous in the filament preset selector for filament slot "
|
||||
+ std::to_string(slot_number)
|
||||
+ "; more than one entry shows it, so name the preset by its own entry");
|
||||
if (match.item >= 0) {
|
||||
resolved = match.internal_name;
|
||||
} else {
|
||||
// Not shown by this slot's selector. Only the slot's current assignment stays
|
||||
// targetable (a hidden or incompatible-but-selected preset the dropdown omits);
|
||||
// resolve it by internal name or by display alias and accept only that preset. A
|
||||
// different preset the tab happens to edit is a globally edited target, not an
|
||||
// offered choice for this slot, and is rejected.
|
||||
Preset* by_name = bundle.filaments.find_preset(preset_name);
|
||||
if (by_name == nullptr) {
|
||||
const std::string& alias_name = bundle.filaments.get_preset_name_by_alias(preset_name);
|
||||
by_name = bundle.filaments.find_preset(alias_name);
|
||||
}
|
||||
if (by_name == nullptr || by_name->name != assigned_internal)
|
||||
throw std::invalid_argument("Filament preset \"" + preset_name
|
||||
+ "\" is not offered by the filament preset selector for filament slot "
|
||||
+ std::to_string(slot_number));
|
||||
resolved = by_name->name;
|
||||
}
|
||||
} else {
|
||||
// The slot selector is not populated; fall back to the tab selector, then to collection
|
||||
// internal names, so the call still works when the sidebar has not been laid out.
|
||||
GUI::TabPresetComboBox* tab_combo = tab->get_combo_box();
|
||||
if (tab_combo != nullptr && tab_combo->GetCount() > 0)
|
||||
resolved = resolve_preset_in_combo(*tab_combo, preset_name, "filament",
|
||||
"filament preset selector for the selected printer");
|
||||
else
|
||||
resolved = preset_name;
|
||||
}
|
||||
|
||||
Preset* requested = bundle.filaments.find_preset(resolved);
|
||||
if (requested == nullptr)
|
||||
throw std::invalid_argument("No filament preset named \"" + resolved + "\" is available to select");
|
||||
target = requested->name;
|
||||
}
|
||||
|
||||
bool selection_changed = false;
|
||||
if (bundle.filaments.get_edited_preset().name != target) {
|
||||
// A declined prompt is a user cancellation: it is thrown before any slot-assignment change,
|
||||
// so neither the mapping nor a setting is touched.
|
||||
if (!tab->select_preset(target))
|
||||
throw SelectionCancelled("Switching the edited filament preset to \"" + target + "\" for filament slot "
|
||||
+ std::to_string(slot_number)
|
||||
+ " was canceled; neither the slot assignment nor any setting was changed");
|
||||
if (bundle.filaments.get_edited_preset().name != target)
|
||||
throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name
|
||||
+ "\", not \"" + target + "\"; the transaction was not created");
|
||||
selection_changed = true;
|
||||
}
|
||||
|
||||
bool assignment_changed = false;
|
||||
if (!preset_name.empty() && target != assigned_internal) {
|
||||
if (app.app_config == nullptr)
|
||||
throw std::runtime_error("Application configuration is not available");
|
||||
// Mirror the filament branch of Plater::priv::on_select_preset() (the sidebar's normal
|
||||
// preset selection): update the mapping, then every dependent project/UI side effect,
|
||||
// rather than the vector entry alone.
|
||||
const bool was_support = GUI::is_support_filament(static_cast<int>(slot_index));
|
||||
bundle.set_filament_preset(slot_index, target);
|
||||
plater->update_project_dirty_from_presets();
|
||||
bundle.export_selections(*app.app_config);
|
||||
plater->sidebar().update_dynamic_filament_list();
|
||||
// Assigning a support filament (or moving away from one) changes which filaments the
|
||||
// flush rules apply to, so the normal flow recalculates the flushing volumes when
|
||||
// auto-calc covers every filament.
|
||||
if (GUI::is_support_filament(static_cast<int>(slot_index)) != was_support
|
||||
&& app.app_config->get("auto_calculate_flush") == "all")
|
||||
plater->sidebar().auto_calc_flushing_volumes(static_cast<int>(slot_index));
|
||||
// Refresh the requested slot's own sidebar combo so its list and current selection show the
|
||||
// newly assigned target (the list may not have contained it), matching the sidebar
|
||||
// selection path. Fetch the pointer after update_dynamic_filament_list(), which may
|
||||
// rebuild the combo vector.
|
||||
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
|
||||
if (slot_index < slot_combos.size() && slot_combos[slot_index] != nullptr) {
|
||||
GUI::PlaterPresetComboBox* slot_combo = slot_combos[slot_index];
|
||||
slot_combo->update();
|
||||
// Keep the slot's AMS sync badge truthful, reading the item's own flag, the same source
|
||||
// on_select_preset() reads, so no UI state is invented here.
|
||||
const int item = combo_item_for_preset(*slot_combo, target);
|
||||
if (item >= 0)
|
||||
slot_combo->set_sync_badge(slot_combo->GetFlag(item)
|
||||
== static_cast<int>(GUI::PresetComboBox::FilamentAMSType::FROM_AMS));
|
||||
}
|
||||
plater->on_filament_change(slot_index);
|
||||
assignment_changed = true;
|
||||
}
|
||||
|
||||
// Match PlaterPresetComboBox::switch_to_tab(): record which 0-based filament slot this tab
|
||||
// combobox now edits, so the GUI shows the slot the value was written for.
|
||||
if (GUI::TabPresetComboBox* combo = tab->get_combo_box())
|
||||
combo->set_filament_idx(slot_number - 1);
|
||||
|
||||
transaction.type = Preset::TYPE_FILAMENT;
|
||||
transaction.filament = true;
|
||||
transaction.slot_number = slot_number;
|
||||
transaction.target_name = target;
|
||||
transaction.selection_changed = selection_changed;
|
||||
transaction.slot_assignment_changed = assignment_changed;
|
||||
// Snapshot the mapping after the explicit assignment, as the slot's resolved internal name,
|
||||
// so commit() compares like with like (a stored alias resolves to the same internal preset).
|
||||
transaction.slot_assignment_snapshot = resolved_slot_preset(bundle, slot_number);
|
||||
}
|
||||
|
||||
// Blocking form used by begin_update() and update().
|
||||
PresetTransaction begin_filament_update(int slot_number, const std::string& preset_name)
|
||||
{
|
||||
PresetTransaction transaction;
|
||||
PluginHostUi::run_on_ui_thread([&] { bind_filament_target(slot_number, preset_name, transaction); });
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Compatibility shorthand: one staged setting, committed immediately through the same
|
||||
// begin_update()/set()/commit() path, so targeting, selection, validation and commit are shared.
|
||||
void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value)
|
||||
{
|
||||
PresetTransaction transaction = begin_preset_update(type, std::string());
|
||||
transaction.set(key, value);
|
||||
transaction.commit();
|
||||
}
|
||||
|
||||
void update_filament_slot(int slot_number, const std::string& key, const std::string& value)
|
||||
{
|
||||
PresetTransaction transaction = begin_filament_update(slot_number, std::string());
|
||||
transaction.set(key, value);
|
||||
transaction.commit();
|
||||
}
|
||||
|
||||
void reload_local_bundle(const std::string& bundle_id)
|
||||
{
|
||||
bool reloaded = false;
|
||||
@@ -641,7 +100,7 @@ std::vector<std::string> list_bundle_ids()
|
||||
|
||||
void host_bindings::register_presets(py::module_& host)
|
||||
{
|
||||
auto preset = host.def_submodule("preset", "Preset operations");
|
||||
auto preset = host.def_submodule("preset", "Preset operations");
|
||||
auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
|
||||
bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
|
||||
"Reload one local preset bundle from the active user's _local directory.");
|
||||
@@ -660,145 +119,6 @@ void host_bindings::register_presets(py::module_& host)
|
||||
.value("Plate", Preset::TYPE_PLATE)
|
||||
.value("Model", Preset::TYPE_MODEL);
|
||||
|
||||
py::class_<PresetUpdateResult>(preset, "PresetUpdateResult",
|
||||
"Outcome of one committed preset transaction. target_name is the preset that was "
|
||||
"edited; selection_changed says whether begin_update() switched the active preset; "
|
||||
"for filament, slot_number is the 1-based target slot, slot_assignment_changed "
|
||||
"says whether selecting the requested preset reassigned that slot, and shared_slots "
|
||||
"lists every 1-based slot whose assigned preset is the edited (shared) one. keys "
|
||||
"lists the settings applied, in order, and dirty says whether the edited preset "
|
||||
"now differs from its saved state.")
|
||||
.def_readonly("type", &PresetUpdateResult::type)
|
||||
.def_readonly("target_name", &PresetUpdateResult::target_name)
|
||||
.def_readonly("selection_changed", &PresetUpdateResult::selection_changed)
|
||||
.def_readonly("slot_number", &PresetUpdateResult::slot_number)
|
||||
.def_readonly("slot_assignment_changed", &PresetUpdateResult::slot_assignment_changed)
|
||||
.def_readonly("shared_slots", &PresetUpdateResult::shared_slots)
|
||||
.def_readonly("keys", &PresetUpdateResult::keys)
|
||||
.def_readonly("dirty", &PresetUpdateResult::dirty)
|
||||
.def_property_readonly("category", [](const PresetUpdateResult& result) { return preset_tab_name(result.type); })
|
||||
.def("__repr__", [](const PresetUpdateResult& result) {
|
||||
return std::string("<PresetUpdateResult ") + preset_tab_name(result.type) + " \"" + result.target_name
|
||||
+ "\" keys=" + std::to_string(result.keys.size()) + ">";
|
||||
});
|
||||
|
||||
py::class_<PresetTransaction>(preset, "PresetTransaction",
|
||||
"A staged, single-target preset edit returned by begin_update(). set() stages a "
|
||||
"serialized value without mutating the preset; commit() validates and applies the "
|
||||
"whole batch through the normal setting-change path. begin_update() blocks the "
|
||||
"background Script worker until any selection or unsaved-edit dialog is answered, "
|
||||
"while the UI thread stays responsive; set() and commit() run synchronously on the "
|
||||
"same worker. Nothing is saved to disk: the edited preset stays dirty until the user "
|
||||
"saves it.")
|
||||
.def_readonly("type", &PresetTransaction::type)
|
||||
.def_readonly("target_name", &PresetTransaction::target_name)
|
||||
.def_readonly("selection_changed", &PresetTransaction::selection_changed)
|
||||
.def_property_readonly("slot_number", [](const PresetTransaction& transaction) {
|
||||
return transaction.filament ? transaction.slot_number : -1;
|
||||
})
|
||||
.def_property_readonly("category", [](const PresetTransaction& transaction) { return preset_tab_name(transaction.type); })
|
||||
.def_property_readonly("staged_keys", [](const PresetTransaction& transaction) {
|
||||
std::vector<std::string> keys;
|
||||
keys.reserve(transaction.staged.size());
|
||||
for (const auto& entry : transaction.staged)
|
||||
keys.push_back(entry.first);
|
||||
return keys;
|
||||
})
|
||||
.def("set", &PresetTransaction::set, py::arg("key"), py::arg("value"),
|
||||
"Stage one setting change. key must be a setting of the target preset category and value its "
|
||||
"serialized string form; staging validates neither, so commit() is where an invalid key or value "
|
||||
"fails. Staging the same key twice in one transaction raises ValueError (conflicting duplicate "
|
||||
"writes are rejected). After commit() the transaction is closed.")
|
||||
.def("commit", &PresetTransaction::commit,
|
||||
"Validate and apply every staged setting through the same path as editing it in the GUI, then return "
|
||||
"a PresetUpdateResult. An invalid key or value raises ValueError; a changed target or filament slot "
|
||||
"mapping raises RuntimeError; a failure leaves none of the staged settings applied. The edited preset "
|
||||
"becomes dirty until the user saves it.");
|
||||
|
||||
py::class_<PresetEditor>(preset, "PresetEditor",
|
||||
"Handle to a category of currently edited preset. It is not a snapshot: it always "
|
||||
"resolves the preset the matching tab edits now, so it stays usable across preset "
|
||||
"switches. update() edits the preset in place; the change is not saved to disk.")
|
||||
.def_readonly("type", &PresetEditor::type)
|
||||
.def(
|
||||
"begin_update",
|
||||
[](const PresetEditor& editor, const std::string& preset_name) {
|
||||
if (editor.type == Preset::TYPE_FILAMENT)
|
||||
throw std::invalid_argument("begin_update on the filament editor takes "
|
||||
"(slot_number, preset_name=\"\"); use orca.host.preset.filament.begin_update(slot_number, ...)");
|
||||
return begin_preset_update(editor.type, preset_name);
|
||||
},
|
||||
py::arg("preset_name") = "",
|
||||
"Resolve and bind this category's target preset and return a PresetTransaction. With no preset_name the "
|
||||
"currently active preset is targeted; a preset_name must be a preset this category can select, and is "
|
||||
"selected through the normal preset-selection flow (which prompts about unsaved edits). The call blocks "
|
||||
"the background Script worker until any such dialog is answered, leaving the UI thread responsive. A "
|
||||
"selection the user cancels raises RuntimeError; an unavailable or invalid target raises ValueError. "
|
||||
"Either failure creates no transaction and stages no setting. Call it from the background Script worker, "
|
||||
"never from the UI thread. This form is for process and printer; filament takes "
|
||||
"begin_update(slot_number, preset_name=\"\").")
|
||||
.def(
|
||||
"begin_update",
|
||||
[](const PresetEditor& editor, int slot_number, const std::string& preset_name) {
|
||||
if (editor.type != Preset::TYPE_FILAMENT)
|
||||
throw std::invalid_argument("The slot_number form of begin_update() is only available on "
|
||||
"orca.host.preset.filament; call begin_update(preset_name=\"\") on this editor");
|
||||
return begin_filament_update(slot_number, preset_name);
|
||||
},
|
||||
py::arg("slot_number"), py::arg("preset_name") = "",
|
||||
"Resolve and bind a filament preset by 1-based slot and return a PresetTransaction. Omitting preset_name "
|
||||
"targets the preset assigned to the slot; supplying a different name selects it for the slot through the "
|
||||
"normal flow (prompting about unsaved edits) and, once that succeeds, records the new slot assignment with "
|
||||
"the same Plater side effects the sidebar selection performs (project/selection refresh, dynamic filament "
|
||||
"list, conditional support-filament flushing recalc and the slot's AMS sync badge). A requested preset "
|
||||
"this slot's dropdown does not offer is rejected before that flow, so it raises no prompt; the slot's own "
|
||||
"current assignment stays targetable even when the dropdown hides it. A selection the user cancels raises "
|
||||
"RuntimeError and changes neither the slot assignment nor any setting. An out-of-range, empty or "
|
||||
"unresolved slot is rejected, and a mixed-color virtual slot is rejected whether or not preset_name is "
|
||||
"given. The transaction edits the shared preset, so every slot assigned to it is affected. The call "
|
||||
"blocks the background Script worker until the selection settles, leaving the UI thread responsive; call "
|
||||
"it from that worker, never from the UI thread.")
|
||||
.def(
|
||||
"update",
|
||||
[](const PresetEditor& editor, const std::string& key, const std::string& value) {
|
||||
update_edited_preset(editor.type, key, value);
|
||||
},
|
||||
py::arg("key"), py::arg("value"),
|
||||
"Set one setting of the currently edited preset and run the same committed-change path as "
|
||||
"editing the setting in the GUI; the edited preset becomes dirty until saved/exported. "
|
||||
"key must be a setting of this preset category and value its serialized string form, "
|
||||
"otherwise ValueError is raised and nothing is written. A setting edited through a field "
|
||||
"runs that field's validation even before its page has been opened; one the GUI edits "
|
||||
"outside a field (such as compatible printers) also recomputes preset compatibility. "
|
||||
"Equivalent to begin_update() with the active preset as target, then set(key, value) "
|
||||
"and commit(); like begin_update() this call blocks the background Script worker.")
|
||||
.def(
|
||||
"update",
|
||||
[](const PresetEditor& editor, int slot_number, const std::string& key, const std::string& value) {
|
||||
if (editor.type != Preset::TYPE_FILAMENT)
|
||||
throw std::invalid_argument("The slot_number form of update() is only available on "
|
||||
"orca.host.preset.filament; call update(key, value) on this editor");
|
||||
update_filament_slot(slot_number, key, value);
|
||||
},
|
||||
py::arg("slot_number"), py::arg("key"), py::arg("value"),
|
||||
"Set one setting of the filament preset assigned to a 1-based filament slot. When that preset "
|
||||
"is not the one currently edited the call switches to it through the normal preset-selection "
|
||||
"flow, so an unsaved edited preset still prompts the user and a declined prompt cancels the "
|
||||
"call with an error and no change. An out-of-range, empty or unresolved slot is rejected with "
|
||||
"a clear error, as is a mixed-color virtual slot, which has no single preset to edit. Other "
|
||||
"preset editors accept only update(key, value); this form raises "
|
||||
"ValueError on them. Equivalent to begin_update(slot_number) with an omitted preset_name "
|
||||
"(the slot's current assignment is kept), then set(key, value) and commit(); like "
|
||||
"begin_update() this call blocks the background Script worker.");
|
||||
|
||||
// The three preset categories edited by the main tabs. `process` is the print/process preset,
|
||||
// `printer` the machine preset and `filament` the currently edited filament preset (its
|
||||
// 3-argument form addresses a 1-based filament slot); each only accepts its own settings, so a
|
||||
// printer setting passed to `process` is rejected instead of written silently.
|
||||
preset.attr("process") = py::cast(PresetEditor{Preset::TYPE_PRINT});
|
||||
preset.attr("printer") = py::cast(PresetEditor{Preset::TYPE_PRINTER});
|
||||
preset.attr("filament") = py::cast(PresetEditor{Preset::TYPE_FILAMENT});
|
||||
|
||||
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
|
||||
.def_readonly("type", &Preset::type)
|
||||
.def_readonly("name", &Preset::name)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
solid STL generated by MeshLab
facet normal 0.000000e+00 -0.000000e+00 -1.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 0.000000e+00
vertex 2.000000e+01 0.000000e+00 0.000000e+00
vertex 0.000000e+00 0.000000e+00 0.000000e+00
endloop
endfacet
facet normal -0.000000e+00 0.000000e+00 -1.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 0.000000e+00
vertex 0.000000e+00 0.000000e+00 0.000000e+00
vertex 0.000000e+00 2.000000e+01 0.000000e+00
endloop
endfacet
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 2.000000e+01
vertex 0.000000e+00 2.000000e+01 2.000000e+01
vertex 0.000000e+00 0.000000e+00 2.000000e+01
endloop
endfacet
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 2.000000e+01
vertex 0.000000e+00 0.000000e+00 2.000000e+01
vertex 2.000000e+01 0.000000e+00 2.000000e+01
endloop
endfacet
facet normal 1.000000e+00 0.000000e+00 -0.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 0.000000e+00
vertex 2.000000e+01 2.000000e+01 2.000000e+01
vertex 2.000000e+01 0.000000e+00 2.000000e+01
endloop
endfacet
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 0.000000e+00
vertex 2.000000e+01 0.000000e+00 2.000000e+01
vertex 2.000000e+01 0.000000e+00 0.000000e+00
endloop
endfacet
facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
outer loop
vertex 2.000000e+01 0.000000e+00 0.000000e+00
vertex 2.000000e+01 0.000000e+00 2.000000e+01
vertex 0.000000e+00 0.000000e+00 2.000000e+01
endloop
endfacet
facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
outer loop
vertex 2.000000e+01 0.000000e+00 0.000000e+00
vertex 0.000000e+00 0.000000e+00 2.000000e+01
vertex 0.000000e+00 0.000000e+00 0.000000e+00
endloop
endfacet
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
outer loop
vertex 0.000000e+00 0.000000e+00 0.000000e+00
vertex 0.000000e+00 0.000000e+00 2.000000e+01
vertex 0.000000e+00 2.000000e+01 2.000000e+01
endloop
endfacet
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
outer loop
vertex 0.000000e+00 0.000000e+00 0.000000e+00
vertex 0.000000e+00 2.000000e+01 2.000000e+01
vertex 0.000000e+00 2.000000e+01 0.000000e+00
endloop
endfacet
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 2.000000e+01
vertex 2.000000e+01 2.000000e+01 0.000000e+00
vertex 0.000000e+00 2.000000e+01 0.000000e+00
endloop
endfacet
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
outer loop
vertex 2.000000e+01 2.000000e+01 2.000000e+01
vertex 0.000000e+00 2.000000e+01 0.000000e+00
vertex 0.000000e+00 2.000000e+01 2.000000e+01
endloop
endfacet
endsolid vcg
|
||||
@@ -44,7 +44,6 @@
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/SVG.hpp"
|
||||
#include "libslic3r/libslic3r.h"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
@@ -53,15 +52,6 @@ using namespace Slic3r;
|
||||
|
||||
bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacing, double angle = 0, double density = 1.0);
|
||||
|
||||
#if 0
|
||||
TEST_CASE("Adjusted solid distance", "[Fill]") {
|
||||
int surface_width = 250;
|
||||
int distance = Slic3r::Flow::solid_spacing(surface_width, 47);
|
||||
REQUIRE(distance == Catch::Approx(50));
|
||||
REQUIRE(surface_width % distance == 0);
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST_CASE("Pattern path length", "[Fill]") {
|
||||
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("rectilinear"));
|
||||
filler->angle = float(-(PI)/2.0);
|
||||
@@ -120,15 +110,6 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
filler->angle = float(angle);
|
||||
ExPolygon e(test_square, test_hole);
|
||||
Slic3r::Polylines paths = test(e);
|
||||
#if 0
|
||||
{
|
||||
BoundingBox bbox = get_extents(e);
|
||||
SVG svg("c:\\data\\temp\\square_with_holes.svg", bbox);
|
||||
svg.draw(e);
|
||||
svg.draw(paths);
|
||||
svg.Close();
|
||||
}
|
||||
#endif
|
||||
REQUIRE((paths.size() >= 1 && paths.size() <= 3));
|
||||
// paths don't cross hole
|
||||
REQUIRE(diff_pl(paths, offset(e, float(SCALED_EPSILON*10))).size() == 0);
|
||||
@@ -175,25 +156,6 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
}
|
||||
}
|
||||
|
||||
#if 0 // Disabled temporarily due to precision issues on the Mac VM
|
||||
SECTION("Solid surface fill") {
|
||||
Slic3r::Points points {
|
||||
Point::new_scale(6883102, 9598327.01296997),
|
||||
Point::new_scale(6883102, 20327272.01297),
|
||||
Point::new_scale(3116896, 20327272.01297),
|
||||
Point::new_scale(3116896, 9598327.01296997)
|
||||
};
|
||||
Slic3r::ExPolygon expolygon(points);
|
||||
|
||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.55) == true);
|
||||
for (size_t i = 0; i <= 20; ++i)
|
||||
{
|
||||
expolygon.scale(1.05);
|
||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.55) == true);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
SECTION("Solid surface fill") {
|
||||
Slic3r::Points points {
|
||||
Slic3r::Point(59515297,5422499),Slic3r::Point(59531249,5578697),Slic3r::Point(59695801,6123186),
|
||||
@@ -226,245 +188,6 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
my $collection = Slic3r::Polyline::Collection->new(
|
||||
Slic3r::Polyline->new([0,15], [0,18], [0,20]),
|
||||
Slic3r::Polyline->new([0,10], [0,8], [0,5]),
|
||||
);
|
||||
is_deeply
|
||||
[ map $_->[Y], map @$_, @{$collection->chained_path_from(Slic3r::Point->new(0,30), 0)} ],
|
||||
[20, 18, 15, 10, 8, 5],
|
||||
'chained path';
|
||||
}
|
||||
|
||||
{
|
||||
my $collection = Slic3r::Polyline::Collection->new(
|
||||
Slic3r::Polyline->new([4,0], [10,0], [15,0]),
|
||||
Slic3r::Polyline->new([10,5], [15,5], [20,5]),
|
||||
);
|
||||
is_deeply
|
||||
[ map $_->[X], map @$_, @{$collection->chained_path_from(Slic3r::Point->new(30,0), 0)} ],
|
||||
[reverse 4, 10, 15, 10, 15, 20],
|
||||
'chained path';
|
||||
}
|
||||
|
||||
{
|
||||
my $collection = Slic3r::ExtrusionPath::Collection->new(
|
||||
map Slic3r::ExtrusionPath->new(polyline => $_, role => 0, mm3_per_mm => 1),
|
||||
Slic3r::Polyline->new([0,15], [0,18], [0,20]),
|
||||
Slic3r::Polyline->new([0,10], [0,8], [0,5]),
|
||||
);
|
||||
is_deeply
|
||||
[ map $_->[Y], map @{$_->polyline}, @{$collection->chained_path_from(Slic3r::Point->new(0,30), 0)} ],
|
||||
[20, 18, 15, 10, 8, 5],
|
||||
'chained path';
|
||||
}
|
||||
|
||||
{
|
||||
my $collection = Slic3r::ExtrusionPath::Collection->new(
|
||||
map Slic3r::ExtrusionPath->new(polyline => $_, role => 0, mm3_per_mm => 1),
|
||||
Slic3r::Polyline->new([15,0], [10,0], [4,0]),
|
||||
Slic3r::Polyline->new([10,5], [15,5], [20,5]),
|
||||
);
|
||||
is_deeply
|
||||
[ map $_->[X], map @{$_->polyline}, @{$collection->chained_path_from(Slic3r::Point->new(30,0), 0)} ],
|
||||
[reverse 4, 10, 15, 10, 15, 20],
|
||||
'chained path';
|
||||
}
|
||||
|
||||
for my $pattern (qw(rectilinear honeycomb hilbertcurve concentric)) {
|
||||
my $config = Slic3r::Config->new_from_defaults;
|
||||
$config->set('fill_pattern', $pattern);
|
||||
$config->set('external_fill_pattern', $pattern);
|
||||
$config->set('perimeters', 1);
|
||||
$config->set('skirts', 0);
|
||||
$config->set('fill_density', 20);
|
||||
$config->set('layer_height', 0.05);
|
||||
$config->set('perimeter_extruder', 1);
|
||||
$config->set('infill_extruder', 2);
|
||||
my $print = Slic3r::Test::init_print('20mm_cube', config => $config, scale => 2);
|
||||
ok my $gcode = Slic3r::Test::gcode($print), "successful $pattern infill generation";
|
||||
my $tool = undef;
|
||||
my @perimeter_points = my @infill_points = ();
|
||||
Slic3r::GCode::Reader->new->parse($gcode, sub {
|
||||
my ($self, $cmd, $args, $info) = @_;
|
||||
|
||||
if ($cmd =~ /^T(\d+)/) {
|
||||
$tool = $1;
|
||||
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
|
||||
if ($tool == $config->perimeter_extruder-1) {
|
||||
push @perimeter_points, Slic3r::Point->new_scale($args->{X}, $args->{Y});
|
||||
} elsif ($tool == $config->infill_extruder-1) {
|
||||
push @infill_points, Slic3r::Point->new_scale($args->{X}, $args->{Y});
|
||||
}
|
||||
}
|
||||
});
|
||||
my $convex_hull = convex_hull(\@perimeter_points);
|
||||
ok !(defined first { !$convex_hull->contains_point($_) } @infill_points), "infill does not exceed perimeters ($pattern)";
|
||||
}
|
||||
|
||||
{
|
||||
my $config = Slic3r::Config->new_from_defaults;
|
||||
$config->set('infill_only_where_needed', 1);
|
||||
$config->set('bottom_solid_layers', 0);
|
||||
$config->set('infill_extruder', 2);
|
||||
$config->set('infill_extrusion_width', 0.5);
|
||||
$config->set('fill_density', 40);
|
||||
$config->set('cooling', 0); # for preventing speeds from being altered
|
||||
$config->set('first_layer_speed', '100%'); # for preventing speeds from being altered
|
||||
|
||||
my $test = sub {
|
||||
my $print = Slic3r::Test::init_print('pyramid', config => $config);
|
||||
|
||||
my $tool = undef;
|
||||
my @infill_extrusions = (); # array of polylines
|
||||
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
|
||||
my ($self, $cmd, $args, $info) = @_;
|
||||
|
||||
if ($cmd =~ /^T(\d+)/) {
|
||||
$tool = $1;
|
||||
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
|
||||
if ($tool == $config->infill_extruder-1) {
|
||||
push @infill_extrusions, Slic3r::Line->new_scale(
|
||||
[ $self->X, $self->Y ],
|
||||
[ $info->{new_X}, $info->{new_Y} ],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return 0 if !@infill_extrusions; # prevent calling convex_hull() with no points
|
||||
|
||||
my $convex_hull = convex_hull([ map $_->pp, map @$_, @infill_extrusions ]);
|
||||
return unscale unscale sum(map $_->area, @{offset([$convex_hull], scale(+$config->infill_extrusion_width/2))});
|
||||
};
|
||||
|
||||
my $tolerance = 5; # mm^2
|
||||
|
||||
$config->set('solid_infill_below_area', 0);
|
||||
ok $test->() < $tolerance,
|
||||
'no infill is generated when using infill_only_where_needed on a pyramid';
|
||||
|
||||
$config->set('solid_infill_below_area', 70);
|
||||
ok abs($test->() - $config->solid_infill_below_area) < $tolerance,
|
||||
'infill is only generated under the forced solid shells';
|
||||
}
|
||||
|
||||
{
|
||||
my $config = Slic3r::Config->new_from_defaults;
|
||||
$config->set('skirts', 0);
|
||||
$config->set('perimeters', 1);
|
||||
$config->set('fill_density', 0);
|
||||
$config->set('top_solid_layers', 0);
|
||||
$config->set('bottom_solid_layers', 0);
|
||||
$config->set('solid_infill_below_area', 20000000);
|
||||
$config->set('solid_infill_every_layers', 2);
|
||||
$config->set('perimeter_speed', 99);
|
||||
$config->set('external_perimeter_speed', 99);
|
||||
$config->set('cooling', 0);
|
||||
$config->set('first_layer_speed', '100%');
|
||||
|
||||
my $print = Slic3r::Test::init_print('20mm_cube', config => $config);
|
||||
my %layers_with_extrusion = ();
|
||||
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
|
||||
my ($self, $cmd, $args, $info) = @_;
|
||||
|
||||
if ($cmd eq 'G1' && $info->{dist_XY} > 0 && $info->{extruding}) {
|
||||
if (($args->{F} // $self->F) != $config->perimeter_speed*60) {
|
||||
$layers_with_extrusion{$self->Z} = ($args->{F} // $self->F);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ok !%layers_with_extrusion,
|
||||
"solid_infill_below_area and solid_infill_every_layers are ignored when fill_density is 0";
|
||||
}
|
||||
|
||||
{
|
||||
my $config = Slic3r::Config->new_from_defaults;
|
||||
$config->set('skirts', 0);
|
||||
$config->set('perimeters', 3);
|
||||
$config->set('fill_density', 0);
|
||||
$config->set('layer_height', 0.2);
|
||||
$config->set('first_layer_height', 0.2);
|
||||
$config->set('nozzle_diameter', [0.35]);
|
||||
$config->set('infill_extruder', 2);
|
||||
$config->set('solid_infill_extruder', 2);
|
||||
$config->set('infill_extrusion_width', 0.52);
|
||||
$config->set('solid_infill_extrusion_width', 0.52);
|
||||
$config->set('first_layer_extrusion_width', 0);
|
||||
|
||||
my $print = Slic3r::Test::init_print('A', config => $config);
|
||||
my %infill = (); # Z => [ Line, Line ... ]
|
||||
my $tool = undef;
|
||||
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
|
||||
my ($self, $cmd, $args, $info) = @_;
|
||||
|
||||
if ($cmd =~ /^T(\d+)/) {
|
||||
$tool = $1;
|
||||
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
|
||||
if ($tool == $config->infill_extruder-1) {
|
||||
my $z = 1 * $self->Z;
|
||||
$infill{$z} ||= [];
|
||||
push @{$infill{$z}}, Slic3r::Line->new_scale(
|
||||
[ $self->X, $self->Y ],
|
||||
[ $info->{new_X}, $info->{new_Y} ],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
my $grow_d = scale($config->infill_extrusion_width)/2;
|
||||
my $layer0_infill = union([ map @{$_->grow($grow_d)}, @{ $infill{0.2} } ]);
|
||||
my $layer1_infill = union([ map @{$_->grow($grow_d)}, @{ $infill{0.4} } ]);
|
||||
my $diff = diff($layer0_infill, $layer1_infill);
|
||||
$diff = offset2_ex($diff, -$grow_d, +$grow_d);
|
||||
$diff = [ grep { $_->area > 2*(($grow_d*2)**2) } @$diff ];
|
||||
is scalar(@$diff), 0, 'no missing parts in solid shell when fill_density is 0';
|
||||
}
|
||||
|
||||
{
|
||||
# GH: #2697
|
||||
my $config = Slic3r::Config->new_from_defaults;
|
||||
$config->set('perimeter_extrusion_width', 0.72);
|
||||
$config->set('top_infill_extrusion_width', 0.1);
|
||||
$config->set('infill_extruder', 2); # in order to distinguish infill
|
||||
$config->set('solid_infill_extruder', 2); # in order to distinguish infill
|
||||
|
||||
my $print = Slic3r::Test::init_print('20mm_cube', config => $config);
|
||||
my %infill = (); # Z => [ Line, Line ... ]
|
||||
my %other = (); # Z => [ Line, Line ... ]
|
||||
my $tool = undef;
|
||||
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
|
||||
my ($self, $cmd, $args, $info) = @_;
|
||||
|
||||
if ($cmd =~ /^T(\d+)/) {
|
||||
$tool = $1;
|
||||
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
|
||||
my $z = 1 * $self->Z;
|
||||
my $line = Slic3r::Line->new_scale(
|
||||
[ $self->X, $self->Y ],
|
||||
[ $info->{new_X}, $info->{new_Y} ],
|
||||
);
|
||||
if ($tool == $config->infill_extruder-1) {
|
||||
$infill{$z} //= [];
|
||||
push @{$infill{$z}}, $line;
|
||||
} else {
|
||||
$other{$z} //= [];
|
||||
push @{$other{$z}}, $line;
|
||||
}
|
||||
}
|
||||
});
|
||||
my $top_z = max(keys %infill);
|
||||
my $top_infill_grow_d = scale($config->top_infill_extrusion_width)/2;
|
||||
my $top_infill = union([ map @{$_->grow($top_infill_grow_d)}, @{ $infill{$top_z} } ]);
|
||||
my $perimeters_grow_d = scale($config->perimeter_extrusion_width)/2;
|
||||
my $perimeters = union([ map @{$_->grow($perimeters_grow_d)}, @{ $other{$top_z} } ]);
|
||||
my $covered = union_ex([ @$top_infill, @$perimeters ]);
|
||||
my @holes = map @{$_->holes}, @$covered;
|
||||
ok sum(map unscale unscale $_->area*-1, @holes) < 1, 'no gaps between top solid infill and perimeters';
|
||||
}
|
||||
*/
|
||||
|
||||
bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacing, double angle, double density)
|
||||
{
|
||||
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("rectilinear"));
|
||||
@@ -498,18 +221,6 @@ bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacin
|
||||
const double scaled_flow_spacing = std::pow(scale_(flow_spacing), 2);
|
||||
uncovered.erase(std::remove_if(uncovered.begin(), uncovered.end(), [scaled_flow_spacing](const ExPolygon& poly) { return poly.area() < scaled_flow_spacing; }), uncovered.end());
|
||||
|
||||
#if 0
|
||||
if (! uncovered.empty()) {
|
||||
BoundingBox bbox = get_extents(expolygon.contour);
|
||||
bbox.merge(get_extents(uncovered));
|
||||
bbox.merge(get_extents(grown_paths));
|
||||
SVG svg("c:\\data\\temp\\test_if_solid_surface_filled.svg", bbox);
|
||||
svg.draw(expolygon);
|
||||
svg.draw(uncovered, "red");
|
||||
svg.Close();
|
||||
}
|
||||
#endif
|
||||
|
||||
return uncovered.empty(); // solid surface is fully filled
|
||||
}
|
||||
|
||||
|
||||
@@ -58,23 +58,6 @@ SCENARIO("Flow math for non-bridges", "[Flow]") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if 0
|
||||
/// Check for an edge case in the maths where the spacing could be 0; original
|
||||
/// math is 0.99. Slic3r issue #4654
|
||||
GIVEN("Input spacing of 0.414159 and a total width of 2") {
|
||||
double in_spacing = 0.414159;
|
||||
double total_width = 2.0;
|
||||
auto flow = Flow::new_from_spacing(1.0, 0.4, 0.3);
|
||||
WHEN("solid_spacing() is called") {
|
||||
double result = flow.solid_spacing(total_width, in_spacing);
|
||||
THEN("Yielded spacing is greater than 0") {
|
||||
REQUIRE(result > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
/// Spacing, width calculation for bridge extrusions
|
||||
|
||||
@@ -142,21 +142,6 @@ SCENARIO("z_hop lifts the nozzle when a lift is requested", "[GCodeWriter]") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Origin manipulation", "[GCodeWriter]") {
|
||||
Slic3r::GCode gcodegen;
|
||||
WHEN("set_origin to (10,0)") {
|
||||
gcodegen.set_origin(Vec2d(10,0));
|
||||
REQUIRE(gcodegen.origin() == Vec2d(10, 0));
|
||||
}
|
||||
WHEN("set_origin to (10,0) and translate by (5, 5)") {
|
||||
gcodegen.set_origin(Vec2d(10,0));
|
||||
gcodegen.set_origin(gcodegen.origin() + Vec2d(5, 5));
|
||||
THEN("origin returns reference to point") {
|
||||
REQUIRE(gcodegen.origin() == Vec2d(15,5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A cached config slot is looked up again whenever its key changes", "[GCodeWriter]")
|
||||
{
|
||||
GCode::ConfigIndexCache cache;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
@@ -502,27 +503,3 @@ std::vector<std::string> role_sequence(const std::string &gcode, const std::vect
|
||||
}
|
||||
|
||||
} } // namespace Slic3r::Test
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include "libslic3r/Arrange.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
|
||||
SCENARIO("init_print functionality", "[test_helpers]") {
|
||||
GIVEN("A default config") {
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
WHEN("init_print is called with a single mesh.") {
|
||||
Slic3r::Model model;
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_print({ Slic3r::Test::cube(20) }, print, model, config);
|
||||
THEN("One mesh/printobject is in the resulting Print object.") {
|
||||
REQUIRE(print.objects().size() == 1);
|
||||
}
|
||||
THEN("print.process() doesn't crash.") {
|
||||
REQUIRE_NOTHROW(print.process());
|
||||
}
|
||||
THEN("Export gcode functions outputs text.") {
|
||||
REQUIRE(! Slic3r::Test::gcode(print).empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,24 +327,6 @@ TEST_CASE("A printer with IMEX disabled resolves the mode placeholders to nothin
|
||||
CHECK_FALSE(has_line(gcode, kQuadScript));
|
||||
}
|
||||
|
||||
// The placeholders are set unconditionally, ahead of the is_imex check, so they are defined for
|
||||
// every printer. A single-extruder preset that mentions {imex_mode} must expand it to an empty
|
||||
// string rather than fail the export on an unknown variable.
|
||||
TEST_CASE("IMEX mode placeholders are defined on an ordinary single-extruder printer",
|
||||
"[ImexModeGcode][IMEX]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "skirt_loops", "0" },
|
||||
{ "brim_type", "no_brim" },
|
||||
{ "gcode_flavor", "klipper" },
|
||||
{ "machine_start_gcode", kReportStartGcode },
|
||||
});
|
||||
|
||||
CHECK(has_line(gcode, ";IMEX_MODE:"));
|
||||
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
|
||||
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
|
||||
}
|
||||
|
||||
// In a parallel mode no tool changes occur, so every active carriage has to be addressed
|
||||
// explicitly: once before the print for pressure advance, and again at the second layer for the
|
||||
// drop from the initial-layer temperature. Nothing else in the suite covers either emission, and
|
||||
|
||||
@@ -66,21 +66,6 @@ void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions)
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("enable_mixed_color_sublayer reaches the Print config", "[MixedFilament]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, mixed_config(true));
|
||||
|
||||
// The option lives in PrintConfig; if it did not survive Print::apply the slicer would
|
||||
// silently fall back to the whole-layer path.
|
||||
CHECK(print.config().enable_mixed_color_sublayer.value == true);
|
||||
REQUIRE(print.config().filament_is_mixed.values.size() == 3);
|
||||
CHECK(print.config().filament_is_mixed.values[2] == true);
|
||||
REQUIRE(print.config().filament_mixed_components.values.size() == 3);
|
||||
CHECK(print.config().filament_mixed_components.values[2] == "1,2");
|
||||
}
|
||||
|
||||
TEST_CASE("Mixed filament splits layers into sub-layers when the option is on", "[MixedFilament]")
|
||||
{
|
||||
Print print;
|
||||
|
||||
@@ -840,32 +840,6 @@ static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1ba
|
||||
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
|
||||
}
|
||||
|
||||
// IMEX parallel modes emit per-carriage temperatures from a branch that is mutually
|
||||
// exclusive with the standard per-extruder path, and that branch skipped the head the
|
||||
// print's own toolpaths run on. That head therefore never received its 1st->2nd layer
|
||||
// transition and held nozzle_temperature_initial_layer for the whole job.
|
||||
TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second-layer temperature",
|
||||
"[MultiFilament][IMEX]")
|
||||
{
|
||||
DynamicPrintConfig config = multifilament_config(7);
|
||||
imex_7x4_printer(config);
|
||||
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
|
||||
// The multi-extruder normalization collapses per-filament temperature vectors to a
|
||||
// single value, so heads are told apart by their tool qualifier, not by temperature.
|
||||
config.set_deserialize_strict({
|
||||
{ "imex_parallel_mode", "copy" },
|
||||
{ "nozzle_temperature_initial_layer", "200" },
|
||||
{ "nozzle_temperature", "240" },
|
||||
});
|
||||
|
||||
const std::string gcode = slice({ cube(20) }, config);
|
||||
|
||||
// Head 0 runs the print's own toolpaths and must step 200 -> 240 at the second layer.
|
||||
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
|
||||
// Head 1 is the copy carriage; it already worked and must keep working.
|
||||
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
|
||||
}
|
||||
|
||||
// IMEX supplements is_extruder_used for the secondary carriages a parallel mode drives.
|
||||
// `primary` drives exactly one tool, so the supplement must not run: routing every region
|
||||
// to filament 6 puts the initial tool on physical head 2, while the mode's only declared
|
||||
|
||||
@@ -54,8 +54,6 @@
|
||||
#include <cstdlib>
|
||||
#include <sstream>
|
||||
#include <limits>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
@@ -794,25 +792,6 @@ TEST_CASE("Print statistics in custom G-code match the final print statistics",
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
Slic3r::Test::init_print({cube(20)}, print, model);
|
||||
print.process();
|
||||
|
||||
SECTION("non-BBL printer") {}
|
||||
SECTION("BBL printer") { print.is_BBL_printer() = true; }
|
||||
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
|
||||
|
||||
std::ifstream in(temp.string());
|
||||
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
|
||||
REQUIRE_FALSE(gcode.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Exporting a sliced print again gives the same G-code", "[Print][export_gcode][Regression]")
|
||||
{
|
||||
const int instances = GENERATE(1, 3);
|
||||
@@ -1062,34 +1041,6 @@ TEST_CASE("Belt printers keep the part fan off within the band above the belt",
|
||||
CHECK(clear_fan_off == 0);
|
||||
}
|
||||
|
||||
// Organic supports under an overhang on a belt printer reach below the object's first layer,
|
||||
// where the virtual belt raft layers sit at negative Z. The lowest of them used to get a
|
||||
// negative height and abort slicing with a negative flow error.
|
||||
TEST_CASE("Belt printers slice organic tree supports that reach the belt", "[Print][belt][Support]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "belt_printer", 1 },
|
||||
{ "belt_slice_rotation", "x" },
|
||||
{ "belt_slice_rotation_angle", 45 },
|
||||
{ "gcode_remap_x", "rev_x" },
|
||||
{ "gcode_remap_y", "pos_z" },
|
||||
{ "gcode_remap_z", "pos_y" },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "z_hop", 0 },
|
||||
{ "enable_support", 1 },
|
||||
{ "support_type", "tree(auto)" },
|
||||
{ "support_style", "organic" },
|
||||
{ "machine_start_gcode", "T[initial_tool]\n" },
|
||||
{ "layer_change_gcode", "G92 E0\n" },
|
||||
});
|
||||
std::string gcode;
|
||||
REQUIRE_NOTHROW(gcode = slice({ TestMesh::overhang }, config));
|
||||
CHECK(! gcode.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]")
|
||||
{
|
||||
Print print;
|
||||
|
||||
@@ -837,30 +837,6 @@ TEST_CASE("Belt brim on an object layer precedes its perimeters, once", "[SkirtB
|
||||
CHECK(role_passes(gc, "brim") <= bands);
|
||||
}
|
||||
|
||||
// B - single extruder (filament id 1). Every band must survive the 1-based -> 0-based
|
||||
// filament-id conversion the apron path performs: a wrong conversion drops all single-extruder
|
||||
// bands, so the pass count would collapse. The expected count is derived from the sliced
|
||||
// layers, not a ratio.
|
||||
TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][belt]")
|
||||
{
|
||||
DynamicPrintConfig config = belt_brim_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 4 },
|
||||
{ "brim_object_gap", 0 },
|
||||
});
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({ cube(20) }, print, model, config);
|
||||
const std::string gc = gcode(print);
|
||||
|
||||
const PrintObject &object = *print.objects().front();
|
||||
const int expected = nonempty_belt_brim_layers(object) + belt_brim_apron_bands(object);
|
||||
REQUIRE(expected > 0);
|
||||
CHECK(role_layers(gc, "brim") == expected);
|
||||
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0 }); // filament 1 -> tool 0
|
||||
}
|
||||
|
||||
// Number of brim segments the belt brim generator produced for `object`: the lattice
|
||||
// lines of every per-layer band plus the apron prologue. Each segment is written as one
|
||||
// extruding move, so this is what a G-code count has to match. A pass count cannot see a
|
||||
|
||||
@@ -27,14 +27,6 @@ TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_
|
||||
|
||||
#include "libslic3r/Print.hpp"
|
||||
|
||||
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
|
||||
int calls = 0;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
|
||||
CHECK(calls == 0);
|
||||
}
|
||||
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
#include <cstdlib>
|
||||
#include <cstddef>
|
||||
#include <cmath>
|
||||
#include <future>
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
#include <math.h>
|
||||
#include "libslic3r/ExPolygon.hpp"
|
||||
@@ -59,12 +57,6 @@ SCENARIO("Basic mesh statistics", "[TriangleMesh]") {
|
||||
REQUIRE(cube.facets_count() == facets.size());
|
||||
}
|
||||
|
||||
#if 0
|
||||
THEN( "Number of normals is equal to the number of facets.") {
|
||||
REQUIRE(cube.normals().size() == facets.size());
|
||||
}
|
||||
#endif
|
||||
|
||||
THEN( "center() returns the center of the object.") {
|
||||
REQUIRE(cube.center() == Vec3d(10.0,10.0,10.0));
|
||||
}
|
||||
@@ -265,20 +257,6 @@ SCENARIO("Split functionality", "[TriangleMesh]") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Mesh merge functions", "[TriangleMesh]") {
|
||||
GIVEN( "Two 20mm cubes, each with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
TriangleMesh cube2(cube);
|
||||
|
||||
WHEN( "The two meshes are merged") {
|
||||
cube.merge(cube2);
|
||||
THEN( "There are twice as many facets in the merged mesh as the original.") {
|
||||
REQUIRE(cube.facets_count() == 2 * cube2.facets_count());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Cut behavior", "[TriangleMesh]") {
|
||||
GIVEN( "A 20mm cube with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
@@ -306,53 +284,3 @@ SCENARIO("Cut behavior", "[TriangleMesh]") {
|
||||
}
|
||||
}
|
||||
}
|
||||
#ifdef TEST_PERFORMANCE
|
||||
TEST_CASE("Large mesh slices within the time budget (#4486)", "[TriangleMesh][Regression]") {
|
||||
TriangleMesh mesh;
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
mesh.ReadSTLFile(std::string(testfile_dir) + "test_trianglemesh/4486/100_000.stl");
|
||||
|
||||
config.set("layer_height", 500);
|
||||
config.set("first_layer_height", 250);
|
||||
config.set("nozzle_diameter", 500);
|
||||
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({mesh}, print, model, config);
|
||||
|
||||
print.status_cb = [] (int ln, const std::string& msg) { Slic3r::Log::info("Print") << ln << " " << msg << "\n";};
|
||||
|
||||
std::future<void> fut = std::async([&print] () { print.process(); });
|
||||
std::chrono::milliseconds span {120000};
|
||||
bool timedout {false};
|
||||
if(fut.wait_for(span) == std::future_status::timeout) {
|
||||
timedout = true;
|
||||
}
|
||||
REQUIRE(timedout == false);
|
||||
|
||||
}
|
||||
#endif // TEST_PERFORMANCE
|
||||
|
||||
#ifdef BUILD_PROFILE
|
||||
TEST_CASE("Large mesh slicing profile (#4486)", "[TriangleMesh][Profile]") {
|
||||
TriangleMesh mesh;
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
mesh.ReadSTLFile(std::string(testfile_dir) + "test_trianglemesh/4486/10_000.stl");
|
||||
|
||||
config.set("layer_height", 500);
|
||||
config.set("first_layer_height", 250);
|
||||
config.set("nozzle_diameter", 500);
|
||||
config.set("fill_density", "5%");
|
||||
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({mesh}, print, model, config);
|
||||
|
||||
print.status_cb = [] (int ln, const std::string& msg) { Slic3r::Log::info("Print") << ln << " " << msg << "\n";};
|
||||
|
||||
print.process();
|
||||
|
||||
REQUIRE(true);
|
||||
|
||||
}
|
||||
#endif //BUILD_PROFILE
|
||||
@@ -348,21 +348,6 @@ TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wi
|
||||
CHECK_FALSE(trajectories_differ(regular_paths, inward_paths));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
|
||||
const std::vector<Vec2d> inward = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true)));
|
||||
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(inward.empty());
|
||||
REQUIRE(trajectories_differ(regular, inward));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
|
||||
@@ -1647,69 +1647,6 @@ SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") {
|
||||
}
|
||||
}
|
||||
|
||||
// Locks the serialization contract of the orca_published_material_keys metadata: the per-entry JSON
|
||||
// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like orca_published_keys.
|
||||
SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") {
|
||||
GIVEN("a model carrying published material keys metadata") {
|
||||
Model model;
|
||||
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
||||
REQUIRE(load_stl(src_file.c_str(), &model));
|
||||
model.add_default_instances();
|
||||
|
||||
const std::string material_keys_json =
|
||||
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])";
|
||||
|
||||
model.model_info = std::make_shared<ModelInfo>();
|
||||
model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json;
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_pub_mat");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
store_params.model = &model;
|
||||
store_params.config = &config;
|
||||
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
REQUIRE(store_bbs_3mf(store_params));
|
||||
|
||||
Model dst_model;
|
||||
DynamicPrintConfig dst_config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs dst_plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
THEN("the published material keys metadata round-trips unchanged") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(dst_model.model_info != nullptr);
|
||||
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
||||
|
||||
// The value must parse back to one material entry carrying the nested identity
|
||||
// object, the author slot ordinal and the key list.
|
||||
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
|
||||
REQUIRE(entries.is_array());
|
||||
REQUIRE(entries.size() == 1);
|
||||
REQUIRE(entries[0]["material"]["filament_type"] == "PLA");
|
||||
REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic");
|
||||
REQUIRE(entries[0]["material"]["filament_id"] == "GFL99");
|
||||
REQUIRE(entries[0]["slot"] == 0);
|
||||
REQUIRE(entries[0]["keys"].is_array());
|
||||
REQUIRE(entries[0]["keys"].size() == 2);
|
||||
REQUIRE(entries[0]["keys"][0] == "filament_retraction_length");
|
||||
}
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") {
|
||||
GIVEN("a multi-instance model carrying published metadata and a published_config payload") {
|
||||
Model model;
|
||||
|
||||
@@ -159,15 +159,6 @@ TEST_CASE("overlap_area detects overlap and ignores touching edges", "[Arrange]"
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Arrange places every item on the physical bed", "[Arrange]")
|
||||
{
|
||||
ArrangePolygons items = squares(5, 20.);
|
||||
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
|
||||
|
||||
for (const ArrangePolygon &ap : items)
|
||||
REQUIRE(ap.bed_idx == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Arranged items stay within the bed", "[Arrange]")
|
||||
{
|
||||
ArrangePolygons items = squares(6, 30.);
|
||||
|
||||
@@ -2061,49 +2061,6 @@ TEST_CASE("mass properties: invalid body index", "[CadDocument]")
|
||||
REQUIRE_FALSE(mp3.valid);
|
||||
}
|
||||
|
||||
TEST_CASE("serialize_recipe roundtrip with two bodies", "[CadDocument]")
|
||||
{
|
||||
using Catch::Matchers::WithinRel;
|
||||
CadDocument doc;
|
||||
|
||||
// Body 1: rectangle sketch + extrude + fillet
|
||||
int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
||||
20, 20, 10, "Rect1");
|
||||
REQUIRE(sk1 >= 0);
|
||||
doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Extrude1");
|
||||
doc.add_fillet(2.0, FaceGroup::All, "Fillet1");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
// Body 2: circle sketch + extrude (separate New body)
|
||||
SketchEntity circ;
|
||||
circ.type = SketchEntity::Type::Circle;
|
||||
circ.center = Vec2d(0, 0);
|
||||
circ.radius = 8.0;
|
||||
int sk2 = doc.add_sketch_entities({circ}, SketchPlane::XZ(), "Circle2");
|
||||
doc.add_extrude(sk2, 6.0, false, BooleanMode::New, "Extrude2");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
REQUIRE(doc.bodies.size() >= 2);
|
||||
std::vector<double> orig_vols;
|
||||
for (const auto& b : doc.bodies)
|
||||
orig_vols.push_back(double(SketchEngine::tessellate(b.shape).volume()));
|
||||
|
||||
auto blob = doc.serialize_recipe();
|
||||
REQUIRE_FALSE(blob.empty());
|
||||
|
||||
CadDocument doc2;
|
||||
REQUIRE(doc2.deserialize_recipe(blob));
|
||||
REQUIRE(doc2.error.empty());
|
||||
REQUIRE(doc2.bodies.size() == doc.bodies.size());
|
||||
|
||||
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
||||
double v2 = double(SketchEngine::tessellate(doc2.bodies[i].shape).volume());
|
||||
REQUIRE_THAT(v2, WithinRel(orig_vols[i], 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("deserialize_recipe rejects future version with error", "[CadDocument]")
|
||||
{
|
||||
CadDocument doc;
|
||||
@@ -2147,41 +2104,6 @@ TEST_CASE("deserialize_recipe handles empty blob without throwing", "[CadDocumen
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("deserialize_recipe error is non-empty on every failure path", "[CadDocument]")
|
||||
{
|
||||
CadDocument doc;
|
||||
auto reset = [&]() { doc = CadDocument{}; };
|
||||
|
||||
// too new
|
||||
{
|
||||
std::ostringstream oss;
|
||||
{ cereal::BinaryOutputArchive ar(oss); uint32_t v = 999; ar(v); }
|
||||
reset();
|
||||
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
// too old
|
||||
{
|
||||
std::ostringstream oss;
|
||||
{ cereal::BinaryOutputArchive ar(oss); uint32_t v = 1; ar(v); }
|
||||
reset();
|
||||
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
// truncated / garbage
|
||||
{
|
||||
reset();
|
||||
REQUIRE_FALSE(doc.deserialize_recipe("not a valid blob \x00\x01\x02"));
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
// empty
|
||||
{
|
||||
reset();
|
||||
REQUIRE_FALSE(doc.deserialize_recipe(""));
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("a v4 project still opens", "[CadDocument][recipe]")
|
||||
{
|
||||
// The regression guard for everyone who already has projects: a pre-framing v4
|
||||
@@ -4209,25 +4131,6 @@ static CadDocument make_golden_doc_v1()
|
||||
return doc;
|
||||
}
|
||||
|
||||
TEST_CASE("regenerate golden recipe fixture", "[.regen]")
|
||||
{
|
||||
CadDocument doc = make_golden_doc_v1();
|
||||
// ponytail: serialize_recipe() only needs features, recompute is unnecessary
|
||||
// for a fixture that exercises the serialization format.
|
||||
auto blob = doc.serialize_recipe();
|
||||
REQUIRE_FALSE(blob.empty());
|
||||
|
||||
// Version-free on purpose: this writes whatever today's format is, so a version bump does
|
||||
// not have to remember to edit it. The version-stamped fixtures beside it are frozen
|
||||
// evidence from older builds and are never regenerated.
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_current.bin";
|
||||
std::ofstream ofs(path, std::ios::binary);
|
||||
REQUIRE(ofs.is_open());
|
||||
ofs.write(blob.data(), static_cast<std::streamsize>(blob.size()));
|
||||
ofs.close();
|
||||
SUCCEED("Fixture written to " << path);
|
||||
}
|
||||
|
||||
// The previous format's real blob, kept on disk deliberately. It is the only thing that can
|
||||
// prove the version gate does its job: a v3 recipe carries FEWER fields per feature than this
|
||||
// build reads, so without the bump to v4 it would have passed the gate and had two ints read
|
||||
@@ -5692,45 +5595,6 @@ TEST_CASE("surface-loft round-trip serialize/deserialize", "[CadDocument][surfac
|
||||
|
||||
// --- Mate tests (M8a) ---
|
||||
|
||||
TEST_CASE("fastened mate with zero offset/angle", "[CadDocument][mate]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
CadDocument doc;
|
||||
int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
||||
doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
||||
doc.features[cs_fixed].coordsys_body = 0;
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
||||
doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == 2);
|
||||
|
||||
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
||||
doc.features[cs_moving].coordsys_body = 1;
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
int mi = doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate");
|
||||
REQUIRE(mi >= 0);
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
GProp_GProps props;
|
||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
||||
gp_Pnt com = props.CentreOfMass();
|
||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0, 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("fastened mate with offset", "[CadDocument][mate]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
@@ -5927,30 +5791,6 @@ TEST_CASE("mate round-trip serialization", "[CadDocument][mate]")
|
||||
REQUIRE(mf->mate_flip == true);
|
||||
}
|
||||
|
||||
TEST_CASE("version 2 blob is rejected", "[CadDocument][mate]")
|
||||
{
|
||||
|
||||
CadDocument doc;
|
||||
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
||||
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
std::ostringstream oss;
|
||||
{
|
||||
cereal::BinaryOutputArchive ar(oss);
|
||||
uint32_t fake_v = 2;
|
||||
ar(fake_v);
|
||||
ar(doc.features);
|
||||
ar(doc.variables);
|
||||
}
|
||||
std::string blob = oss.str();
|
||||
|
||||
CadDocument fresh;
|
||||
REQUIRE_FALSE(fresh.deserialize_recipe(blob));
|
||||
REQUIRE_CONTAINS(fresh.error, "older version");
|
||||
}
|
||||
|
||||
TEST_CASE("mate error: out of range connectors", "[CadDocument][mate]")
|
||||
{
|
||||
|
||||
@@ -7693,17 +7533,6 @@ TEST_CASE("two holes are both subtracted", "[CadDocument][sketchwire]")
|
||||
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01));
|
||||
}
|
||||
|
||||
TEST_CASE("a lone circle still extrudes exactly as before", "[CadDocument][sketchwire]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { circle_entity({0, 0}, 8.0) };
|
||||
CadDocument doc = plate_doc(ents, 5.0);
|
||||
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
const double expected = M_PI * 64.0 * 5.0;
|
||||
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01));
|
||||
}
|
||||
|
||||
TEST_CASE("a closed polygon still extrudes exactly as before", "[CadDocument][sketchwire]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = rect_entities(20, 20);
|
||||
|
||||
@@ -155,51 +155,6 @@ SCENARIO("Various Clipper operations - xs/t/11_clipper.t", "[ClipperUtils]") {
|
||||
REQUIRE(result.front().length() == Catch::Approx(subject.length()));
|
||||
}
|
||||
}
|
||||
|
||||
#if 0
|
||||
{
|
||||
# Clipper does not preserve polyline orientation
|
||||
my $polyline = Slic3r::Polyline->new([50, 150], [300, 150]);
|
||||
my $result = Slic3r::Geometry::Clipper::intersection_pl([$polyline], [$square]);
|
||||
is scalar(@$result), 1, 'intersection_pl - correct number of result lines';
|
||||
is_deeply $result->[0]->pp, [[100, 150], [200, 150]], 'clipped line orientation is preserved';
|
||||
}
|
||||
{
|
||||
# Clipper does not preserve polyline orientation
|
||||
my $polyline = Slic3r::Polyline->new([300, 150], [50, 150]);
|
||||
my $result = Slic3r::Geometry::Clipper::intersection_pl([$polyline], [$square]);
|
||||
is scalar(@$result), 1, 'intersection_pl - correct number of result lines';
|
||||
is_deeply $result->[0]->pp, [[200, 150], [100, 150]], 'clipped line orientation is preserved';
|
||||
}
|
||||
{
|
||||
# Disabled until Clipper bug #127 is fixed
|
||||
my $subject = [
|
||||
Slic3r::Polyline->new([-90000000, -100000000], [-90000000, 100000000]), # vertical
|
||||
Slic3r::Polyline->new([-100000000, -10000000], [100000000, -10000000]), # horizontal
|
||||
Slic3r::Polyline->new([-100000000, 0], [100000000, 0]), # horizontal
|
||||
Slic3r::Polyline->new([-100000000, 10000000], [100000000, 10000000]), # horizontal
|
||||
];
|
||||
my $clip = Slic3r::Polygon->new(# a circular, convex, polygon
|
||||
[99452190, 10452846], [97814760, 20791169], [95105652, 30901699], [91354546, 40673664], [86602540, 50000000],
|
||||
[80901699, 58778525], [74314483, 66913061], [66913061, 74314483], [58778525, 80901699], [50000000, 86602540],
|
||||
[40673664, 91354546], [30901699, 95105652], [20791169, 97814760], [10452846, 99452190], [0, 100000000],
|
||||
[-10452846, 99452190], [-20791169, 97814760], [-30901699, 95105652], [-40673664, 91354546],
|
||||
[-50000000, 86602540], [-58778525, 80901699], [-66913061, 74314483], [-74314483, 66913061],
|
||||
[-80901699, 58778525], [-86602540, 50000000], [-91354546, 40673664], [-95105652, 30901699],
|
||||
[-97814760, 20791169], [-99452190, 10452846], [-100000000, 0], [-99452190, -10452846],
|
||||
[-97814760, -20791169], [-95105652, -30901699], [-91354546, -40673664], [-86602540, -50000000],
|
||||
[-80901699, -58778525], [-74314483, -66913061], [-66913061, -74314483], [-58778525, -80901699],
|
||||
[-50000000, -86602540], [-40673664, -91354546], [-30901699, -95105652], [-20791169, -97814760],
|
||||
[-10452846, -99452190], [0, -100000000], [10452846, -99452190], [20791169, -97814760],
|
||||
[30901699, -95105652], [40673664, -91354546], [50000000, -86602540], [58778525, -80901699],
|
||||
[66913061, -74314483], [74314483, -66913061], [80901699, -58778525], [86602540, -50000000],
|
||||
[91354546, -40673664], [95105652, -30901699], [97814760, -20791169], [99452190, -10452846], [100000000, 0]
|
||||
);
|
||||
my $result = Slic3r::Geometry::Clipper::intersection_pl($subject, [$clip]);
|
||||
is scalar(@$result), scalar(@$subject), 'intersection_pl - expected number of polylines';
|
||||
is sum(map scalar(@$_), @$result), scalar(@$subject) * 2, 'intersection_pl - expected number of points in polylines';
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
SCENARIO("Various Clipper operations - t/clipper.t", "[ClipperUtils]") {
|
||||
|
||||
@@ -643,45 +643,6 @@ SCENARIO("update_non_diff_values_to_base_config keeps a project's changed values
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO("DynamicPrintConfig JSON serialization", "[Config]") {
|
||||
// WHEN("DynamicPrintConfig is serialized and deserialized") {
|
||||
// auto now = std::chrono::high_resolution_clock::now();
|
||||
// auto timestamp = now.time_since_epoch().count();
|
||||
// std::stringstream ss;
|
||||
// ss << "catch_test_serialization_" << timestamp << ".json";
|
||||
// std::string filename = (fs::temp_directory_path() / ss.str()).string();
|
||||
|
||||
// TODO: Finish making a unit test for JSON serialization
|
||||
// FullPrintConfig full_print_config;
|
||||
// DynamicPrintConfig cfg;
|
||||
// cfg.apply(full_print_config, false);
|
||||
|
||||
// std::string serialized;
|
||||
// try {
|
||||
// std::ostringstream ss;
|
||||
// cereal::BinaryOutputArchive oarchive(ss);
|
||||
// oarchive(cfg);
|
||||
// serialized = ss.str();
|
||||
// } catch (const std::runtime_error & /* e */) {
|
||||
// // e.what();
|
||||
// }
|
||||
// CAPTURE(serialized.length());
|
||||
|
||||
// THEN("Config object contains ini file options.") {
|
||||
// DynamicPrintConfig cfg2;
|
||||
// try {
|
||||
// std::stringstream ss(serialized);
|
||||
// cereal::BinaryInputArchive iarchive(ss);
|
||||
// iarchive(cfg2);
|
||||
// } catch (const std::runtime_error & /* e */) {
|
||||
// // e.what();
|
||||
// }
|
||||
// CAPTURE(cfg.diff_report(cfg2));
|
||||
// REQUIRE(cfg == cfg2);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
|
||||
ScopedTemporaryFile tmp(".json");
|
||||
const std::vector<std::string> refs = {
|
||||
|
||||
@@ -17,10 +17,6 @@ using namespace Slic3r;
|
||||
|
||||
// #define TESTS_EXPORT_SVGS
|
||||
|
||||
namespace Slic3r {
|
||||
Points mittered_offset_path_scaled(const Points& contour, const std::vector<float>& deltas, double miter_limit);
|
||||
}
|
||||
|
||||
static ExPolygon spirograph_gear_1mm()
|
||||
{
|
||||
ExPolygon out;
|
||||
@@ -479,33 +475,6 @@ SCENARIO("Elephant foot compensation", "[ElephantFoot]") {
|
||||
}
|
||||
}
|
||||
|
||||
#if 0
|
||||
GIVEN("Varying inner offset") {
|
||||
ExPolygon input = spirograph_gear_1mm().simplify(SCALED_EPSILON).front();
|
||||
ExPolygon output;
|
||||
std::vector<float> deltas(input.contour.points.size(), scale_(1.));
|
||||
// mittered_offset_path_scaled_points is commented out somewhere above
|
||||
output.contour.points = Slic3r::mittered_offset_path_scaled_points(input.contour.points, deltas, 2.);
|
||||
#ifdef TESTS_EXPORT_SVGS
|
||||
{
|
||||
SVG svg(debug_out_path("elephant_foot_compensation_0.svg").c_str(), get_extents(output));
|
||||
svg.draw(input, "blue");
|
||||
svg.draw_outline(output, "black", coord_t(scale_(0.01)));
|
||||
}
|
||||
#endif /* TESTS_EXPORT_SVGS */
|
||||
for (size_t i = 0; i <= deltas.size() / 2; ++ i)
|
||||
deltas[i] = deltas[deltas.size() - i - 1] = scale_(1.) * double(i) / (0.5 * double(deltas.size()));
|
||||
output.contour.points = Slic3r::mittered_offset_path_scaled_points(input.contour.points, deltas, 2.);
|
||||
#ifdef TESTS_EXPORT_SVGS
|
||||
{
|
||||
SVG svg(debug_out_path("elephant_foot_compensation_varying.svg").c_str(), get_extents(output));
|
||||
svg.draw(input, "blue");
|
||||
svg.draw_outline(output, "black", coord_t(scale_(0.01)));
|
||||
}
|
||||
#endif /* TESTS_EXPORT_SVGS */
|
||||
}
|
||||
#endif
|
||||
|
||||
GIVEN("Rectangle with a narrow part sticking out") {
|
||||
// Rectangle
|
||||
ExPolygon expoly;
|
||||
|
||||
@@ -208,20 +208,3 @@ TEST_CASE("Octagram spiral smooth factor controls corner curvature", "[FillPlane
|
||||
REQUIRE(path_length(half_smooth) < path_length(sharp));
|
||||
REQUIRE(invalid_factor == sharp);
|
||||
}
|
||||
|
||||
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
|
||||
{
|
||||
const Points sharp = TestableHilbertCurve().generate_points(0.005);
|
||||
const Points half_smooth = TestableHilbertCurve().generate_points(0.005, 0.5);
|
||||
const Points full_smooth = TestableHilbertCurve().generate_points(0.005, 1.);
|
||||
const Points invalid_factor = TestableHilbertCurve().generate_points(
|
||||
0.005, std::numeric_limits<double>::quiet_NaN());
|
||||
|
||||
REQUIRE(full_smooth.front() == half_smooth.front());
|
||||
REQUIRE(full_smooth.back() == half_smooth.back());
|
||||
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
|
||||
REQUIRE(invalid_factor == sharp);
|
||||
|
||||
for (size_t i = 1; i < full_smooth.size(); ++i)
|
||||
REQUIRE((full_smooth[i] - full_smooth[i - 1]).squaredNorm() > 0);
|
||||
}
|
||||
@@ -18,8 +18,6 @@
|
||||
#include "libslic3r/ClipperUtils.hpp"
|
||||
#include "libslic3r/ShortestPath.hpp"
|
||||
|
||||
//#include <random>
|
||||
//#include "libnest2d/tools/benchmark.h"
|
||||
#include "libslic3r/SVG.hpp"
|
||||
|
||||
#include "../libnest2d/printer_parts.hpp"
|
||||
@@ -605,73 +603,6 @@ TEST_CASE("Convex polygon intersection on two overlapping squares", "[Geometry][
|
||||
REQUIRE(is_inters == true);
|
||||
}
|
||||
|
||||
//// Only for benchmarking
|
||||
//static Polygon gen_convex_poly(std::mt19937_64 &rg, size_t point_cnt)
|
||||
//{
|
||||
// std::uniform_int_distribution<coord_t> dist(0, 100);
|
||||
|
||||
// Polygon out;
|
||||
// out.points.reserve(point_cnt);
|
||||
|
||||
// coord_t tr = dist(rg) * 2 / SCALING_FACTOR;
|
||||
|
||||
// for (size_t i = 0; i < point_cnt; ++i)
|
||||
// out.points.emplace_back(tr + dist(rg) / SCALING_FACTOR,
|
||||
// tr + dist(rg) / SCALING_FACTOR);
|
||||
|
||||
// return Geometry::convex_hull(out.points);
|
||||
//}
|
||||
//TEST_CASE("Convex polygon intersection test on random polygons", "[Geometry]") {
|
||||
// constexpr size_t TEST_CNT = 1000;
|
||||
// constexpr size_t POINT_CNT = 1000;
|
||||
|
||||
// auto seed = std::random_device{}();
|
||||
//// unsigned long seed = 2525634386;
|
||||
// std::mt19937_64 rg{seed};
|
||||
// Benchmark bench;
|
||||
|
||||
// auto tests = reserve_vector<std::pair<Polygon, Polygon>>(TEST_CNT);
|
||||
// auto results = reserve_vector<bool>(TEST_CNT);
|
||||
// auto expects = reserve_vector<bool>(TEST_CNT);
|
||||
|
||||
// for (size_t i = 0; i < TEST_CNT; ++i) {
|
||||
// tests.emplace_back(gen_convex_poly(rg, POINT_CNT), gen_convex_poly(rg, POINT_CNT));
|
||||
// }
|
||||
|
||||
// bench.start();
|
||||
// for (const auto &test : tests)
|
||||
// results.emplace_back(Geometry::convex_polygons_intersect(test.first, test.second));
|
||||
// bench.stop();
|
||||
|
||||
// std::cout << "Test time: " << bench.getElapsedSec() << std::endl;
|
||||
|
||||
// bench.start();
|
||||
// for (const auto &test : tests)
|
||||
// expects.emplace_back(!intersection(test.first, test.second).empty());
|
||||
// bench.stop();
|
||||
|
||||
// std::cout << "Clipper time: " << bench.getElapsedSec() << std::endl;
|
||||
|
||||
// REQUIRE(results.size() == expects.size());
|
||||
|
||||
// auto seedstr = std::to_string(seed);
|
||||
// for (size_t i = 0; i < results.size(); ++i) {
|
||||
// // std::cout << expects[i] << " ";
|
||||
|
||||
// if (results[i] != expects[i]) {
|
||||
// SVG svg{std::string("fail_seed") + seedstr + "_" + std::to_string(i) + ".svg"};
|
||||
// svg.draw(tests[i].first, "blue");
|
||||
// svg.draw(tests[i].second, "green");
|
||||
// svg.Close();
|
||||
|
||||
// // std::cout << std::endl;
|
||||
// }
|
||||
// REQUIRE(results[i] == expects[i]);
|
||||
// }
|
||||
// std::cout << std::endl;
|
||||
|
||||
//}
|
||||
|
||||
struct Pair
|
||||
{
|
||||
size_t first, second;
|
||||
|
||||
@@ -71,15 +71,6 @@ TEST_CASE("effective_physical_extruder_map - degenerate nozzle count still yield
|
||||
REQUIRE(effective_physical_extruder_map(nullptr, 0).values == std::vector<int>{0});
|
||||
}
|
||||
|
||||
TEST_CASE("effective_physical_extruder_map - IDEX ghost-color regression guard", "[IMEX]") {
|
||||
// Dual extruder, no authored map. Before the GUI fix this produced a black ghost on T1
|
||||
// because first_filament_for_physical_head({0}, 1) == -1.
|
||||
auto default_pem = make_pem({0});
|
||||
auto pem = effective_physical_extruder_map(&default_pem, 2);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 1) == 1); // no longer -1
|
||||
}
|
||||
|
||||
TEST_CASE("imex_pem_tool_for - non-parallel mode returns -1", "[IMEX]") {
|
||||
// Empty mode string (not in IMEX) and "primary" (IMEX present but not parallel) both
|
||||
// short-circuit to bare-firmware PA/temp emission.
|
||||
@@ -1058,12 +1049,6 @@ TEST_CASE("compute_imex_slice_offset - flag on + copy mode returns primary zone
|
||||
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("compute_imex_slice_offset - flag on + mirror mode returns primary zone center", "[IMEX]") {
|
||||
auto z = compute_imex_slice_offset(true, "mirror", kPrimaryZoneCenteredAt60_40);
|
||||
REQUIRE_THAT(z.x(), WithinAbs(60.0, 1e-9));
|
||||
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("compute_imex_slice_offset - user-defined non-primary mode names trigger shift", "[IMEX]") {
|
||||
// Mode names are user-defined; anything not "primary" or "" should activate.
|
||||
auto z = compute_imex_slice_offset(true, "iq-copy", kPrimaryZoneCenteredAt60_40);
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/libslic3r.h"
|
||||
#include <limits>
|
||||
#include <random>
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
@@ -117,68 +116,6 @@ TEST_CASE("Split two watertight meshes", "[its_split][its]") {
|
||||
}
|
||||
|
||||
#include <libslic3r/QuadricEdgeCollapse.hpp>
|
||||
static float triangle_area(const Vec3f &v0, const Vec3f &v1, const Vec3f &v2)
|
||||
{
|
||||
Vec3f ab = v1 - v0;
|
||||
Vec3f ac = v2 - v0;
|
||||
return ab.cross(ac).norm() / 2.f;
|
||||
}
|
||||
|
||||
static float triangle_area(const stl_triangle_vertex_indices &triangle_indices, const std::vector<Vec3f> &vertices)
|
||||
{
|
||||
return triangle_area(vertices[triangle_indices[0]],
|
||||
vertices[triangle_indices[1]],
|
||||
vertices[triangle_indices[2]]);
|
||||
}
|
||||
|
||||
#if 0
|
||||
// clang complains about unused functions
|
||||
static std::mt19937 create_random_generator() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
return gen;
|
||||
}
|
||||
#endif
|
||||
|
||||
std::vector<Vec3f> its_sample_surface(const indexed_triangle_set &its,
|
||||
double sample_per_mm2,
|
||||
std::mt19937 random_generator) // = create_random_generator())
|
||||
{
|
||||
std::vector<Vec3f> samples;
|
||||
std::uniform_real_distribution<float> rand01(0.f, 1.f);
|
||||
for (const auto &triangle_indices : its.indices) {
|
||||
float area = triangle_area(triangle_indices, its.vertices);
|
||||
float countf;
|
||||
float fractional = std::modf(area * sample_per_mm2, &countf);
|
||||
int count = static_cast<int>(countf);
|
||||
|
||||
float generate = rand01(random_generator);
|
||||
if (generate < fractional) ++count;
|
||||
if (count == 0) continue;
|
||||
|
||||
const Vec3f &v0 = its.vertices[triangle_indices[0]];
|
||||
const Vec3f &v1 = its.vertices[triangle_indices[1]];
|
||||
const Vec3f &v2 = its.vertices[triangle_indices[2]];
|
||||
for (int c = 0; c < count; c++) {
|
||||
// barycentric coordinate
|
||||
Vec3f b;
|
||||
b[0] = rand01(random_generator);
|
||||
b[1] = rand01(random_generator);
|
||||
if ((b[0] + b[1]) > 1.f) {
|
||||
b[0] = 1.f - b[0];
|
||||
b[1] = 1.f - b[1];
|
||||
}
|
||||
b[2] = 1.f - b[0] - b[1];
|
||||
Vec3f pos;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
pos[i] = b[0] * v0[i] + b[1] * v1[i] + b[2] * v2[i];
|
||||
}
|
||||
samples.push_back(pos);
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
|
||||
#include "libslic3r/AABBTreeIndirect.hpp"
|
||||
|
||||
|
||||
@@ -261,16 +261,6 @@ TEST_CASE("snake_core produces valid paths on ring", "[Snake]") {
|
||||
REQUIRE(is_permutation(p2, centers.size()));
|
||||
}
|
||||
|
||||
// --- Core Strategy Tests: Random Layout ---
|
||||
|
||||
TEST_CASE("snake_core produces valid paths on random input", "[Snake]") {
|
||||
Points centers = make_random_16();
|
||||
|
||||
auto p2 = snake_core(centers);
|
||||
|
||||
REQUIRE(is_permutation(p2, centers.size()));
|
||||
}
|
||||
|
||||
// --- Quality Comparison Tests ---
|
||||
|
||||
TEST_CASE("snake has no crossings on random input", "[Snake]") {
|
||||
|
||||
@@ -730,25 +730,6 @@ TEST_CASE("Neighboring vertices distinguish repeated anchors on different lobes"
|
||||
CHECK(edges == std::vector<size_t>{0, 2, 3, 4, 6, 7});
|
||||
}
|
||||
|
||||
TEST_CASE("Modifier holes subtract coverage while separate components add intervals", "[PreciseSeam][SegmentExtraction]")
|
||||
{
|
||||
const Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
ExPolygon area(rectangle(1, -3, 10, 3));
|
||||
area.holes.push_back(rectangle(4, -1, 7, 1));
|
||||
area.holes.back().reverse();
|
||||
const auto result = PreciseSeam::extract_perimeter_segments(PreciseSeam::PreparedPerimeter(perimeter),
|
||||
PreciseSeam::prepare_modifier_regions({area, ExPolygon(rectangle(14, -3, 18, 3))}), ModelVolumeType::PRECISE_SEAM_CENTER);
|
||||
check_provenance(perimeter, result);
|
||||
REQUIRE(result.segments.size() == 3);
|
||||
const std::vector<Point> starts{mm(1, 0), mm(7, 0), mm(14, 0)};
|
||||
const std::vector<Point> ends{mm(4, 0), mm(10, 0), mm(18, 0)};
|
||||
for (size_t i = 0; i < 3; ++i) {
|
||||
CHECK(result.segments[i].polyline.points.front() == starts[i]);
|
||||
CHECK(result.segments[i].polyline.points.back() == ends[i]);
|
||||
}
|
||||
CHECK_FALSE(result.full_containment);
|
||||
}
|
||||
|
||||
TEST_CASE("Full coverage is distinct from an empty or point-only intersection", "[PreciseSeam][SegmentExtraction]")
|
||||
{
|
||||
const bool reverse = GENERATE(false, true);
|
||||
|
||||
@@ -1219,6 +1219,24 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
|
||||
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("The selected preset is its own base when its parent is not loaded", "[Preset]")
|
||||
{
|
||||
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||
add_inmemory_preset(printers, "Printer");
|
||||
add_inmemory_preset(printers, "User Printer", "Printer");
|
||||
add_inmemory_preset(printers, "Orphan Printer", "Uninstalled Vendor Printer");
|
||||
|
||||
printers.select_preset_by_name("User Printer", true);
|
||||
REQUIRE(printers.get_selected_preset_name() == "User Printer");
|
||||
CHECK(printers.get_selected_preset_base().name == "Printer");
|
||||
|
||||
printers.select_preset_by_name("Orphan Printer", true);
|
||||
REQUIRE(printers.get_selected_preset_name() == "Orphan Printer");
|
||||
REQUIRE(printers.get_preset_base(printers.get_selected_preset()) == nullptr);
|
||||
CHECK(printers.get_selected_preset_base().name == "Orphan Printer");
|
||||
}
|
||||
|
||||
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
|
||||
@@ -239,30 +239,6 @@ TEST_CASE("DistanceX on two Points binds real roles with non-negative prefill",
|
||||
REQUIRE(p.defs[0].eb == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Horizontal on a Point rejects with NeedALine", "[SketchConstraints]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { mk_point(1, 2) };
|
||||
ConstraintPlan p = plan_entity_constraint(ents, 0, -1, -1, SketchConstraintType::Horizontal);
|
||||
REQUIRE(p.kind == ConstraintPlan::Kind::Reject);
|
||||
REQUIRE(p.reason == ConstraintReject::NeedALine);
|
||||
}
|
||||
|
||||
TEST_CASE("Angle on two Circles rejects with NeedTwoLines", "[SketchConstraints]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 1) };
|
||||
ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Angle);
|
||||
REQUIRE(p.kind == ConstraintPlan::Kind::Reject);
|
||||
REQUIRE(p.reason == ConstraintReject::NeedTwoLines);
|
||||
}
|
||||
|
||||
TEST_CASE("Parallel on a Line + Circle rejects with NeedTwoLines (new guard)", "[SketchConstraints]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) };
|
||||
ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Parallel);
|
||||
REQUIRE(p.kind == ConstraintPlan::Kind::Reject);
|
||||
REQUIRE(p.reason == ConstraintReject::NeedTwoLines);
|
||||
}
|
||||
|
||||
TEST_CASE("Equal on two Circles promotes to EqualRadius", "[SketchConstraints]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 2) };
|
||||
|
||||
@@ -44,17 +44,6 @@ SCENARIO("Reading an STL file", "[stl]") {
|
||||
REQUIRE(is_approx(model.objects.front()->volumes.front()->mesh().size(), Vec3d(20, 20, 20)));
|
||||
}
|
||||
}
|
||||
#if 0
|
||||
// ASCII STLs ending with just carriage returns are not supported. These were used by the old Macs, while the Unix based MacOS uses LFs as any other Unix.
|
||||
WHEN("line endings CR") {
|
||||
Slic3r::Model model;
|
||||
THEN("load should succeed") {
|
||||
REQUIRE(Slic3r::load_stl(stl_path("ASCII/20mmbox-CR.stl").c_str(), &model));
|
||||
REQUIRE(is_approx(model.objects.front()->volumes.front()->mesh().size(), Vec3d(20, 20, 20)));
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
WHEN("nonstandard STL file (text after ending tags, invalid normals, for example infinities)") {
|
||||
Slic3r::Model model;
|
||||
THEN("load should succeed") {
|
||||
|
||||
@@ -3285,33 +3285,6 @@ TEST_CASE("a ModelUV layer falls back to Triplanar when the volume carries no UV
|
||||
CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f));
|
||||
}
|
||||
|
||||
TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]")
|
||||
{
|
||||
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
|
||||
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.depth_mm = 1.0f;
|
||||
layer.tiling_scale = 5.0f;
|
||||
layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give...
|
||||
layer.color_enabled = false; // ...but is not asked for it
|
||||
|
||||
TextureDisplacementFacetsData facets{};
|
||||
facets[0] = paint_whole_mesh(cube);
|
||||
|
||||
std::vector<uint8_t> triangle_color;
|
||||
TextureColorRequest request;
|
||||
request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked
|
||||
request.out_triangle = &triangle_color;
|
||||
|
||||
const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request);
|
||||
REQUIRE_FALSE(out.vertices.empty());
|
||||
// Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever
|
||||
// the model was already painted with exactly as it was.
|
||||
for (const uint8_t entry : triangle_color)
|
||||
CHECK(entry == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface",
|
||||
"[TextureDisplacement]")
|
||||
{
|
||||
|
||||
@@ -1110,28 +1110,6 @@ TEST_CASE("a cache with the wrong cache version is rejected", "[VendorCache]")
|
||||
REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0")));
|
||||
}
|
||||
|
||||
TEST_CASE("a cache truncated mid-blob is rejected", "[VendorCache]")
|
||||
{
|
||||
TempDir tmp;
|
||||
const std::string vid = "Acme";
|
||||
const fs::path cache = tmp.path / "vendor.opc";
|
||||
|
||||
REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0",
|
||||
{filament_entry(vid + " PLA")}));
|
||||
|
||||
{
|
||||
std::ifstream in(cache.string(), std::ios::binary);
|
||||
std::vector<char> buf(30); // 20-byte header + 10 bytes of blob
|
||||
in.read(buf.data(), 30);
|
||||
in.close();
|
||||
std::ofstream out(cache.string(), std::ios::binary | std::ios::trunc);
|
||||
out.write(buf.data(), 30);
|
||||
}
|
||||
|
||||
PresetBundle out;
|
||||
REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0")));
|
||||
}
|
||||
|
||||
TEST_CASE("printer model bed texture fields survive a cache round-trip", "[VendorCache]")
|
||||
{
|
||||
TempDir tmp;
|
||||
|
||||
@@ -34,53 +34,6 @@ using namespace Slic3r;
|
||||
using VD = Geometry::VoronoiDiagram;
|
||||
using BoostVD = boost::polygon::voronoi_diagram<double>;
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12067
|
||||
// This bug seems to be confirmed.
|
||||
// Vojtech supposes that there may be no Voronoi edges produced for
|
||||
// the 1st and last sweep line positions.
|
||||
TEST_CASE("Voronoi missing edges - points 12067", "[Voronoi]")
|
||||
{
|
||||
Points pts {
|
||||
{ -10, -20 },
|
||||
{ 10, -20 },
|
||||
{ 5, 0 },
|
||||
{ 10, 20 },
|
||||
{ -10, 20 },
|
||||
{ -5, 0 }
|
||||
};
|
||||
|
||||
#if 0
|
||||
for (Point &p : pts) {
|
||||
Vec2d q = p.cast<double>();
|
||||
p.x() = scale_(p.x());
|
||||
p.y() = scale_(p.y());
|
||||
}
|
||||
#endif
|
||||
|
||||
#if 0
|
||||
// Try to rotate, maybe the issue is caused by incorrect handling of vertical or horizontal edges?
|
||||
double a = 0.18764587962597876897475f;
|
||||
double c = cos(a);
|
||||
double s = sin(a);
|
||||
for (Point &p : poly.points) {
|
||||
Vec2d q = p.cast<double>();
|
||||
p.x() = coord_t(q.x() * c + q.y() * s + 0.5);
|
||||
p.y() = coord_t(- q.x() * s + q.y() * c + 0.5);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Construction of the Voronoi Diagram.
|
||||
BoostVD vd;
|
||||
construct_voronoi(pts.begin(), pts.end(), &vd);
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-pts.svg").c_str(),
|
||||
vd, pts, Lines());
|
||||
#endif
|
||||
|
||||
// REQUIRE(closest_point.z() == Catch::Approx(1.));
|
||||
}
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12707
|
||||
// This issue is confirmed, there are no self intersections in the polygon.
|
||||
// A minimal test case is created at the end of this test,
|
||||
@@ -315,36 +268,6 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
|
||||
#endif
|
||||
}
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12903
|
||||
// division by zero reported, but this issue is most likely a non-issue, as it produces an infinity for the interval of validity
|
||||
// of the floating point calculation, therefore forcing a recalculation with extended accuracy.
|
||||
TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
|
||||
{
|
||||
Points pts { { 1, 1 }, { 3, 1 }, { 1, 3 }, { 3, 3 },
|
||||
{ -1, 1 }, { 1, -1 }, { 5, 1 }, { 3, -1 },
|
||||
{ -1, 3 }, { 1, 5 }, { 5, 3 }, { 3, 5 } };
|
||||
{
|
||||
auto pts2 { pts };
|
||||
std::sort(pts2.begin(), pts2.end(), [](auto &l, auto &r) { return (l.x() == r.x()) ? l.y() < r.y() : l.x() < r.x(); });
|
||||
// No point removed -> no duplicate.
|
||||
REQUIRE(std::unique(pts2.begin(), pts2.end()) == pts2.end());
|
||||
}
|
||||
|
||||
BoostVD vd;
|
||||
construct_voronoi(pts.begin(), pts.end(), &vd);
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
// Scale the voronoi vertices and input points, so that the dump_voronoi_to_svg will display them correctly.
|
||||
for (auto &pt : vd.vertices()) {
|
||||
const_cast<double&>(pt.x()) = scale_(pt.x());
|
||||
const_cast<double&>(pt.y()) = scale_(pt.y());
|
||||
}
|
||||
for (auto &pt : pts)
|
||||
pt = Point::new_scale(pt.x(), pt.y());
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-div-by-zero.svg").c_str(), vd, pts, Lines());
|
||||
#endif
|
||||
}
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12139
|
||||
// Funny sample from a dental industry?
|
||||
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
|
||||
@@ -1383,15 +1306,6 @@ TEST_CASE("Voronoi offset", "[VoronoiOffset]")
|
||||
OffsetTest { scale_(0.55), 1, 0 }
|
||||
}) {
|
||||
|
||||
#if 0
|
||||
Polygons offsetted_polygons_out = Slic3r::Voronoi::offset(vd, lines, ot.distance, scale_(0.005));
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-offset-out-%lf.svg", ot.distance).c_str(),
|
||||
vd, Points(), lines, offsetted_polygons_out);
|
||||
#endif
|
||||
REQUIRE(offsetted_polygons_out.size() == ot.num_outer);
|
||||
#endif
|
||||
|
||||
Polygons offsetted_polygons_in = Slic3r::Voronoi::offset(vd, lines, - ot.distance, scale_(0.005));
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-offset-in-%lf.svg", ot.distance).c_str(),
|
||||
|
||||
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_creality_cfs_match.cpp
|
||||
test_dev_mapping.cpp
|
||||
test_device_access.cpp
|
||||
test_moonraker_discovery.cpp
|
||||
test_moonraker_printer_agent.cpp
|
||||
test_network_discovery.cpp
|
||||
test_filament_bitmap_utils.cpp
|
||||
test_colour_picker_field.cpp
|
||||
|
||||
@@ -1,61 +1 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "slic3r/Utils/Http.hpp"
|
||||
|
||||
TEST_CASE("Check SSL certificates paths", "[Http][NotWorking]") {
|
||||
|
||||
Slic3r::Http g = Slic3r::Http::get("https://github.com/");
|
||||
|
||||
unsigned status = 0;
|
||||
g.on_error([&status](std::string, std::string, unsigned http_status) {
|
||||
status = http_status;
|
||||
});
|
||||
|
||||
g.on_complete([&status](std::string /* body */, unsigned http_status){
|
||||
status = http_status;
|
||||
});
|
||||
|
||||
g.perform_sync();
|
||||
|
||||
REQUIRE(status == 200);
|
||||
}
|
||||
|
||||
TEST_CASE("Http digest authentication", "[Http][NotWorking]") {
|
||||
Slic3r::Http g = Slic3r::Http::get("https://httpbingo.org/digest-auth/auth/guest/guest");
|
||||
|
||||
g.auth_digest("guest", "guest");
|
||||
|
||||
unsigned status = 0;
|
||||
g.on_error([&status](std::string, std::string, unsigned http_status) {
|
||||
status = http_status;
|
||||
});
|
||||
|
||||
g.on_complete([&status](std::string /* body */, unsigned http_status){
|
||||
status = http_status;
|
||||
});
|
||||
|
||||
g.perform_sync();
|
||||
|
||||
REQUIRE(status == 200);
|
||||
}
|
||||
|
||||
TEST_CASE("Http basic authentication", "[Http][NotWorking]") {
|
||||
Slic3r::Http g = Slic3r::Http::get("https://httpbingo.org/basic-auth/guest/guest");
|
||||
|
||||
g.auth_basic("guest", "guest");
|
||||
|
||||
unsigned status = 0;
|
||||
g.on_error([&status](std::string, std::string, unsigned http_status) {
|
||||
status = http_status;
|
||||
});
|
||||
|
||||
g.on_complete([&status](std::string /* body */, unsigned http_status){
|
||||
status = http_status;
|
||||
});
|
||||
|
||||
g.perform_sync();
|
||||
|
||||
REQUIRE(status == 200);
|
||||
}
|
||||
|
||||
@@ -73,34 +73,6 @@ TEST_CASE("ActionRegistry takes exclusive ownership of published actions", "[Act
|
||||
STATIC_CHECK(std::is_same_v<decltype(&ActionRegistry::upsert), ExpectedUpsert>);
|
||||
}
|
||||
|
||||
// A dynamic "Go to Plate N" action is keyed by plate index (not the display title), so renaming
|
||||
// a plate never re-keys it - the same contract as a setting action.
|
||||
TEST_CASE("Go-to-plate actions are keyed by index, not title", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
CHECK(AppAction::compose_id("orca_plate_goto", "0", "orca") == "orca_plate_goto:0:orca");
|
||||
CHECK(AppAction::compose_id("orca_plate_goto", "2", "orca") == "orca_plate_goto:2:orca");
|
||||
}
|
||||
|
||||
// A dynamic "Open recent project" action is keyed by file path (not the display name), so renaming
|
||||
// a project or reordering the recents list never re-keys it - the same contract as a setting action.
|
||||
TEST_CASE("Recent-project actions are keyed by path, not title", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
CHECK(AppAction::compose_id("orca_recent_project", "/a/b/project.3mf", "orca") ==
|
||||
"orca_recent_project:/a/b/project.3mf:orca");
|
||||
CHECK(AppAction::compose_id("orca_recent_project", "C:/Data/cube.3mf", "orca") ==
|
||||
"orca_recent_project:C:/Data/cube.3mf:orca");
|
||||
}
|
||||
|
||||
// A built-in command is keyed by its stable catalog key (not the localized display title), so a
|
||||
// rename or a UI-language switch never re-keys the action and its persisted favourite/stats survive.
|
||||
TEST_CASE("Command actions are keyed by catalog key, not display title", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
CHECK(AppAction::compose_id("orca_command", "save_project", "orca") == "orca_command:save_project:orca");
|
||||
// The second field is the stable key, so distinct commands never collide.
|
||||
CHECK(AppAction::compose_id("orca_command", "save_project", "orca") !=
|
||||
AppAction::compose_id("orca_command", "load_project", "orca"));
|
||||
}
|
||||
|
||||
// The real catalog -> action mapping keys by the stable catalog key and copies presentation from the
|
||||
// catalog, so a rename or a UI-language switch never re-keys the action. The catalog's English title
|
||||
// and group become the action's English search aliases; no translation catalog is loaded in the tests,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
|
||||
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
|
||||
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
|
||||
// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
|
||||
// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
|
||||
// as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
|
||||
// ahead of it).
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
@@ -17,12 +18,15 @@
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include <imgui/imgui.h>
|
||||
#include <imgui/imgui_internal.h>
|
||||
#include <wx/event.h>
|
||||
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
#include "slic3r/GUI/KeyChord.hpp"
|
||||
|
||||
using Catch::Matchers::WithinAbs;
|
||||
using Slic3r::GUI::ImGuiWrapper;
|
||||
using Slic3r::GUI::KeyChord;
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -41,6 +45,45 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
|
||||
return ImGui::GetIO().MouseWheel;
|
||||
}
|
||||
|
||||
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
|
||||
struct ImGuiFrames
|
||||
{
|
||||
ImGuiWrapper imgui;
|
||||
|
||||
ImGuiFrames()
|
||||
{
|
||||
imgui.set_display_size(800.f, 600.f);
|
||||
unsigned char* pixels = nullptr;
|
||||
int width = 0, height = 0;
|
||||
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||
}
|
||||
|
||||
template<class Draw> void frame(Draw draw)
|
||||
{
|
||||
ImGui::NewFrame();
|
||||
draw();
|
||||
ImGui::EndFrame();
|
||||
}
|
||||
|
||||
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
|
||||
void draw_input(bool focus)
|
||||
{
|
||||
ImGui::Begin("window");
|
||||
if (focus)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
ImGui::InputText("##text", text, sizeof(text));
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
char text[16] = "";
|
||||
};
|
||||
|
||||
void draw_modal()
|
||||
{
|
||||
if (ImGui::BeginPopupModal("modal"))
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
||||
@@ -59,3 +102,62 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
|
||||
}));
|
||||
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
||||
}
|
||||
|
||||
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
|
||||
{
|
||||
ImGuiFrames f;
|
||||
f.frame([&] { f.draw_input(false); });
|
||||
CHECK_FALSE(f.imgui.text_input_active());
|
||||
|
||||
f.frame([&] { f.draw_input(true); });
|
||||
f.frame([&] { f.draw_input(false); });
|
||||
CHECK(f.imgui.text_input_active());
|
||||
|
||||
f.frame([] {});
|
||||
f.frame([] {});
|
||||
CHECK_FALSE(f.imgui.text_input_active());
|
||||
}
|
||||
|
||||
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
|
||||
{
|
||||
ImGuiFrames f;
|
||||
CHECK_FALSE(f.imgui.modal_popup_open());
|
||||
|
||||
f.frame([] {
|
||||
ImGui::OpenPopup("modal");
|
||||
draw_modal();
|
||||
});
|
||||
CHECK(f.imgui.modal_popup_open());
|
||||
|
||||
// A frame opened by input, before anything is drawn in it.
|
||||
ImGui::NewFrame();
|
||||
CHECK(f.imgui.modal_popup_open());
|
||||
draw_modal();
|
||||
ImGui::EndFrame();
|
||||
CHECK(f.imgui.modal_popup_open());
|
||||
|
||||
// A frame that no longer draws it, as when its canvas is hidden.
|
||||
f.frame([] {});
|
||||
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
|
||||
CHECK_FALSE(f.imgui.modal_popup_open());
|
||||
}
|
||||
|
||||
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
|
||||
{
|
||||
ImGuiFrames f;
|
||||
const KeyChord letter{ 'A' };
|
||||
const KeyChord save{ 'S', wxMOD_CONTROL };
|
||||
CHECK_FALSE(f.imgui.owns_key(letter));
|
||||
CHECK_FALSE(f.imgui.owns_key(save));
|
||||
|
||||
f.frame([&] { f.draw_input(true); });
|
||||
f.frame([&] { f.draw_input(false); });
|
||||
CHECK(f.imgui.owns_key(letter));
|
||||
CHECK_FALSE(f.imgui.owns_key(save));
|
||||
|
||||
f.frame([] {
|
||||
ImGui::OpenPopup("modal");
|
||||
draw_modal();
|
||||
});
|
||||
CHECK(f.imgui.owns_key(save));
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <string>
|
||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
class DiscoveryAgent : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||
using MoonrakerPrinterAgent::device_info;
|
||||
mutable unsigned fetch_count = 0;
|
||||
|
||||
protected:
|
||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||
{
|
||||
++fetch_count;
|
||||
info.dev_name = "Test printer";
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
|
||||
{
|
||||
DiscoveryAgent agent("");
|
||||
unsigned announcements = 0;
|
||||
nlohmann::json announcement;
|
||||
agent.set_on_ssdp_msg_fn([&](std::string payload) {
|
||||
++announcements;
|
||||
announcement = nlohmann::json::parse(payload);
|
||||
});
|
||||
|
||||
agent.start_discovery(true, false);
|
||||
REQUIRE(announcements == 0);
|
||||
REQUIRE(agent.fetch_count == 0);
|
||||
|
||||
agent.device_info.dev_id = "127.0.0.1:7125";
|
||||
agent.device_info.dev_ip = agent.device_info.dev_id;
|
||||
agent.device_info.base_url = "http://127.0.0.1:7125";
|
||||
agent.start_discovery(true, false);
|
||||
REQUIRE(announcements == 1);
|
||||
REQUIRE(agent.fetch_count == 1);
|
||||
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
|
||||
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
|
||||
REQUIRE(announcement.at("dev_name") == "Test printer");
|
||||
}
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user