Compare commits

..
Author SHA1 Message Date
Hanif Koh 86941859fa Keep a Scalar Config Option's Value When Deserializing Fails
A failed stream extraction writes 0 into its target, so Float, Int, Percent and FloatOrPercent options were overwritten before deserialize reported the error, and Point/Point3 kept a partially scanned value. Parse into a temporary and assign only on success; FloatOrPercent sets its percent flag only then too.
2026-10-19 16:41:04 +08:00
HanifKoh d3699dd9f6 Keep Python Bytecode out of the Bundled Python Home (#16550)
* Keep Python Bytecode out of the Bundled Python Home

The bundled stdlib ships .pyc files that are stale against their sources:
they are compiled when the Python deps are installed, and the copy into the
app does not keep the sources' mtimes. CPython then recompiles every module
it imports and writes the result next to the source. A drag-installed macOS
app rewrites files inside its own bundle on first launch, so its signature
no longer verifies, and read-only installs (AppImage, Flatpak, Program Files)
recompile the same modules on every launch.

Point pycache_prefix at <data_dir>/python/pycache so the interpreter reads
and writes bytecode there. The bundle is never written, the cache survives
between launches, and the data directory is already a plugin read/write root.

* Drop Cached Bytecode Through the Interpreter in the Module Release Test
2026-10-19 13:48:53 +08:00
HanifKoh a315a96c5c Show "No Available Printers" Instead of an Empty Printer Dropdown (#16611)
* Show "No Available Printers" Instead of an Empty Printer Dropdown

A ComboBox with no items opened a one-row-high popup that DropDown never
paints, leaving a blank strip with stale pixels under the box. Don't open
the list when it is empty, and label the printer box in the print and
send-to-printer dialogs when no printer is available.

* Add "No Available Printers" to the Translation Template
2026-10-19 13:30:36 +08:00
HanifKoh 24cf1c6bcc Fix Crash Selecting a User Printer Whose Parent Preset Is Not Loaded (#16579)
A user preset can inherit a system preset from a vendor that is not
installed. get_preset_base() returns null for it, and
get_selected_preset_base() dereferenced that unconditionally, so picking
the printer crashed while building the shared profiles notification URL.
Fall back to the selected preset itself. The create printer and create
filament dialogs had the same null dereference on such presets.
2026-10-19 13:29:26 +08:00
Kris Austin d16ca97380 Snapmaker U1: set the top cover purifier mode and match Snapmaker's start G-code (#16590)
Port the U1 top cover purifier mode and the related start G-code
changes from Snapmaker's Orca (Snapmaker/OrcaSlicer#504 and
Snapmaker/OrcaSlicer#1005, as of 8931178229).

The start G-code sends SET_PURIFIER_MODE for strong cooling, weak
cooling or keep warm, picked from the lowest softening temperature of
the filaments used through min_vitrification_temperature, with
Snapmaker's thresholds and parameters. It also pre-extrudes the first
tool, and turns the part fan off for probing and back on for nozzle
cleaning.

U1 filament presets use Snapmaker's softening temperatures. U1 presets
that have no U1 version in Snapmaker's Orca use the value Snapmaker
gives that material on the U1.
2026-10-18 21:35:32 -05:00
Kris Austin da4d766d46 Fix Moonraker status stream hanging app exit and missing dead printers (#16604)
The status stream read the websocket synchronously. Beast's
expires_after() only applies to async operations, so when a printer
stopped sending, ws.read() blocked forever, and stop_status_stream()
joined that thread when the app closed. On Windows the stuck process
also froze the next launch through the shared WebView2 browser.

Run the whole stream on one io_context owned by the stream thread, and
have stop_status_stream() post an abort to it. A stop on an open
websocket sends a close frame and waits up to 1 s for the answer.
Keep-alive pings detect a dead printer, and the stream reconnects until
stopped, with the backoff capped at 32 s. When a quiet printer still
answers pings, its last status is sent again. The stream starts under
connect_mutex after a staleness check, and a stop from the stream's own
callback no longer joins its own thread.

The Moonraker tests move into test_moonraker_printer_agent.cpp.
2026-10-18 19:33:58 -05:00
Kris Austin 06665d0558 Post a readable diff of changed custom G-code on profile PRs (#16605)
A profile stores each custom G-code setting as one JSON string, so GitHub
shows a G-code edit as one long changed line and reviewers cannot see
what changed.

Add a gcode-diff command to orca_profile_tool.py. For each changed
profile file it decodes, on both sides, every *_gcode and multiline
string option PrintConfig.cpp defines, the options Tab.cpp edits as
code, and any string value that spans several lines. It prints one
collapsed unified diff per setting as markdown. Files with the same diff
for a setting share one section. Trailing spaces are removed, file names
cannot mention GitHub users, and the output is cut to whole sections to
fit a GitHub comment.

Check profiles runs it between the PR's merge commit and its first
parent and adds the result to its artifact, with a line naming the PR
commit. Post profile check comment posts it under its own marker. It
keeps the comment while the diff is unchanged, edits it in place when
only the commit changed, replaces it when the diff changes, and deletes
it when the PR no longer changes any G-code.

The posting job still runs no PR code. It now posts only when the run
checked the head of the PR the artifact names, which covers the
validation comment too.
2026-10-18 13:33:53 -05:00
Kris Austin 0f59cd5d05 Let Global shortcuts run while typing, except keys that edit text (#16558)
#16505 skipped every Global shortcut while ImGui had the keyboard, so
Ctrl+S, Ctrl+R and the others did nothing while the cursor was in the
text tool's box. Skip only the chords that edit text, and only while a
text field (a wx text entry, a spin control, a web view, or an active
ImGui InputText) has the focus. Skip every Global shortcut while an
ImGui modal is open on the focused canvas.

KeyChord::edits_text() defines the text-editing chords. The Global page
of the shortcuts dialog no longer says these shortcuts apply while
typing.
2026-10-18 13:32:41 -05:00
yw4z c1fb801ff3 Linux: Show display backend of app on Troubleshoot dialog (#16615)
* init

* Update TroubleshootDialog.cpp

* fix build
2026-10-18 22:45:30 +08:00
Lam Wei Lun 1ee0b730a1 Linux: use WebKit's shared-memory transport in hypervisor guests
Web views in VirtualBox (and other virtual GPUs) fail on WebKitGTK >= 2.44
because the guest GL stack cannot back the hardware DMABUF renderer. Setting
WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 fixes it and keeps accelerated compositing,
confirmed in VirtualBox on WebKitGTK 2.54.

Detect a hypervisor guest from the cpuinfo hypervisor flag before GTK init and
set the variable non-replacing, so a user-exported value still wins. Bare metal
is unchanged. The skill references are updated to match.
2026-10-18 04:43:45 +08:00
Kiss Lorand 72ba7a2b35 Fix incorrect layer number in G-code conflict warning (#16588)
Fix layer number in G-code conflict warning

Correct the comparison used to look up a layer by Z height.

The reversed comparison could return the total layer count instead of
the layer corresponding to the reported conflict height.
2026-10-17 23:04:50 +03:00
Downtime 8160f64cae Skip the LAN reconnect after a print to the selected printer (#16271)
After a print, `Plater::print_job_finished` selects the target printer again. When that printer was already the selected LAN printer, `set_selected_machine` disconnected, reset and reconnected it for no reason. Skip the call in that case. Selecting the printer by hand still reconnects it, and cloud printers are unchanged.
2026-10-17 13:50:38 -05:00
fd28f6c189 Fix Ctrl+A (select current plate) and Delete All skipping objects that no longer fit or while one of instances not on the bed (#15895)
* Fix Ctrl+A (select current plate) skipping objects that no longer fit the bed

Selection::add_curr_plate() used PartPlate::contain_instance_totally(),
which excludes any instance flagged in instance_outside_set -- an
instance too big for, or dragged outside, the bed. Since that is
exactly the kind of object a user reaches for Ctrl+A to move or
delete, it silently dropped out of "select all" (and out of the
selection entirely when it was the only object on the plate).

Selection::remove_curr_plate(), used for "delete all on this plate",
already does this correctly with the plain membership check,
PartPlate::contain_instance() (no outside-bed exclusion). Match it.

Likely reachable since #12236 changed Ctrl+A's target from "all
plates" to "current plate", switching it onto this pre-existing
function.

Fixes OrcaSlicer/OrcaSlicer#15888

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Trim comment verbosity per review feedback

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix behaviour for instances

* simplify code

* Update Selection.cpp

* Update Selection.cpp

* update

* update

---------

Co-authored-by: Fernando Marino <f.marino@rheagroup.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-10-17 02:21:41 +03:00
Kris Austin 9b2fcaa53a fix: "bad allocation" when opening a 2.4 project with a custom filament (#16569) 2026-10-16 20:12:24 -03:00
Ian BassiandRodrigo Faselli 0acf1e6680 Fix wall order on narrow tube walls and under outer brims, and test wall order and direction (#16421)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-10-16 18:56:55 -03:00
Ján Mišenko c6804fbb68 fix: print statistics placeholders fail in custom G-code (#16348)
The G-code editor lists the Print Statistics placeholders for every
custom G-code field, but total_weight, used_filament, total_cost,
print_time, normal_print_time, silent_print_time,
total_wipe_tower_cost and total_wipe_tower_filament were only defined
for filename_format. Using one in custom G-code, including the File
header G-code, stopped slicing with "Not a variable name".

They are known only once the whole G-code is generated, so they use the
reserved-tag mechanism of print_time_sec and used_filament_length: the
placeholder parser gets a tag such as @TOTAL_WEIGHT@ and
GCodeProcessor::run_post_process() replaces it with the same value
filename_format gets, with numbers written to two decimals. The
per-tag replacement loops are folded into one helper. The running
extruded_* totals are unchanged.

Fixes #15320
Fixes #7870
2026-10-16 16:30:05 -05:00
Kiss Lorand 6d40ec93f6 Fix missing tree support paths and misplaced extra walls (#16549) 2026-10-16 18:04:57 -03:00
93 changed files with 2812 additions and 3272 deletions

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
+25
View File
@@ -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: |
+58 -3
View File
@@ -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"
-239
View File
@@ -1,239 +0,0 @@
# ChromaStack - High Level Design
## Purpose and scope
ChromaStack prints a colour picture on a single face of a model: a few filaments are printed one on
top of another, the printer swaps filament at fixed layer boundaries, and the *height* of every
pixel decides how many of those layers reach it. Filament is translucent, so a pixel that stops
part-way through a run shows a blend of that run's filament and whatever is under it, which is
where the colours come from - far more of them than there are filaments loaded.
This is that model inside OrcaSlicer, as a mode of a texture displacement layer. The picture and
the filament stack are the input; the output is a grayscale height map and a set of filament swaps.
Nothing about it is a new pipeline: the height map is handed to the layer the user painted, so the
existing projection, preview and bake turn it into real geometry, and the swaps are handed to the
object's height ranges, which is the slicer's own way of printing a Z band in another filament.
It does not import other filament-painting tools' project files, and it does not choose the filaments or
their order - that is the user's artistic decision, and the panel exists to make its result
visible before the print.
## The colour model
`libslic3r/ChromaStack.hpp` holds the whole of it, free of any GUI dependency, which is what lets the
solve run against plain image bytes and be unit tested.
A **stack** (`ChromaStack`) is a list of **bands** (`ChromaBand`), bottom-up: a filament and
how many layers it gets. Alongside them it carries the print's layer height, which the colours
depend on directly, and the **base**: how many layers of solid backing the picture sits on.
The base is not a band. It is a measurement of the model - everything below the painted face, often
a hundred layers or more on a tall part - while a band is a handful of layers the user chooses. It
prints in the bottom band's filament, because that is what is loaded when the print starts, so it
extends that band's height range down to the first layer instead of being a swap of its own. Only
the bands are quantized into the height map, which is why the 255-layer limit
(`CHROMA_MAX_RELIEF_LAYERS`) bounds the picture and not the stack.
Each filament carries its **transmission distance** (TD), the usual measure for how deep
light gets into it: about 1 for an opaque filament, 4 to 7 for a usefully translucent one, 8 and
above for a nearly clear one. TD lives in the filament preset, as
`filament_transmission_distance` - it is a property of the material, so it belongs with the
material and ships with a profile rather than being retyped per project. It has no effect on
slicing.
`chroma_palette()` turns a stack into the colour it shows at every possible column height, which
is all the solver ever needs. The model is Beer-Lambert absorption, layer by layer: a layer of
thickness `h` in a filament of transmission distance `TD` passes
T = exp(-h / (CHROMA_TD_OPAQUE_FRACTION * TD / 3))
of the light from under it and shows its own colour with the rest, so walking up from an opaque
base,
C_k = (1 - T_k) * c_k + T_k * C_(k-1), C_0 = c_1
The constants say what TD means in physical terms: a filament stops showing what is behind it at
about a tenth of its TD number (`CHROMA_TD_OPAQUE_FRACTION`), and three e-folding lengths of
absorption pass 5% of the light, which is where the eye stops seeing through. Those are calibrated
against how the community's TD step prints behave, not derived from first principles - TD is an
empirical scale.
How fast that happens is the single most useful fact about a stack, and `chroma_layers_to_opaque()`
is it: a filament hides what is under it at about a tenth of its TD, so how many *layers* that takes
depends entirely on how thick they are. TD 4 covers in two layers at 0.2 mm and in five at 0.08 mm.
Below about two layers a band has no intermediate colours at all - the picture can only use the
filament or not use it - which is why an otherwise sound stack prints as flat steps at a coarse
layer height. The panel reports the number under the palette ramp, and warns when it falls below
two, because nothing else makes it visible: the filaments look fine, the bands look long enough, and
the result looks like the colour model is wrong when it is the layer height.
How many of a picture's layers actually earn their place is a second question, and
`chroma_distinct_levels()` answers it: a band eventually hides what is under it, and another layer of
it then shows the colour the one below already showed. It is measured against the palette rather than
against TD, because how long a band keeps paying depends on what is under it as much as on the
filament - a light band over a dark one is finished in four or five layers, while a dark one over a
light one goes on moving the colour perceptibly well past the depth at which it is nominally opaque,
since the last few percent of light coming through still shifts a near-black colour. `Trim` shortens
each band to its last layer that still changes anything.
Beer-Lambert is also the only model that stays self-consistent applied per layer: transmittance
multiplies, so a run of n layers of one filament passes `exp(-n h / d)` whether it is composited in
one step or n, and the palette therefore does not depend on how the stack happens to be cut into
bands. A unit test asserts exactly that. Compositing is done on linear light, because the quantity
being attenuated is light rather than an sRGB code.
The base is assumed opaque. That is what makes a column's colour depend only on the layers above it,
and it is why the panel warns when the base is thin.
## The solve
`chroma_solve()` maps each pixel of the picture to the column height whose palette colour is
closest to it, perceptually: both sides go to CIELAB and the match is a CIE76 distance. A 1-D
palette of a few dozen entries makes the extra accuracy of CIEDE2000 hard to see and the cost of it
easy to feel - several hundred million comparisons for a large picture - so the cheaper metric is
the deliberate choice here, unlike the texture colour quantizer, which matches against a
two-dimensional palette of filament mixes and uses a precomputed CIEDE2000 cube.
Optional Floyd-Steinberg error diffusion, serpentine, trades the bands a photograph shows across a
sky or a cheek for fine stipple. Off by default, since stipple is the artifact rather than the cure
for flat-coloured artwork.
Three things come back: the per-pixel layer count, the same thing as an 8-bit grayscale height map
(0 at the base, 255 at the top of the stack), and the palette colours chosen, as an RGB image. The
quantization belongs to the solve rather than to the displacement because the relief's steps have
to land on layer boundaries, and a grey level is the only channel the displacement reads.
The solve also reports the mean and worst colour difference it had to accept. That is the only
honest answer to "will this stack reproduce my picture": under about 5 is a good match, over about
15 means the filaments cannot reach the picture's colours and no number of layers will help.
## How it attaches to a texture displacement layer
A ChromaStack layer is an ordinary `TextureDisplacementLayer` whose height map is computed rather than
imported:
- `chroma_source` keeps the picture the user chose, so the solve can be redone whenever the stack,
the layer height or the picture changes;
- `chroma` is the stack;
- `image_data` - the same field every other layer uses - holds whatever the last solve produced.
So everything downstream needs no changes at all and knows nothing about colour stacks. It also
means the solve writes `depth_mm` (the relief is exactly as tall as the layers above the base) and
pins `invert`, `midlevel` and `smoothing`: blurring a solved height map moves its steps off the
layer boundaries they were solved for.
Colour by filament swap and the layer's own `color_enabled` (one filament per triangle, via
`mmu_segmentation_facets`) are mutually exclusive, and the panel enforces it. They are two
different ways of colouring the same surface, and painting the relief per triangle would hide the
very stack the heights were solved against.
## The filament swaps
`chroma_ranges()` turns the bands into Z bands in the object's own coordinates, merging any two
adjacent bands that share a filament, since nothing is swapped between them. The panel writes them
onto the object as `layer_config_ranges`, each range carrying `extruder` - which
`apply_to_print_region_config()` spreads over every feature filament the range does not otherwise
override, i.e. the whole band prints in that filament - and `layer_height`, both because the solve
is only valid at the height it was run at and because `layer_height_profile_from_ranges()` reads
that option without checking it is there.
Height ranges rather than colour-change custom G-code: they are object-relative, so they survive
the object being moved, they are saved in the project file, and they appear in the object list where
the user can see and edit them. The cost is that an object can only have one set of them, so
applying a stack replaces whatever ranges the object had, with a confirmation when it had any.
`ObjectList::rebuild_layer_ranges()` mirrors them in the object tree without touching the
selection - changing the selection while a gizmo is open tears the gizmo down under the user.
## Geometry the model assumes
A filament swap happens at one Z across the whole plate, and the relief grows along the painted
surface's normal. The two agree only where that surface is horizontal, so:
- the painted patch's height above the bottom of the object is what the base layer count has to
match, and the panel measures it from the patch and offers the number rather than leaving the user
to work it out;
- a patch that is not flat in Z gets a warning, because its colours will only be right where it is
level. The relief itself is still computed, since a gently domed plaque is a legitimate thing to
print and only the user can judge how much error is acceptable.
The model itself is the user's: the stack needs a slab of the base thickness with a face to paint,
and nothing here creates or thickens geometry.
## Seeing the colours
The colours are blends of translucent filaments. No single filament makes them, so they cannot be
expressed as the per-triangle filament assignment the 3D scene paints with, and there is nothing for
the ordinary view to draw. They exist in two places: the sliced preview, which shows the filament
bands as they print, and an overlay the gizmo draws over whatever surface is on screen.
That overlay is a shader, `resources/shaders/*/chromastack.*`, and it reads the colour from each
fragment's own **print Z** rather than from the height map or from anything in the mesh. A filament
swap happens at one Z across the whole plate, so the layer a point sits in is what decides its
colour whatever the picture says. Two things follow. A band edge lands exactly on the layer boundary
however coarse the triangles are - a per-triangle colouring puts it wherever the mesh happens to
have an edge. And it keeps working after a bake, when the relief is real geometry, the paint has
been cleared and the height map is attached to nothing on screen; the overlay is built from the live
preview's displaced mesh before a bake and from the volume's own mesh after one.
The stack's colours reach the shader as a texture of one texel per printed layer, because a base can
be several hundred layers and no uniform array holds that. The mesh carries nothing about the stack,
so editing the stack costs a palette upload rather than a mesh rebuild.
On top of the colour the shader tries to look like printed plastic, because a flat shaded colour
reads as a rendering and the whole point of the overlay is judging a print:
- the terminator is **wrapped** and the grazing angles **glow**, both scaled by the transmission
distance of the filament on top - filament translucent enough to be worth stacking scatters light
rather than reflecting it, and an opaque stack correctly gets neither;
- the walls carry **layer lines**, a groove at every layer boundary that also modulates the
specular, so a wall glints in stripes as a printed one does. Terraces get none: a terrace is one
layer's top surface and has no lines to show.
Both effects are driven by the fragment's height, so they are free of any texture or UV, and both
fade out through `fwidth` once a pixel covers more than about half a layer - lines finer than a
pixel are noise, and a colour read from one layer out of the dozen under a pixel flickers as the
camera moves. The palette lookup slides off the texel centres over the same range so the linear
filter averages the bands a pixel covers instead of picking one of them.
The overlay is drawn pulled forward and without depth writes, the same pass the painted-colour
overlay uses, since it is the same surface as whatever is underneath it.
## What the panel shows
The kind of a layer is the layer's own, chosen when it is added and fixed from then on: **Add layer**
offers *Relief layer* or *ChromaStack picture*, and the card carries its kind as a chip. There is no
panel-wide mode, so one object can hold a picture on its face and a knurl on its rim, and a card
never shows a control that cannot do anything on it.
What the two cards do not share:
| Control | Relief card | ChromaStack card |
| --- | --- | --- |
| Depth | slider | the solve writes it; the card prints the result |
| Tile size | repeats the pattern | **Picture size**, the same number named for what it does |
| Midlevel, Smoothing, Invert, Edge fade | present | absent: each would move the steps off the layer boundaries |
| Colors, Mix filaments, Denoise | one filament per triangle | absent: swaps are how this card colours |
| Tile, tile method | present | absent: one copy, never tiled |
| Blend | four modes | absent: it is the top layer and replaces what is under it |
| Picture, prediction, stack, palette, match, Dither, Solve, Apply swaps | absent | present |
A ChromaStack card has no grip and cannot be reordered: its colours come from swapping filament at a
height, so anything printed above it would bury them. An object may hold only one, refused when the
layer is added rather than when the swaps are applied, because the swaps are the object's own height
ranges and there is one set of those.
The card itself, top to bottom: what it is in one line, the picture and the predicted print side by
side at the same size, the match figures, the base with the measurement from the painted face offered
beside it, a row per band - swatch, filament, layer count, TD - the colours the stack reaches as a
ramp, the totals, dither with the layer height it is solved against, and the two actions. Base and
band counts are typed fields rather than sliders: a base is measured in hundreds of layers and a band
in single digits, and dragging past the end of a slider's range is how a stack silently loses its
backing.
The ramp is computed from the stack alone, before any solve. It is what answers "can these filaments
print this picture" without spending a solve to find out they cannot: a short or flat ramp means the
filaments are too alike, or too opaque, to give the picture anything to work with.
A per-slot flag records that the stack has been edited since the last solve, because a stale height
map looks exactly like a good one.
+7
View File
@@ -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
+3
View File
@@ -10657,6 +10657,9 @@ msgstr ""
msgid "Send Failed"
msgstr ""
msgid "No available printers"
msgstr ""
msgid "The name length exceeds the limit."
msgstr ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Snapmaker",
"version": "02.04.00.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",
-92
View File
@@ -1,92 +0,0 @@
#version 110
// What a ChromaStack print looks like, shaded per fragment.
//
// The colour is not a vertex attribute and not a texture on the surface: it is read from the
// fragment's own height. A filament swap happens at one print Z across the whole plate, so the layer
// a point sits in is what decides its colour - which means a band edge lands exactly on the layer
// boundary however coarse the mesh is, and the same shader is right before a bake (on the preview's
// displaced mesh) and after one (on the volume's own).
//
// On top of that it tries to look like printed plastic rather than like flat shaded colour: the
// terminator is wrapped and the grazing angles glow, because filament translucent enough to be worth
// stacking scatters light rather than reflecting it; and the walls carry layer lines, because that is
// the first thing the eye uses to read a print as a print.
#define INTENSITY_CORRECTION 0.6
const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929);
#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SHININESS 20.0
const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074);
#define LIGHT_FRONT_DIFFUSE (0.3 * INTENSITY_CORRECTION)
#define INTENSITY_AMBIENT 0.3
// The stack's colours, one texel per printed layer, bottom to top. A texture rather than a uniform
// array because a base can be several hundred layers and no uniform array survives that.
uniform sampler2D palette;
uniform float palette_size; // texels in `palette`, i.e. the stack's total printed layers
uniform float base_z; // world Z of the bottom of the object
uniform float layer_height; // mm
// How translucent the stack reads, 0 to 1, from the transmission distance of the filament on top.
// It drives the wrap and the rim: an opaque stack should not glow.
uniform float translucency;
uniform float alpha;
varying vec3 clipping_planes_dots;
varying vec3 eye_normal;
varying vec3 eye_position;
varying vec3 world_normal;
varying float world_z;
void main()
{
if (any(lessThan(clipping_planes_dots, vec3(0.0))))
discard;
float z_rel = world_z - base_z;
float layers = max(z_rel / layer_height, 0.0);
// How much of a layer one pixel covers. Everything below is faded out as this grows: layer lines
// finer than a pixel are noise, and a colour read from one layer out of the dozen under a pixel
// flickers as the camera moves.
float px = fwidth(layers);
// The layer this fragment sits in, 1-based. Up close that is an exact texel of the palette; once a
// pixel spans a layer or more it slides to a continuous coordinate so the linear filter averages
// the bands it covers instead of picking one of them at random.
float stepped = clamp(ceil(layers - 1.0e-4), 1.0, palette_size) - 0.5;
float smooth_ = clamp(layers, 0.5, palette_size - 0.5);
float u = mix(stepped, smooth_, clamp(px - 0.5, 0.0, 1.0)) / palette_size;
vec3 albedo = texture2D(palette, vec2(u, 0.5)).rgb;
vec3 N = normalize(eye_normal);
vec3 V = normalize(-eye_position);
// Layer lines, on the walls only: a terrace is one layer's top surface and has none to show. The
// groove runs round the part at every layer boundary, so it is a function of height alone.
float wall = 1.0 - abs(world_normal.z);
float phase = fract(layers);
float groove = smoothstep(0.0, 0.16, phase) * smoothstep(1.0, 0.84, phase);
groove = mix(groove, 0.65, clamp(px * 1.5, 0.0, 1.0)); // fade to flat when too fine to resolve
float lines = mix(1.0, 0.84 + 0.16 * groove, wall * 0.9);
albedo *= lines;
// Diffuse, with a wrapped terminator: light entering translucent filament comes back out a little
// way round the curve, so the shaded side never goes as dark as an opaque surface's would.
float wrap = 0.25 + 0.45 * translucency;
float ndotl = dot(N, LIGHT_TOP_DIR);
float diffuse = max((ndotl + wrap) / (1.0 + wrap), 0.0) * LIGHT_TOP_DIFFUSE;
diffuse += max((dot(N, LIGHT_FRONT_DIR) + wrap) / (1.0 + wrap), 0.0) * LIGHT_FRONT_DIFFUSE;
// Specular, strongest along a layer line, which is what makes a printed wall glint in stripes
// rather than evenly.
float spec = LIGHT_TOP_SPECULAR * pow(max(dot(V, reflect(-LIGHT_TOP_DIR, N)), 0.0), LIGHT_TOP_SHININESS);
spec *= mix(1.0, 0.35 + 0.65 * groove, wall * 0.9);
// Scattering out of the edges: a translucent part lights up where it is seen at a grazing angle
// and where it is thin. Tinted by the filament, because what comes back out has been through it.
float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 4.0);
vec3 color = albedo * (INTENSITY_AMBIENT + diffuse) + vec3(spec) + albedo * rim * translucency * 0.55;
gl_FragColor = vec4(color, alpha);
}
-40
View File
@@ -1,40 +0,0 @@
#version 110
uniform mat4 view_model_matrix;
uniform mat4 projection_matrix;
uniform mat3 view_normal_matrix;
uniform mat4 volume_world_matrix;
// The volume's normal matrix in world space, so the fragment shader can tell a wall from a terrace -
// which is what decides whether layer lines show at all.
uniform mat3 world_normal_matrix;
// Clipping plane, x = min z, y = max z (the FFF/SLA preview's top/bottom cut).
uniform vec2 z_range;
// Clipping plane, general orientation (the section view).
uniform vec4 clipping_plane;
// GLModel's P3N3 layout, which is what init_from(indexed_triangle_set) produces - this shader needs
// nothing per vertex that the mesh does not already carry, because the colour comes from the
// fragment's own height rather than from anything baked into the vertices.
attribute vec3 v_position;
attribute vec3 v_normal;
varying vec3 clipping_planes_dots;
varying vec3 eye_normal;
varying vec3 eye_position;
varying vec3 world_normal;
varying float world_z;
void main()
{
vec4 model_pos = vec4(v_position, 1.0);
vec4 world_pos = volume_world_matrix * model_pos;
world_z = world_pos.z;
world_normal = normalize(world_normal_matrix * v_normal);
eye_normal = normalize(view_normal_matrix * v_normal);
vec4 eye_pos = view_model_matrix * model_pos;
eye_position = eye_pos.xyz;
gl_Position = projection_matrix * eye_pos;
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
}
-94
View File
@@ -1,94 +0,0 @@
#version 140
// What a ChromaStack print looks like, shaded per fragment.
//
// The colour is not a vertex attribute and not a texture on the surface: it is read from the
// fragment's own height. A filament swap happens at one print Z across the whole plate, so the layer
// a point sits in is what decides its colour - which means a band edge lands exactly on the layer
// boundary however coarse the mesh is, and the same shader is right before a bake (on the preview's
// displaced mesh) and after one (on the volume's own).
//
// On top of that it tries to look like printed plastic rather than like flat shaded colour: the
// terminator is wrapped and the grazing angles glow, because filament translucent enough to be worth
// stacking scatters light rather than reflecting it; and the walls carry layer lines, because that is
// the first thing the eye uses to read a print as a print.
#define INTENSITY_CORRECTION 0.6
const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929);
#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SHININESS 20.0
const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074);
#define LIGHT_FRONT_DIFFUSE (0.3 * INTENSITY_CORRECTION)
#define INTENSITY_AMBIENT 0.3
// The stack's colours, one texel per printed layer, bottom to top. A texture rather than a uniform
// array because a base can be several hundred layers and no uniform array survives that.
uniform sampler2D palette;
uniform float palette_size; // texels in `palette`, i.e. the stack's total printed layers
uniform float base_z; // world Z of the bottom of the object
uniform float layer_height; // mm
// How translucent the stack reads, 0 to 1, from the transmission distance of the filament on top.
// It drives the wrap and the rim: an opaque stack should not glow.
uniform float translucency;
uniform float alpha;
in vec3 clipping_planes_dots;
in vec3 eye_normal;
in vec3 eye_position;
in vec3 world_normal;
in float world_z;
out vec4 out_color;
void main()
{
if (any(lessThan(clipping_planes_dots, vec3(0.0))))
discard;
float z_rel = world_z - base_z;
float layers = max(z_rel / layer_height, 0.0);
// How much of a layer one pixel covers. Everything below is faded out as this grows: layer lines
// finer than a pixel are noise, and a colour read from one layer out of the dozen under a pixel
// flickers as the camera moves.
float px = fwidth(layers);
// The layer this fragment sits in, 1-based. Up close that is an exact texel of the palette; once a
// pixel spans a layer or more it slides to a continuous coordinate so the linear filter averages
// the bands it covers instead of picking one of them at random.
float stepped = clamp(ceil(layers - 1.0e-4), 1.0, palette_size) - 0.5;
float smooth_ = clamp(layers, 0.5, palette_size - 0.5);
float u = mix(stepped, smooth_, clamp(px - 0.5, 0.0, 1.0)) / palette_size;
vec3 albedo = texture(palette, vec2(u, 0.5)).rgb;
vec3 N = normalize(eye_normal);
vec3 V = normalize(-eye_position);
// Layer lines, on the walls only: a terrace is one layer's top surface and has none to show. The
// groove runs round the part at every layer boundary, so it is a function of height alone.
float wall = 1.0 - abs(world_normal.z);
float phase = fract(layers);
float groove = smoothstep(0.0, 0.16, phase) * smoothstep(1.0, 0.84, phase);
groove = mix(groove, 0.65, clamp(px * 1.5, 0.0, 1.0)); // fade to flat when too fine to resolve
float lines = mix(1.0, 0.84 + 0.16 * groove, wall * 0.9);
albedo *= lines;
// Diffuse, with a wrapped terminator: light entering translucent filament comes back out a little
// way round the curve, so the shaded side never goes as dark as an opaque surface's would.
float wrap = 0.25 + 0.45 * translucency;
float ndotl = dot(N, LIGHT_TOP_DIR);
float diffuse = max((ndotl + wrap) / (1.0 + wrap), 0.0) * LIGHT_TOP_DIFFUSE;
diffuse += max((dot(N, LIGHT_FRONT_DIR) + wrap) / (1.0 + wrap), 0.0) * LIGHT_FRONT_DIFFUSE;
// Specular, strongest along a layer line, which is what makes a printed wall glint in stripes
// rather than evenly.
float spec = LIGHT_TOP_SPECULAR * pow(max(dot(V, reflect(-LIGHT_TOP_DIR, N)), 0.0), LIGHT_TOP_SHININESS);
spec *= mix(1.0, 0.35 + 0.65 * groove, wall * 0.9);
// Scattering out of the edges: a translucent part lights up where it is seen at a grazing angle
// and where it is thin. Tinted by the filament, because what comes back out has been through it.
float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 4.0);
vec3 color = albedo * (INTENSITY_AMBIENT + diffuse) + vec3(spec) + albedo * rim * translucency * 0.55;
out_color = vec4(color, alpha);
}
-40
View File
@@ -1,40 +0,0 @@
#version 140
uniform mat4 view_model_matrix;
uniform mat4 projection_matrix;
uniform mat3 view_normal_matrix;
uniform mat4 volume_world_matrix;
// The volume's normal matrix in world space, so the fragment shader can tell a wall from a terrace -
// which is what decides whether layer lines show at all.
uniform mat3 world_normal_matrix;
// Clipping plane, x = min z, y = max z (the FFF/SLA preview's top/bottom cut).
uniform vec2 z_range;
// Clipping plane, general orientation (the section view).
uniform vec4 clipping_plane;
// GLModel's P3N3 layout, which is what init_from(indexed_triangle_set) produces - this shader needs
// nothing per vertex that the mesh does not already carry, because the colour comes from the
// fragment's own height rather than from anything baked into the vertices.
in vec3 v_position;
in vec3 v_normal;
out vec3 clipping_planes_dots;
out vec3 eye_normal;
out vec3 eye_position;
out vec3 world_normal;
out float world_z;
void main()
{
vec4 model_pos = vec4(v_position, 1.0);
vec4 world_pos = volume_world_matrix * model_pos;
world_z = world_pos.z;
world_normal = normalize(world_normal_matrix * v_normal);
eye_normal = normalize(view_normal_matrix * v_normal);
vec4 eye_pos = view_model_matrix * model_pos;
eye_position = eye_pos.xyz;
gl_Position = projection_matrix * eye_pos;
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
}
+251 -4
View File
@@ -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)
+309 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
normalize, trim, update-index, fix-variant 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
# ---------------------------------------------------------------------------
+16
View File
@@ -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.";
+3 -7
View File
@@ -812,7 +812,8 @@ bool WallToolPaths::removeEmptyToolPaths(std::vector<VariableWidthLines> &toolpa
* Get the order constraints of the insets when printing walls per region / hole.
* Each returned pair consists of adjacent wall lines where the left has an inset_idx one lower than the right.
*
* Odd walls should always go after their enclosing wall polygons.
* Odd walls follow the same order as even ones, so that the centre line of a narrow wall is an inner wall
* like any other when printing inner to outer.
*
* \param outer_to_inner Whether the wall polygons with a lower inset_idx should go before those with a higher one.
*/
@@ -886,12 +887,7 @@ WallToolPaths::ExtrusionLineSet WallToolPaths::getRegionOrder(const std::vector<
continue; // not directly adjacent
if (!shorter_then(loc_here - lineloc_nearby.j.p, (lineloc_here.j.w + lineloc_nearby.j.w) / 2 * diagonal_extension))
continue; // points are too far away from each other
if (here->is_odd || nearby->is_odd) {
if (here->is_odd && !nearby->is_odd && nearby->inset_idx < here->inset_idx)
order_requirements.emplace(std::make_pair(nearby, here));
if (nearby->is_odd && !here->is_odd && here->inset_idx < nearby->inset_idx)
order_requirements.emplace(std::make_pair(here, nearby));
} else if ((nearby->inset_idx < here->inset_idx) == outer_to_inner) {
if ((nearby->inset_idx < here->inset_idx) == outer_to_inner) {
order_requirements.emplace(std::make_pair(nearby, here));
} else {
assert((nearby->inset_idx > here->inset_idx) == outer_to_inner);
-2
View File
@@ -327,8 +327,6 @@ set(lisbslic3r_sources
Geometry/VoronoiUtils.cpp
Geometry/VoronoiUtils.hpp
Geometry/VoronoiVisualUtils.hpp
ChromaStack.cpp
ChromaStack.hpp
IMEXArrange.cpp
IMEXArrange.hpp
IMEXHelpers.cpp
-394
View File
@@ -1,394 +0,0 @@
#include "ChromaStack.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <cstdlib>
#include <numeric>
#include <tbb/blocked_range.h>
#include <tbb/parallel_for.h>
#include <miniz.h>
namespace Slic3r {
// How many e-folding lengths of absorption count as "the colour beneath no longer shows". Three
// passes 5% of the light, which is about where the eye stops seeing what is under a layer.
static constexpr float OPAQUE_EFOLDS = 3.f;
// TD is a filament property, so a zero or negative value in a preset is a data error rather than a
// request for an infinitely opaque filament. Clamp instead: below this the filament is opaque at a
// single layer anyway.
static constexpr float TD_MIN_MM = 0.05f;
static constexpr float TD_MAX_MM = 100.f;
static float srgb_to_linear(float c)
{
return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f);
}
static float linear_to_srgb(float c)
{
return c <= 0.0031308f ? c * 12.92f : 1.055f * std::pow(c, 1.f / 2.4f) - 0.055f;
}
// CIELAB under D65, from non-linear sRGB in [0, 1]. Only used for nearest-colour search, so the
// absolute white point matters far less than both sides using the same one.
struct Lab
{
float l = 0.f, a = 0.f, b = 0.f;
};
static Lab srgb_to_lab(float r, float g, float b)
{
const float rl = srgb_to_linear(r), gl = srgb_to_linear(g), bl = srgb_to_linear(b);
const float x = (0.4124f * rl + 0.3576f * gl + 0.1805f * bl) / 0.95047f;
const float y = (0.2126f * rl + 0.7152f * gl + 0.0722f * bl);
const float z = (0.0193f * rl + 0.1192f * gl + 0.9505f * bl) / 1.08883f;
auto f = [](float t) {
return t > 0.008856f ? std::cbrt(t) : (7.787f * t + 16.f / 116.f);
};
const float fx = f(x), fy = f(y), fz = f(z);
return Lab{ 116.f * fy - 16.f, 500.f * (fx - fy), 200.f * (fy - fz) };
}
static float delta_e(const Lab &p, const Lab &q)
{
const float dl = p.l - q.l, da = p.a - q.a, db = p.b - q.b;
return std::sqrt(dl * dl + da * da + db * db);
}
int ChromaStack::relief_layers() const
{
int n = 0;
for (const ChromaBand &band : bands)
n += std::max(0, band.layers);
return n;
}
int ChromaStack::total_layers() const { return std::max(0, base_layers) + relief_layers(); }
float ChromaStack::depth_mm() const { return float(relief_layers()) * layer_height_mm; }
std::vector<ColorRGB> chroma_palette(const ChromaStack &stack)
{
const int total = stack.total_layers();
// No bands means no picture and no filament to print the base in, whatever base_layers says.
if (total <= 0 || stack.bands.empty() || stack.layer_height_mm <= 0.f)
return {};
// The filament each printed layer is in, bottom-up: the base first, in the bottom band's filament,
// then the bands themselves.
std::vector<const ChromaFilament *> per_layer;
per_layer.reserve(size_t(total));
for (int i = 0; i < std::max(0, stack.base_layers); ++i)
per_layer.push_back(&stack.bands.front().filament);
for (const ChromaBand &band : stack.bands)
for (int i = 0; i < band.layers; ++i)
per_layer.push_back(&band.filament);
std::vector<ColorRGB> palette(size_t(total) + 1);
// Linear light, walking up from an opaque base: C_0 is the bottom layer's own colour, so a
// column one layer tall comes out exactly that colour however translucent the filament is.
std::array<float, 3> acc{ srgb_to_linear(per_layer.front()->color.r()), srgb_to_linear(per_layer.front()->color.g()),
srgb_to_linear(per_layer.front()->color.b()) };
auto store = [&palette](size_t k, const std::array<float, 3> &lin) {
palette[k] = ColorRGB(std::clamp(linear_to_srgb(lin[0]), 0.f, 1.f), std::clamp(linear_to_srgb(lin[1]), 0.f, 1.f),
std::clamp(linear_to_srgb(lin[2]), 0.f, 1.f));
};
store(0, acc);
for (int k = 1; k <= total; ++k) {
const ChromaFilament &f = *per_layer[size_t(k - 1)];
const float td = std::clamp(f.td_mm, TD_MIN_MM, TD_MAX_MM);
// The depth at which this filament hides what is behind it, turned into the e-folding length
// of its absorption; see CHROMA_TD_OPAQUE_FRACTION.
const float efold = CHROMA_TD_OPAQUE_FRACTION * td / OPAQUE_EFOLDS;
const float t = std::exp(-stack.layer_height_mm / efold);
const std::array<float, 3> own{ srgb_to_linear(f.color.r()), srgb_to_linear(f.color.g()),
srgb_to_linear(f.color.b()) };
for (int c = 0; c < 3; ++c)
acc[size_t(c)] = (1.f - t) * own[size_t(c)] + t * acc[size_t(c)];
store(size_t(k), acc);
}
return palette;
}
ChromaSolve chroma_solve(const uint8_t *rgb, int width, int height, const ChromaStack &stack)
{
ChromaSolve solve;
const int total = stack.total_layers();
const int base = std::max(0, stack.base_layers);
const int relief = stack.relief_layers();
if (rgb == nullptr || width <= 0 || height <= 0 || relief <= 0 || relief > CHROMA_MAX_RELIEF_LAYERS)
return solve;
const std::vector<ColorRGB> palette = chroma_palette(stack);
if (int(palette.size()) != total + 1)
return solve;
// Only the heights the solver may choose: base (nothing of the relief printed) up to total.
const int choices = relief + 1;
const size_t choice_count = size_t(choices);
std::vector<Lab> choice_lab(choice_count);
std::vector<std::array<uint8_t, 3>> choice_rgb(choice_count);
for (int i = 0; i < choices; ++i) {
const ColorRGB &c = palette[size_t(base + i)];
choice_lab[size_t(i)] = srgb_to_lab(c.r(), c.g(), c.b());
choice_rgb[size_t(i)] = { uint8_t(std::lround(c.r() * 255.f)), uint8_t(std::lround(c.g() * 255.f)),
uint8_t(std::lround(c.b() * 255.f)) };
}
const size_t count = size_t(width) * size_t(height);
solve.width = width;
solve.height = height;
solve.levels.resize(count);
solve.grey.resize(count);
solve.preview.resize(count * 3);
std::vector<float> errors(count, 0.f);
auto pick = [&](float r, float g, float b, float &out_error) {
const Lab target = srgb_to_lab(std::clamp(r, 0.f, 1.f), std::clamp(g, 0.f, 1.f), std::clamp(b, 0.f, 1.f));
int best = 0;
float best_d = std::numeric_limits<float>::max();
for (int i = 0; i < choices; ++i) {
const float d = delta_e(target, choice_lab[size_t(i)]);
if (d < best_d) {
best_d = d;
best = i;
}
}
out_error = best_d;
return best;
};
// The grey level a chosen height is written as. Rounded to the nearest of 256 levels, which for
// any practical layer budget (a few dozen) maps every height to its own level exactly - the
// relief's steps stay on layer boundaries, which is the whole point of quantizing here.
auto grey_of = [relief](int choice) {
return uint8_t(std::lround(255.0 * double(choice) / double(relief)));
};
auto write = [&](size_t px, int choice, float err) {
solve.levels[px] = uint16_t(base + choice);
solve.grey[px] = grey_of(choice);
solve.preview[px * 3] = choice_rgb[size_t(choice)][0];
solve.preview[px * 3 + 1] = choice_rgb[size_t(choice)][1];
solve.preview[px * 3 + 2] = choice_rgb[size_t(choice)][2];
errors[px] = err;
};
if (stack.dither) {
// Floyd-Steinberg, serpentine so the error does not drift consistently to one side. The
// diffusion buffer holds the two rows the kernel reaches, in sRGB - which is where the
// artifact being traded away lives, and what every reference implementation diffuses in.
const size_t row_len = size_t(width);
std::vector<std::array<float, 3>> row(row_len), next(row_len);
for (int x = 0; x < width; ++x) {
const uint8_t *src = rgb + size_t(x) * 3;
row[size_t(x)] = { src[0] / 255.f, src[1] / 255.f, src[2] / 255.f };
}
for (int y = 0; y < height; ++y) {
if (y + 1 < height)
for (int x = 0; x < width; ++x) {
const uint8_t *src = rgb + (size_t(y + 1) * size_t(width) + size_t(x)) * 3;
next[size_t(x)] = { src[0] / 255.f, src[1] / 255.f, src[2] / 255.f };
}
const bool ltr = (y % 2) == 0;
for (int i = 0; i < width; ++i) {
const int x = ltr ? i : width - 1 - i;
const size_t px = size_t(y) * size_t(width) + size_t(x);
float err = 0.f;
const int choice = pick(row[size_t(x)][0], row[size_t(x)][1], row[size_t(x)][2], err);
write(px, choice, err);
std::array<float, 3> residual{};
for (int c = 0; c < 3; ++c)
residual[size_t(c)] = row[size_t(x)][size_t(c)] - choice_rgb[size_t(choice)][size_t(c)] / 255.f;
const int ahead = ltr ? x + 1 : x - 1;
const int back = ltr ? x - 1 : x + 1;
auto spread = [&](std::vector<std::array<float, 3>> &dst, int at, float w) {
if (at < 0 || at >= width)
return;
for (int c = 0; c < 3; ++c)
dst[size_t(at)][size_t(c)] += residual[size_t(c)] * w;
};
spread(row, ahead, 7.f / 16.f);
if (y + 1 < height) {
spread(next, back, 3.f / 16.f);
spread(next, x, 5.f / 16.f);
spread(next, ahead, 1.f / 16.f);
}
}
row.swap(next);
}
} else {
tbb::parallel_for(tbb::blocked_range<int>(0, height), [&](const tbb::blocked_range<int> &range) {
for (int y = range.begin(); y < range.end(); ++y)
for (int x = 0; x < width; ++x) {
const size_t px = size_t(y) * size_t(width) + size_t(x);
const uint8_t *src = rgb + px * 3;
float err = 0.f;
write(px, pick(src[0] / 255.f, src[1] / 255.f, src[2] / 255.f, err), err);
}
});
}
solve.mean_delta_e = count == 0 ? 0. : std::accumulate(errors.begin(), errors.end(), 0.) / double(count);
solve.max_delta_e = errors.empty() ? 0. : double(*std::max_element(errors.begin(), errors.end()));
return solve;
}
float chroma_layers_to_opaque(float td_mm, float layer_height_mm)
{
if (layer_height_mm <= 0.f)
return 0.f;
return CHROMA_TD_OPAQUE_FRACTION * std::clamp(td_mm, TD_MIN_MM, TD_MAX_MM) / layer_height_mm;
}
// Below about one dE two colours are the same colour to the eye. Half of that is the threshold for
// "this layer changed something", so a step has to be comfortably invisible before a layer is called
// wasted.
static constexpr float VISIBLE_DE = 0.5f;
static float step_delta_e(const ColorRGB &a, const ColorRGB &b)
{
return delta_e(srgb_to_lab(a.r(), a.g(), a.b()), srgb_to_lab(b.r(), b.g(), b.b()));
}
int chroma_distinct_levels(const ChromaStack &stack)
{
const std::vector<ColorRGB> palette = chroma_palette(stack);
const int base = std::max(0, stack.base_layers);
if (int(palette.size()) <= base + 1)
return 0;
int n = 0;
for (size_t i = size_t(base) + 1; i < palette.size(); ++i)
if (step_delta_e(palette[i - 1], palette[i]) > VISIBLE_DE)
++n;
return n;
}
void chroma_trim_bands(ChromaStack &stack)
{
// Walked over the stack's own palette rather than over the filaments' TD, because how fast a band
// runs out of anything to say depends on what is under it as much as on the filament. A light band
// over a dark one is finished in four or five layers; a dark one over a light one goes on paying
// for many more, long past the depth at which it is nominally opaque, because the last few percent
// of light coming through still moves a near-black colour perceptibly.
const std::vector<ColorRGB> palette = chroma_palette(stack);
if (palette.empty())
return;
int layer = std::max(0, stack.base_layers); // palette index of the layer under the band being looked at
for (ChromaBand &band : stack.bands) {
int keep = 1;
for (int i = 1; i <= band.layers; ++i) {
const size_t cur = size_t(layer) + size_t(i);
if (cur >= palette.size())
break;
if (step_delta_e(palette[cur - 1], palette[cur]) > VISIBLE_DE)
keep = i; // the last layer of this band that still changes the colour
}
layer += band.layers;
band.layers = std::clamp(keep, 1, band.layers);
}
}
int chroma_level_at(const ChromaStack &stack, double z_mm)
{
const int total = stack.total_layers();
if (total <= 0 || stack.layer_height_mm <= 0.f)
return 0;
// Layer k occupies ((k-1)h, k h], so a point inside it rounds up. The epsilon keeps a surface
// sitting exactly on a layer boundary in the layer that printed it rather than in the one above,
// which is the difference between a terrace showing its own colour and showing the next band's.
const int k = int(std::ceil(z_mm / double(stack.layer_height_mm) - 1e-6));
return std::clamp(k, 1, total);
}
std::vector<unsigned char> chroma_encode_height_png(const ChromaSolve &solve)
{
if (solve.empty())
return {};
size_t size = 0;
void *png = tdefl_write_image_to_png_file_in_memory_ex(solve.grey.data(), solve.width, solve.height, 1, &size,
MZ_DEFAULT_LEVEL, MZ_FALSE);
if (png == nullptr || size == 0)
return {};
const unsigned char *bytes = static_cast<const unsigned char *>(png);
std::vector<unsigned char> out(bytes, bytes + size);
mz_free(png);
return out;
}
std::vector<ChromaRange> chroma_ranges(const ChromaStack &stack, double z_bottom)
{
std::vector<ChromaRange> out;
if (stack.layer_height_mm <= 0.f)
return out;
// The layer height is stored as a float, like every other dimension of a texture displacement
// layer, so multiplying it up leaves noise in the last few digits - 5 layers of 0.1 mm come out as
// 0.5000000074. The slicer derives its own layer boundaries from the print config in double
// precision and lands on 0.5 exactly, and a boundary that misses it by any amount is a boundary a
// layer can fall on the wrong side of. Rounding to the nanometre removes the noise without
// disturbing any layer height a profile can express.
const auto snap = [](double z) { return std::round(z * 1e6) / 1e6; };
// The base is printed in the bottom band's filament - it is what is loaded when the print starts -
// so it extends that band's range down to the first layer instead of being a swap of its own.
int layer = std::max(0, stack.base_layers);
bool first = true;
for (const ChromaBand &band : stack.bands) {
if (band.layers <= 0)
continue;
const double z_min = snap(z_bottom + (first ? 0. : double(layer) * double(stack.layer_height_mm)));
first = false;
layer += band.layers;
const double z_max = snap(z_bottom + double(layer) * double(stack.layer_height_mm));
if (!out.empty() && out.back().filament == band.filament.filament)
out.back().z_max = z_max; // no swap happens here, so it is one range
else
out.push_back(ChromaRange{ z_min, z_max, band.filament.filament });
}
return out;
}
ChromaStack chroma_default_stack(const std::vector<ChromaFilament> &filaments, float layer_height_mm,
int base_layers, int relief_layers)
{
ChromaStack stack;
stack.layer_height_mm = layer_height_mm > 0.f ? layer_height_mm : 0.08f;
stack.base_layers = std::max(0, base_layers);
if (filaments.empty() || relief_layers <= 0)
return stack;
std::vector<ChromaFilament> ordered = filaments;
std::stable_sort(ordered.begin(), ordered.end(), [](const ChromaFilament &a, const ChromaFilament &b) {
// Relative luminance of the linear colour: the perceptual order, so a saturated yellow
// counts as light and a saturated blue as dark, which a plain r+g+b sum gets wrong.
auto lum = [](const ColorRGB &c) {
return 0.2126f * srgb_to_linear(c.r()) + 0.7152f * srgb_to_linear(c.g()) + 0.0722f * srgb_to_linear(c.b());
};
return lum(a.color) > lum(b.color);
});
const int count = int(ordered.size());
int left = relief_layers;
for (int i = 0; i < count; ++i) {
// Spread the remainder over the first bands rather than piling it onto the last one.
const int share = left / (count - i) + ((left % (count - i)) > 0 ? 1 : 0);
ChromaBand band;
band.filament = ordered[size_t(i)];
band.layers = std::max(1, share);
left -= share;
stack.bands.push_back(band);
}
return stack;
}
} // namespace Slic3r
-286
View File
@@ -1,286 +0,0 @@
#ifndef slic3r_ChromaStack_hpp_
#define slic3r_ChromaStack_hpp_
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include <cereal/cereal.hpp>
#include <cereal/types/vector.hpp>
#include "Color.hpp"
namespace Slic3r {
// ChromaStack-style colour printing: a picture is reproduced on a single face by stacking thin layers
// of a few filaments, swapping filament at fixed layer boundaries, and varying the *height* of each
// pixel's column. Where the column is low, the layers above it are missing and the filament below
// shows; where it is high, the topmost filament covers everything beneath it. Because filament is
// translucent, a column that stops part-way through a run shows a blend of that run's filament and
// what is under it, which is what gives far more colours than there are filaments loaded.
//
// So there are only two things to compute, and they are both here:
// - the *palette*: the colour the stack shows at every possible column height (chroma_palette());
// - the *height map*: per pixel, the column height whose palette colour is closest to the picture
// (chroma_solve()).
//
// Everything else is machinery that already exists. The height map is handed to a texture
// displacement layer as its height texture, so the existing projection/preview/bake pipeline turns
// it into real geometry (see TextureDisplacement.hpp), and the filament swaps are handed to the
// object's height ranges (ModelObject::layer_config_ranges), which is the slicer's own way of
// saying "print this Z band with that filament".
// The depth of filament that blocks essentially all the light behind it, as a fraction of the
// filament's transmission distance (TD).
//
// TD's scale is empirical, not physical: its values run from about 1
// for an opaque filament to 10 and above for a nearly clear one, and the community's calibration
// prints (the "seashell" step test) read a TD off the step where the colour beneath stops showing.
// That step is about a tenth of the TD number, which is the only thing this constant encodes - a
// filament of TD 2 stops hiding what is under it at roughly 0.2 mm, i.e. after two or three layers,
// while one of TD 8 needs most of a millimetre. The model below turns that into a per-layer
// transmittance; see chroma_palette().
static constexpr float CHROMA_TD_OPAQUE_FRACTION = 0.1f;
// What a fresh stack gets above the base. Twenty-four layers is about two millimetres at a fine layer
// height: enough steps for a continuous tonal ramp, and still a print that finishes in an evening.
static constexpr int CHROMA_DEFAULT_RELIEF_LAYERS = 24;
// The most picture layers the solve can express. A column's height above the base is stored as one
// byte per pixel in the 8-bit grayscale height map the displacement reads, so beyond this two
// different heights would have to share a grey level and the relief's steps would stop landing on
// layer boundaries - which is the one thing the quantization exists to guarantee.
//
// It bounds the bands, not the stack: the base is whatever the model already is under the painted
// face, often hundreds of layers on a tall part, and nothing about it is quantized into the height
// map. As a limit on the picture it is not one in practice - 255 layers is 20 mm of relief at
// 0.08 mm, far past where a picture gains anything.
static constexpr int CHROMA_MAX_RELIEF_LAYERS = 255;
// The most filament changes one stack may hold. Every band is a swap the printer has to make, so this is
// a limit on patience rather than on the model; the palette itself is bounded by the layer count.
static constexpr size_t CHROMA_MAX_BANDS = 16;
// One filament as the colour model sees it: what colour it is, and how far light gets into it.
struct ChromaFilament
{
// 0-based index into the project's filaments, which is what a height range's `extruder` option
// (1-based) is written from. -1 for a filament that is not loaded, which the GUI refuses to
// solve with rather than silently printing in the wrong colour.
int filament = -1;
ColorRGB color;
// The filament preset's own name, so the panel can say which filament this is. Six similar greys
// are six identical swatches and six identical numbers otherwise. Stored rather than looked up on
// demand because a stack outlives the project's filament list - a preset can be swapped out from
// under a saved stack, and then the name is the only record of what it was solved against.
std::string name;
// Transmission distance, mm, as the filament preset reports it
// (`filament_transmission_distance`). Clamped to a sane range when the palette is built: zero
// would make a layer infinitely opaque and a negative value is meaningless.
float td_mm = 4.f;
template<class Archive> void serialize(Archive &ar)
{
float r = color.r(), g = color.g(), b = color.b();
ar(filament, r, g, b, td_mm, name);
color = ColorRGB(r, g, b);
}
};
// A run of consecutive layers printed in one filament. The stack is a list of these, bottom-up, so
// a band boundary is exactly where the print pauses and the filament is changed.
struct ChromaBand
{
ChromaFilament filament;
int layers = 1;
template<class Archive> void serialize(Archive &ar) { ar(filament, layers); }
};
// The whole recipe: which filaments, in which order, how thick a layer is, and how much of the
// bottom is solid backing rather than picture.
struct ChromaStack
{
std::vector<ChromaBand> bands; // bottom-up; bands.front() is the one the base is printed in
// The print's layer height, mm. Taken from the print profile rather than chosen here - a stack
// solved against 0.08 mm layers prints as the wrong picture at 0.2 mm, so the GUI re-solves when
// the profile's layer height changes.
float layer_height_mm = 0.08f;
// Layers of solid backing under the picture: everything the model already is below the painted
// face. Not part of any band - the bands are the picture - but printed in the bottom band's
// filament, since that is what is loaded when the print starts.
//
// Keeping it out of the bands is what lets it be what it really is: a measurement of the model,
// often a hundred layers or more, while a band is a handful. Folding the two together put a
// three-digit number on a slider meant for single digits and made "how thick is the backing" and
// "how many layers does the first filament get" the same control, which they are not.
//
// The colour model treats the base as opaque. That is what makes a column's colour depend only on
// the layers above it, and why the panel warns about a base thin enough to let the plate show
// through.
int base_layers = 4;
// Whether `base_layers` follows the model or was typed in. It follows by default, and it should:
// the painted face is wherever the geometry puts it, and a base that disagrees with it shifts
// every colour in the picture - the stack ends up anchored at the bottom of the part instead of
// under the face, so the picture's whole range is squeezed into the first few millimetres and
// everything above it clamps to the top band.
//
// It cannot be measured once and left: a layer is created, and its picture chosen, before there
// is any paint to measure, and the face moves again whenever the part is scaled or the layer
// height changes. So the panel re-measures it every frame while this is set.
bool base_auto = true;
// Spread the quantization error into neighbouring pixels (Floyd-Steinberg, serpentine) instead
// of rounding each pixel to its nearest palette colour on its own. A palette of twenty or thirty
// colours has to cover a photograph's whole range, so plain rounding leaves visible bands across
// a sky or a cheek; diffusing the error trades them for fine stipple, which at a tenth of a
// millimetre per pixel reads as the intermediate colour. Off for flat-coloured artwork, where
// stipple is the artifact rather than the cure.
bool dither = false;
// Total printed layers, base included: what the tallest column is.
int total_layers() const;
// Layers the picture is built from, i.e. the bands' own total above the base.
int relief_layers() const;
// The relief's height in mm, which is the `depth_mm` of the texture displacement layer the
// height map is handed to.
float depth_mm() const;
bool empty() const { return bands.empty(); }
template<class Archive> void serialize(Archive &ar) { ar(bands, layer_height_mm, base_layers, dither, base_auto); }
};
// The colour the stack shows at every column height: entry k is a column of exactly k printed
// layers, so the vector has total_layers() + 1 entries.
//
// The model is Beer-Lambert absorption, layer by layer. A layer of thickness h in a filament of
// transmission distance TD passes
//
// T = exp(-h / (CHROMA_TD_OPAQUE_FRACTION * TD / 3))
//
// of the light coming from under it and shows its own colour with the rest, so
//
// C_k = (1 - T_k) * c_k + T_k * C_(k-1)
//
// walking up from an opaque base, C_0 = c_1. The divisor is what makes "opaque at
// CHROMA_TD_OPAQUE_FRACTION * TD" true: three e-folding lengths pass 5% of the light, which is
// where the colour beneath stops being visible to the eye.
//
// Beer-Lambert is not just the physically honest choice here, it is the only one that stays
// consistent when applied per layer: transmittance multiplies, so a run of n layers of one filament
// passes exp(-n h / d) whether it is composited in one step or n, and the palette does not depend on
// how the stack happens to be cut into bands.
//
// Compositing is done on linear light and the entries are converted back to sRGB, because the
// quantity being attenuated is light, not an sRGB code.
std::vector<ColorRGB> chroma_palette(const ChromaStack &stack);
// What chroma_solve() produced.
struct ChromaSolve
{
int width = 0;
int height = 0;
// Per pixel, row-major and top-to-bottom like DecodedHeightTexture: the column's printed layer
// count, between base_layers and total_layers(). Sixteen bits because it counts from the build
// plate, and the base alone can be several hundred layers on a tall part.
std::vector<uint16_t> levels;
// The same thing as the 8-bit grayscale height map the displacement samples: 0 at base_layers,
// 255 at total_layers(). This is what gets encoded as a PNG into the layer's `image_data`, which
// is why the solve has to be the thing that owns the quantization - the relief's steps have to
// land on layer boundaries, and a grey level is the only channel the displacement reads.
std::vector<uint8_t> grey;
// Per pixel RGB: the palette colour the solve chose, i.e. what the print is predicted to look
// like. Shown in the panel next to the source picture, since the 3D view can only show the
// relief - the colours do not exist until the print swaps filament.
std::vector<uint8_t> preview;
// How far the prediction landed from the picture, as a mean and worst-case CIE76 dE over all
// pixels. The panel reports it because it is the only honest answer to "will this stack
// reproduce my image": under about 5 is a good match, over about 15 means the filaments cannot
// reach the picture's colours and no amount of layers will fix it.
double mean_delta_e = 0.;
double max_delta_e = 0.;
bool empty() const { return width <= 0 || height <= 0 || levels.empty(); }
};
// Solves `rgb` (width * height * 3 bytes, row-major, top-to-bottom) against the stack's palette.
// An empty stack, a zero-sized image or a stack with no relief layers gives an empty result.
ChromaSolve chroma_solve(const uint8_t *rgb, int width, int height, const ChromaStack &stack);
// How many layers of this filament it takes to hide what is under it, at this layer height. The
// single most useful number about a stack and the one nothing else makes visible: it is what decides
// whether a band is a gradient or a step.
//
// A filament hides what is behind it at about CHROMA_TD_OPAQUE_FRACTION of its TD (see
// chroma_palette()), so this is that depth in layers. Below two the band has no intermediate colours
// at all - the picture can only use the filament or not use it - which is what a coarse layer height
// does to an otherwise good stack: TD 4 at 0.2 mm covers in two layers, the same filament at 0.08 mm
// in five.
float chroma_layers_to_opaque(float td_mm, float layer_height_mm);
// How many of the picture's layers actually change the colour.
//
// A band eventually stops saying anything: once it hides what is under it, another layer of it shows
// the colour the one below already showed. Those layers still cost print time and still take up
// relief height, and nothing about the stack makes them visible - the band looks like ten layers of
// gradient and is, say, five of gradient and five of flat.
//
// Measured against the palette rather than against TD, because how long a band keeps paying depends
// on what is under it as much as on the filament: a light band over a dark one is finished in four or
// five layers, while a dark one over a light one goes on moving the colour perceptibly well past the
// depth at which it is nominally opaque.
int chroma_distinct_levels(const ChromaStack &stack);
// Shortens every band to its last layer that still changes the colour. Bands already shorter than
// that are left alone: they are still inside their fade, where every layer counts.
void chroma_trim_bands(ChromaStack &stack);
// The printed layer a point `z_mm` above the bottom of the object falls in: 1-based, clamped to the
// stack, 0 for a stack that prints nothing. Pair it with chroma_palette() to get the colour shown
// there.
//
// This, rather than the height map, is what decides the colour a surface shows. A filament swap
// happens at one print Z across the whole plate, so every surface crossing that height changes colour
// whatever the picture says - the terraces of the relief, the walls between them, and the sides of the
// model alike. Colouring a preview this way is therefore not an approximation of the print; it is what
// the print does, and it keeps working after the relief has been baked into the mesh and the height
// map is no longer attached to anything on screen.
int chroma_level_at(const ChromaStack &stack, double z_mm);
// The solve's height map as an 8-bit grayscale PNG, ready to be stored in a
// TextureDisplacementLayer's `image_data`.
std::vector<unsigned char> chroma_encode_height_png(const ChromaSolve &solve);
// One filament swap as an object height range: the Z band, in the object's own coordinates (0 at its
// bottom), that prints in `filament` (0-based).
struct ChromaRange
{
double z_min = 0.;
double z_max = 0.;
int filament = -1;
};
// The stack's bands as height ranges, in object coordinates, starting at `z_bottom` (which is 0 for
// a stack whose base starts at the object's first layer). Bands printed in the same filament as the
// band below are merged, so a stack that repeats a filament does not produce a pointless swap.
std::vector<ChromaRange> chroma_ranges(const ChromaStack &stack, double z_bottom = 0.);
// A starting point for a stack: the given filaments, lightest at the bottom, each with an equal
// share of `relief_layers` on top of `base_layers`.
//
// Light-to-dark is the convention filament-painting guides teach, and it is not arbitrary: a dark
// filament over a light one reads as a midtone at one or two layers and as the dark colour at four,
// so putting the dark filaments last is what buys the long tonal ramp. The reverse order gives a few
// washed-out steps and then nothing.
ChromaStack chroma_default_stack(const std::vector<ChromaFilament> &filaments, float layer_height_mm,
int base_layers, int relief_layers);
} // namespace Slic3r
#endif // slic3r_ChromaStack_hpp_
+37 -13
View File
@@ -841,8 +841,12 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
}
bool operator==(const ConfigOption &rhs) const override
@@ -1033,8 +1037,12 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
int value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
}
ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1252,8 +1260,12 @@ public:
UNUSED(append);
// don't try to parse the trailing % since it's optional
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
}
private:
@@ -1365,10 +1377,14 @@ public:
bool deserialize(const std::string &str, bool append = false) override
{
UNUSED(append);
this->percent = str.find_first_of("%") != std::string::npos;
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
}
private:
@@ -1538,8 +1554,12 @@ public:
{
UNUSED(append);
char dummy;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
Vec2d value;
if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
return false;
this->value = value;
return true;
}
private:
@@ -1658,8 +1678,12 @@ public:
{
UNUSED(append);
char dummy;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
Vec3d value;
if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
return false;
this->value = value;
return true;
}
private:
+10
View File
@@ -3985,6 +3985,16 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
this->placeholder_parser().set("print_time_minute", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder)));
this->placeholder_parser().set("print_time_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder)));
this->placeholder_parser().set("used_filament_length", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
// The final print statistics are known only after the whole G-code is generated, so they are filled in during post-processing too.
// The running extruded_* totals are left alone; profiles use them as numbers.
this->placeholder_parser().set("used_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
this->placeholder_parser().set("print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)));
this->placeholder_parser().set("normal_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)));
this->placeholder_parser().set("silent_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Silent_Print_Time_Placeholder)));
this->placeholder_parser().set("total_weight", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Weight_Placeholder)));
this->placeholder_parser().set("total_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Cost_Placeholder)));
this->placeholder_parser().set("total_wipe_tower_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Cost_Placeholder)));
this->placeholder_parser().set("total_wipe_tower_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Filament_Placeholder)));
// Sync variant-mapped params into placeholder_parser before processing start gcode
update_placeholder_parser_with_variant_params();
+47 -71
View File
@@ -120,7 +120,13 @@ const std::vector<std::string> GCodeProcessor::Reserved_Tags = {
"@PRINT_TIME_HOUR@",
"@PRINT_TIME_MINUTE@",
"@PRINT_TIME_SEC@",
"@USED_FILAMENT_LENGTH@"
"@USED_FILAMENT_LENGTH@",
"@NORMAL_PRINT_TIME@",
"@SILENT_PRINT_TIME@",
"@TOTAL_WEIGHT@",
"@TOTAL_COST@",
"@TOTAL_WIPE_TOWER_COST@",
"@TOTAL_WIPE_TOWER_FILAMENT@"
};
const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
@@ -147,7 +153,13 @@ const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
"@PRINT_TIME_HOUR@",
"@PRINT_TIME_MINUTE@",
"@PRINT_TIME_SEC@",
"@USED_FILAMENT_LENGTH@"
"@USED_FILAMENT_LENGTH@",
"@NORMAL_PRINT_TIME@",
"@SILENT_PRINT_TIME@",
"@TOTAL_WEIGHT@",
"@TOTAL_COST@",
"@TOTAL_WIPE_TOWER_COST@",
"@TOTAL_WIPE_TOWER_FILAMENT@"
};
@@ -1264,19 +1276,22 @@ void GCodeProcessor::run_post_process()
return ret;
};
// Process inline placeholders (print_time_total_sec, print_time_day, print_time_hour, print_time_minute, print_time_sec and used_filament_length)
// Process inline placeholders (print_time_*, used_filament_length and the print statistics known only after export)
auto process_inline_placeholders = [&](std::string& gcode_line) {
bool processed = false;
// Every inline placeholder contains '@', so a line without one has nothing to replace.
if (gcode_line.find('@') == std::string::npos)
return processed;
const std::string& print_time_total_placeholder = reserved_tag(ETags::Print_Time_Total_Sec_Placeholder);
const std::string& print_time_day_placeholder = reserved_tag(ETags::Print_Time_Day_Placeholder);
const std::string& print_time_hour_placeholder = reserved_tag(ETags::Print_Time_Hour_Placeholder);
const std::string& print_time_minute_placeholder = reserved_tag(ETags::Print_Time_Minute_Placeholder);
const std::string& print_time_sec_placeholder = reserved_tag(ETags::Print_Time_Sec_Placeholder);
const std::string& used_filament_placeholder = reserved_tag(ETags::Used_Filament_Length_Placeholder);
auto replace_tag = [&gcode_line, &processed](ETags tag, const std::string& value) {
const std::string& placeholder = reserved_tag(tag);
size_t pos = gcode_line.find(placeholder);
while (pos != std::string::npos) {
gcode_line.replace(pos, placeholder.length(), value);
processed = true;
pos = gcode_line.find(placeholder, pos + value.length());
}
};
double print_time_total_sec = m_time_processor.machines[static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Normal)].time;
if (print_time_total_sec < 0.0)
@@ -1289,70 +1304,31 @@ void GCodeProcessor::run_post_process()
int print_time_minute = (day_remainder_seconds % 3600) / 60;
int print_time_sec = day_remainder_seconds % 60;
// Replace print_time_total_sec
size_t pos = gcode_line.find(print_time_total_placeholder);
while (pos != std::string::npos) {
char buf[64];
sprintf(buf, "%.2f", print_time_total_sec);
gcode_line.replace(pos, print_time_total_placeholder.length(), buf);
processed = true;
pos = gcode_line.find(print_time_total_placeholder, pos + strlen(buf));
double total_filament_mm = 0.0;
for (const auto& mm : filament_mm) {
total_filament_mm += mm;
}
// Replace print_time_day
pos = gcode_line.find(print_time_day_placeholder);
while (pos != std::string::npos) {
char buf[64];
sprintf(buf, "%d", print_time_day);
gcode_line.replace(pos, print_time_day_placeholder.length(), buf);
processed = true;
pos = gcode_line.find(print_time_day_placeholder, pos + strlen(buf));
}
// Replace print_time_hour
pos = gcode_line.find(print_time_hour_placeholder);
while (pos != std::string::npos) {
char buf[64];
sprintf(buf, "%d", print_time_hour);
gcode_line.replace(pos, print_time_hour_placeholder.length(), buf);
processed = true;
pos = gcode_line.find(print_time_hour_placeholder, pos + strlen(buf));
}
// Replace print_time_minute
pos = gcode_line.find(print_time_minute_placeholder);
while (pos != std::string::npos) {
char buf[64];
sprintf(buf, "%d", print_time_minute);
gcode_line.replace(pos, print_time_minute_placeholder.length(), buf);
processed = true;
pos = gcode_line.find(print_time_minute_placeholder, pos + strlen(buf));
}
// Replace print_time_sec
pos = gcode_line.find(print_time_sec_placeholder);
while (pos != std::string::npos) {
char buf[64];
sprintf(buf, "%d", print_time_sec);
gcode_line.replace(pos, print_time_sec_placeholder.length(), buf);
processed = true;
pos = gcode_line.find(print_time_sec_placeholder, pos + strlen(buf));
}
// Replace used_filament_length
pos = gcode_line.find(used_filament_placeholder);
while (pos != std::string::npos) {
double total_filament_mm = 0.0;
for (const auto& mm : filament_mm) {
total_filament_mm += mm;
}
double used_filament_length = total_filament_mm / 1000.0; // Convert mm to m
char buf[64];
sprintf(buf, "%.2f", used_filament_length);
gcode_line.replace(pos, used_filament_placeholder.length(), buf);
processed = true;
pos = gcode_line.find(used_filament_placeholder, pos + strlen(buf));
}
replace_tag(ETags::Print_Time_Total_Sec_Placeholder, Slic3r::float_to_string_decimal_point(print_time_total_sec, 2));
replace_tag(ETags::Print_Time_Day_Placeholder, std::to_string(print_time_day));
replace_tag(ETags::Print_Time_Hour_Placeholder, std::to_string(print_time_hour));
replace_tag(ETags::Print_Time_Minute_Placeholder, std::to_string(print_time_minute));
replace_tag(ETags::Print_Time_Sec_Placeholder, std::to_string(print_time_sec));
replace_tag(ETags::Used_Filament_Length_Placeholder,
Slic3r::float_to_string_decimal_point(total_filament_mm / 1000.0, 2)); // Convert mm to m
// Same values as the print statistics that filename_format gets (PrintStatistics::config(), filled in by
// update_print_estimated_stats() in GCode.cpp), which runs only after this post-processing.
replace_tag(ETags::Normal_Print_Time_Placeholder, get_time_dhm(PrintEstimatedStatistics::ETimeMode::Normal));
replace_tag(ETags::Silent_Print_Time_Placeholder, is_stealth_time_estimator_enabled() ?
get_time_dhm(PrintEstimatedStatistics::ETimeMode::Stealth) :
short_time("N/A"));
const double total_cost = filament_total_cost + m_print->config().time_cost.value * print_time_total_sec / 3600.0;
replace_tag(ETags::Total_Weight_Placeholder, Slic3r::float_to_string_decimal_point(filament_total_g, 2));
replace_tag(ETags::Total_Cost_Placeholder, Slic3r::float_to_string_decimal_point(total_cost, 2));
replace_tag(ETags::Total_Wipe_Tower_Cost_Placeholder,
Slic3r::float_to_string_decimal_point(m_print->print_statistics().total_wipe_tower_cost, 2));
replace_tag(ETags::Total_Wipe_Tower_Filament_Placeholder,
Slic3r::float_to_string_decimal_point(m_print->print_statistics().total_wipe_tower_filament, 2));
return processed;
};
+6
View File
@@ -601,6 +601,12 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f;
Print_Time_Minute_Placeholder,
Print_Time_Sec_Placeholder,
Used_Filament_Length_Placeholder,
Normal_Print_Time_Placeholder,
Silent_Print_Time_Placeholder,
Total_Weight_Placeholder,
Total_Cost_Placeholder,
Total_Wipe_Tower_Cost_Placeholder,
Total_Wipe_Tower_Filament_Placeholder,
};
static const std::string& reserved_tag(ETags tag) { return s_IsBBLPrinter ? Reserved_Tags[static_cast<unsigned char>(tag)] : Reserved_Tags_compatible[static_cast<unsigned char>(tag)]; }
+65 -24
View File
@@ -62,7 +62,12 @@ public:
// Depth in the hierarchy. External perimeter has depth = 0. An external perimeter could be both a contour and a hole.
unsigned short depth;
// ORCA: an external hole perimeter touching the next outer wall all around, with nothing between them.
// E.g. a 1mm tube wall with 0.5mm walls: 2 lines, the contour's outer wall and the hole's.
bool is_thin_wall_hole = false;
// ORCA: an external hole perimeter with no room for an inner wall beside it, at most gap fill. Every thin wall
// hole is one, and so is e.g. a 1.5mm tube wall with 0.5mm walls: 3 lines, the two outer walls and gap fill
// between them. A 2mm tube wall has room for an inner wall a side and is not. Only set for Inner/Outer/Inner.
bool is_narrow_wall_hole = false;
// Children contour, may be both CCW and CW oriented (outer contours or holes).
std::vector<PerimeterGeneratorLoop> children;
@@ -572,7 +577,8 @@ static ExtrusionEntityCollection traverse_extrusions(const PerimeterGenerator& p
extrusion_coll.append(std::move(extrusion_loop));
// Orca: Reverse the order of paths for thin wall holes. We define thin wall hole as a hole with only one perimeter.
const bool thin_wall_hole = !pg_extrusion.is_contour && pg_extrusions.size() == 2;
if (thin_wall_hole && perimeter_generator.config->wall_sequence != WallSequence::OuterInner)
if (thin_wall_hole && perimeter_generator.config->wall_sequence != WallSequence::OuterInner &&
!perimeter_generator.outer_walls_first_for_brim())
std::reverse(extrusion_coll.entities.begin(), extrusion_coll.entities.end());
}
else {
@@ -1690,13 +1696,24 @@ void PerimeterGenerator::process_classic()
last = std::move(offsets);
// ORCA: a thin wall hole has no room for gap fill or an inner wall anywhere beside its outer wall.
// ORCA: a thin wall hole has no room for gap fill or an inner wall anywhere beside its outer wall, a
// narrow wall hole none for an inner wall, the room the next loop is offset by below.
if (i == 0 && ! holes[0].empty()) {
const float room = float(0.5 * (ext_perimeter_spacing2 + gap_fill_min_width));
const Polygons reach = to_polygons(offset2_ex(last, -room, room + float(SCALED_EPSILON)));
for (PerimeterGeneratorLoop &hole : holes[0])
hole.is_thin_wall_hole = intersection_pl(Polylines{ hole.polygon.split_at_first_point() },
ClipperUtils::clip_clipper_polygons_with_subject_bbox(reach, get_extents(hole.polygon).inflated(SCALED_EPSILON))).empty();
auto reach = [&last](double room) {
return to_polygons(offset2_ex(last, -float(room), float(room) + float(SCALED_EPSILON)));
};
auto out_of_reach = [](const Polygon &hole, const Polygons &reach) {
return intersection_pl(Polylines{ hole.split_at_first_point() },
ClipperUtils::clip_clipper_polygons_with_subject_bbox(reach, get_extents(hole).inflated(SCALED_EPSILON))).empty();
};
// Only Inner/Outer/Inner orders narrow wall holes apart.
const bool narrow_holes_ordered = this->config->wall_sequence == WallSequence::InnerOuterInner;
const Polygons gap_fill_reach = reach(0.5 * (ext_perimeter_spacing2 + gap_fill_min_width));
const Polygons inner_wall_reach = narrow_holes_ordered ? reach(ext_perimeter_spacing2 + min_spacing / 2.) : Polygons();
for (PerimeterGeneratorLoop &hole : holes[0]) {
hole.is_thin_wall_hole = out_of_reach(hole.polygon, gap_fill_reach);
hole.is_narrow_wall_hole = narrow_holes_ordered && out_of_reach(hole.polygon, inner_wall_reach);
}
}
//BBS: refer to superslicer
@@ -1835,18 +1852,16 @@ void PerimeterGenerator::process_classic()
// if brim will be printed, reverse the order of perimeters so that
// we continue inwards after having finished the brim
// TODO: add test for perimeter order
bool is_outer_wall_first = this->config->wall_sequence == WallSequence::OuterInner;
if (is_outer_wall_first ||
//BBS: always print outer wall first when there indeed has brim.
// btLeadingEdgeOnly is an outer brim too (a belt brim at the part's first contact).
(this->layer_id == 0 &&
(this->object_config->brim_type == BrimType::btOuterOnly ||
this->object_config->brim_type == BrimType::btLeadingEdgeOnly) &&
this->object_config->brim_width.value > 0))
// Orca: a contour with only a narrow wall hole beside it has nothing to sandwich, and prints the hole first,
// then the contour, then the gap fill between them, as Arachne does.
const PerimeterGeneratorLoops &island_loops = contours.front();
const bool narrow_wall_hole_island = island_loops.size() == 1 && island_loops.front().children.size() == 1 &&
island_loops.front().children.front().is_narrow_wall_hole;
if (is_outer_wall_first || this->outer_walls_first_for_brim())
entities.reverse();
// Orca: sandwich mode. Apply after 1st layer.
else if ((this->config->wall_sequence == WallSequence::InnerOuterInner) && layer_id > 0){
else if ((this->config->wall_sequence == WallSequence::InnerOuterInner) && layer_id > 0 && !narrow_wall_hole_island){
entities.reverse(); // reverse all entities - order them from external to internal
if(entities.entities.size()>2){ // 3 walls minimum needed to do inner outer inner ordering
int position = 0; // index to run the re-ordering for multiple external perimeters in a single island.
@@ -2412,11 +2427,25 @@ std::vector<PerimeterGeneratorArachneExtrusion> reorderPerimetersByProximity(std
// Function to reorder perimeters starting from a given reference index
auto reorderFromReference = [&](int referenceIndex) {
std::unordered_set<int> firstLevelIndices;
firstLevelIndices.insert(referenceIndex);
// Find first level touching perimeters
std::vector<int> firstLevelTouchingIndices = findAllTouchingPerimeters(entities, firstLevelIndices, threshold_external, threshold_internal, 1);
// Find first level touching perimeters. The odd centre line of a wall too narrow for two inner walls is shared
// by the outer walls on both sides, so those join this island and are printed before it too, holes first.
std::vector<int> outerIndices{referenceIndex};
std::vector<int> firstLevelTouchingIndices;
for (size_t next = 0; next < outerIndices.size(); ++next)
for (int idx : findAllTouchingPerimeters(entities, {outerIndices[next]}, threshold_external, threshold_internal, 1)) {
if (std::find(firstLevelTouchingIndices.begin(), firstLevelTouchingIndices.end(), idx) != firstLevelTouchingIndices.end())
continue;
firstLevelTouchingIndices.push_back(idx);
if (!entities[idx].extrusion->is_odd)
continue;
for (size_t i = 0; i < entities.size(); ++i)
if (entities[i].extrusion->inset_idx == 0 && includedIndices.count(int(i)) == 0 &&
std::find(outerIndices.begin(), outerIndices.end(), int(i)) == outerIndices.end() &&
arachne_lines_touch(*entities[i].extrusion, *entities[idx].extrusion, double(threshold_external)))
outerIndices.push_back(int(i));
}
std::stable_partition(outerIndices.begin(), outerIndices.end(),
[&entities](int idx) { return entities[idx].extrusion->is_closed && !entities[idx].is_contour; });
// Bring the largest first level perimeter to the front
// The longest first neighbour is most likely the dominant proximate perimeter
// hence printing it immediately after the external perimeter should speed things up
@@ -2427,8 +2456,10 @@ std::vector<PerimeterGeneratorArachneExtrusion> reorderPerimetersByProximity(std
std::iter_swap(maxIt, firstLevelTouchingIndices.end() - 1);
}
// Insert first level perimeters into reordered list
reordered.push_back(entities[referenceIndex]);
includedIndices.insert(referenceIndex);
for (int idx : outerIndices) {
reordered.push_back(entities[idx]);
includedIndices.insert(idx);
}
for (int idx : firstLevelTouchingIndices) {
if (includedIndices.count(idx) == 0) {
@@ -2757,7 +2788,7 @@ void PerimeterGenerator::process_arachne()
if (layer_id == 0){ // disable inner outer inner algorithm after the first layer
is_outer_wall_first =
this->config->wall_sequence == WallSequence::OuterInner;
this->config->wall_sequence == WallSequence::OuterInner || this->outer_walls_first_for_brim();
}
if (is_outer_wall_first) {
start_perimeter = 0;
@@ -3042,6 +3073,16 @@ ExPolygons PerimeterGenerator::printable_slices(const ExPolygons &slices) const
return min_width > EPSILON ? opening_ex(slices, float(scale_(min_width / 2.))) : slices;
}
bool PerimeterGenerator::outer_walls_first_for_brim() const
{
return layer_id == 0 && brim_puts_outer_walls_first(object_config->brim_type.value, object_config->brim_width.value);
}
bool brim_puts_outer_walls_first(BrimType brim_type, double brim_width)
{
return brim_width > 0 && (brim_type == btOuterOnly || brim_type == btOuterAndInner || brim_type == btLeadingEdgeOnly);
}
std::vector<Polygons> PerimeterGenerator::generate_lower_polygons_series(float width)
{
float nozzle_diameter = print_config->nozzle_diameter.get_at(config->outer_wall_filament_id - 1);
+6
View File
@@ -164,6 +164,8 @@ public:
Polygons lower_slices_polygons() const { return m_lower_slices_polygons; }
// ORCA: the slices less the slivers the wall generator prints nothing for, so they never count as support.
ExPolygons printable_slices(const ExPolygons &slices) const;
// ORCA: whether this layer prints its outer walls first for the brim, see brim_puts_outer_walls_first().
bool outer_walls_first_for_brim() const;
private:
std::vector<Polygons> generate_lower_polygons_series(float width);
@@ -183,6 +185,10 @@ private:
Polygons m_lower_slices_polygons;
};
// ORCA: a brim against the outer walls makes the first layer print them first, whatever the wall sequence, so the
// brim runs straight on into the outer wall. Auto, ear and painted brims may leave the outer walls bare.
bool brim_puts_outer_walls_first(BrimType brim_type, double brim_width);
}
#endif
+2 -1
View File
@@ -1489,7 +1489,7 @@ static std::vector<std::string> s_Preset_print_options{
static std::vector<std::string> s_Preset_filament_options {/*"filament_colour", */ "default_filament_colour", "required_nozzle_HRC", "filament_diameter", "pellet_flow_coefficient", "volumetric_speed_coefficients", "filament_type",
"filament_soluble", "filament_is_support", "filament_printable", "filament_extruder_compatibility",
"filament_max_volumetric_speed", "filament_adaptive_volumetric_speed",
"filament_flow_ratio", "filament_density", "filament_transmission_distance", "filament_adhesiveness_category", "filament_cost", "filament_minimal_purge_on_wipe_tower",
"filament_flow_ratio", "filament_density", "filament_adhesiveness_category", "filament_cost", "filament_minimal_purge_on_wipe_tower",
"filament_tower_interface_pre_extrusion_dist", "filament_tower_interface_pre_extrusion_length", "filament_tower_ironing_area", "filament_tower_interface_purge_volume",
"filament_tower_interface_print_temp",
"nozzle_temperature", "nozzle_temperature_initial_layer",
@@ -2293,6 +2293,7 @@ void PresetCollection::load_project_embedded_presets(std::vector<Preset*>& proje
BOOST_LOG_TRIVIAL(error) << boost::format("can not find parent for config %1%!")%preset->file;
continue;
}
extend_default_config_length(config, false, {});
preset->config.update_diff_values_to_child_config(config, extruder_id_name, extruder_variant_name, *key_set1, *key_set2);
//preset->config.apply(std::move(config));
Preset::normalize(preset->config);
+8 -1
View File
@@ -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; }
-1
View File
@@ -228,7 +228,6 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
"filament_diameter",
"volumetric_speed_coefficients",
"filament_density",
"filament_transmission_distance",
"filament_cost",
"filament_notes",
"outer_wall_acceleration",
-14
View File
@@ -3440,20 +3440,6 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloats { 0. });
def = this->add("filament_transmission_distance", coFloats);
def->label = L("Transmission distance");
def->tooltip = L("How deep light gets into this filament, in millimetres, also called its "
"transmission distance or TD. About 1 for an opaque filament, 4 to 7 for a usefully "
"translucent one, 8 and above for a nearly clear one. Used only to predict the "
"colours of a ChromaStack in the texture displacement tool; it has no effect on "
"slicing. Measure it with a step test print, or take a published value for the "
"filament.");
def->sidetext = L("mm");
def->min = 0;
def->max = 100;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloats { 4. });
def = this->add("filament_type", coStrings);
def->label = L("Type");
def->tooltip = L("Filament material type");
-3
View File
@@ -1709,9 +1709,6 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionStrings, volumetric_speed_coefficients))
((ConfigOptionInts, filament_adhesiveness_category))
((ConfigOptionFloats, filament_density))
// Transmission distance: how deep light gets into this filament. Not used by
// slicing - it is the colour model of a ChromaStack stack (see ChromaStack.hpp).
((ConfigOptionFloats, filament_transmission_distance))
((ConfigOptionStrings, filament_type))
((ConfigOptionBools, filament_soluble))
((ConfigOptionStrings, filament_ids))
+8 -9
View File
@@ -17,6 +17,7 @@
#include "I18N.hpp"
#include "Layer.hpp"
#include "MutablePolygon.hpp"
#include "PerimeterGenerator.hpp"
#include "PrintBase.hpp"
#include "PrintConfig.hpp"
#include "SLA/IndexedMesh.hpp"
@@ -1464,15 +1465,13 @@ bool PrintObject::invalidate_state_by_config_options(
|| opt_key == "top_surface_speed") {
// Brim is printed below supports, support invalidates brim and skirt.
steps.emplace_back(posSupportMaterial);
if (opt_key == "brim_type") {
const auto* old_brim_type = old_config.option<ConfigOptionEnum<BrimType>>(opt_key);
const auto* new_brim_type = new_config.option<ConfigOptionEnum<BrimType>>(opt_key);
//BBS: When switch to manual brim, the object must have brim, then re-generate perimeter
//to make the wall order of first layer to be outer-first
// btLeadingEdgeOnly is printed as an outer brim (Brim.cpp, BeltBrim.cpp), so it
// takes part in the same first-layer wall order rule.
if (old_brim_type->value == btOuterOnly || new_brim_type->value == btOuterOnly ||
old_brim_type->value == btLeadingEdgeOnly || new_brim_type->value == btLeadingEdgeOnly)
if (opt_key == "brim_type" || opt_key == "brim_width") {
// Regenerate the walls when the brim starts or stops putting the first layer's outer walls first.
auto outer_walls_first = [](const ConfigOptionResolver &config) {
return brim_puts_outer_walls_first(config.option<ConfigOptionEnum<BrimType>>("brim_type")->value,
config.option<ConfigOptionFloat>("brim_width")->value);
};
if (outer_walls_first(old_config) != outer_walls_first(new_config))
steps.emplace_back(posPerimeters);
}
} else if (
+5 -8
View File
@@ -707,8 +707,10 @@ void tree_supports_generate_paths(
false);
}
if (eec) {
std::reverse(eec->entities.begin(), eec->entities.end());
dst.emplace_back(eec.release());
if (!eec->empty()) {
std::reverse(eec->entities.begin(), eec->entities.end());
dst.emplace_back(eec.release());
}
}
}
}
@@ -1825,12 +1827,7 @@ void generate_support_toolpaths(
no_sort = true;
} else if (support_params.support_style == SupportMaterialStyle::smsTreeOrganic &&
(config.support_base_pattern == smpNone || config.support_base_pattern == smpDefault)) {
// Orca: A special case for the hollow Organic supports
// Orca: If the tree supports are too tall, use a double wall to make it stronger
SupportParameters support_params2 = support_params;
if (support_layer.print_z > 100.0)
support_params2.tree_branch_diameter_double_wall_area_scaled = 0.1;
tree_supports_generate_paths(base_layer.extrusions, base_layer.polygons_to_extrude(), flow, support_params2);
tree_supports_generate_paths(base_layer.extrusions, base_layer.polygons_to_extrude(), flow, support_params);
done = true;
}
if (! done)
+10 -22
View File
@@ -2149,7 +2149,9 @@ void TreeSupport::draw_circles()
const bool with_lightning_infill = m_support_params.base_fill_pattern == ipLightning;
coordf_t support_extrusion_width = m_support_params.support_extrusion_width;
const float tree_brim_width = config.tree_support_brim_width.value;
// Reinforce branches moving more than half a support-line width between layers.
// Square the threshold for comparison with the squared XY movement below.
const double max_move_sq = sqr(double(scale_(m_object_config->support_line_width / 2)));
// Belt floor: the first object layer is not on a flat bed — it rests on the
// tilted, moving belt. So the first-object-layer adhesion features (the tree
// support brim, the hybrid first-layer base expansion) must be suppressed:
@@ -2201,7 +2203,7 @@ void TreeSupport::draw_circles()
size_t first_base_roof_area = 0;
bool floor_interface_as_base = false;
bool has_circle_node = false;
bool need_extra_wall = false;
ExPolygons extra_wall_areas;
ExPolygons collision_sharp_tails;
ExPolygons collision_base;
auto get_collision = [&](bool sharp_tail) -> ExPolygons &{
@@ -2279,7 +2281,6 @@ void TreeSupport::draw_circles()
// area = diff_clipped({ ExPolygon(circle) }, get_collision(node.is_sharp_tail && node.distance_to_top <= 0));
if (!area.empty()) has_circle_node = true;
if (node.need_extra_wall) need_extra_wall = true;
// merge overhang to get a smoother interface surface
// Do not merge when buildplate_only is on, because some underneath nodes may have been deleted.
@@ -2294,6 +2295,10 @@ void TreeSupport::draw_circles()
}
}
// Use the Pythagorean theorem to compare movement magnitude (dx² + dy²) without a square root.
if (node.is_processed && node.movement.cast<double>().squaredNorm() > max_move_sq)
append(extra_wall_areas, area);
if (obj_layer_nr>0 && node.distance_to_top < 0)
append(roof_gap_areas, area);
else if (obj_layer_nr > 0 && node.support_roof_layers_below == 1 &&
@@ -2530,7 +2535,8 @@ void TreeSupport::draw_circles()
//if (area(expoly) < SQ(scale_(1))) continue;
area_groups.emplace_back(&expoly, SupportLayer::BaseType, max_layers_above_base);
area_groups.back().need_infill = overlaps({ expoly }, area_poly);
area_groups.back().need_extra_wall = need_extra_wall && !area_groups.back().need_infill;
area_groups.back().need_extra_wall = !area_groups.back().need_infill &&
!extra_wall_areas.empty() && overlaps({expoly}, extra_wall_areas);
}
for (size_t roof_idx = 0; roof_idx < ts_layer->roof_areas.size(); ++roof_idx) {
auto &expoly = ts_layer->roof_areas[roof_idx];
@@ -3433,11 +3439,6 @@ void TreeSupport::smooth_nodes()
}
}
float max_move = scale_(m_object_config->support_line_width / 2);
// if the branch is very tall, the tip also needs extra wall
float thresh_tall_branch = 100;
float thresh_dist_to_top = 30;
for (int layer_nr = 0; layer_nr< contact_nodes.size(); layer_nr++) {
std::vector<SupportNode *> &curr_layer_nodes = contact_nodes[layer_nr];
if (curr_layer_nodes.empty()) continue;
@@ -3447,20 +3448,17 @@ void TreeSupport::smooth_nodes()
std::vector<double> radii;
std::vector<SupportNode *> branch;
SupportNode * p_node = node;
float total_height = 0;
// add a fixed head if it's not a polygon node, see STUDIO-4403
// Polygon node can't be added because the move distance might be huge, making the nodes in between jump and dangling
if (node->child && node->child->type!=ePolygon) {
pts.push_back(p_node->child->position);
radii.push_back(p_node->child->radius);
branch.push_back(p_node->child);
total_height += p_node->child->height;
}
do {
pts.push_back(p_node->position);
radii.push_back(p_node->radius);
branch.push_back(p_node);
total_height += p_node->height;
p_node = p_node->parent;
} while (p_node && !p_node->is_processed);
if (pts.size() < 3) continue;
@@ -3479,9 +3477,6 @@ void TreeSupport::smooth_nodes()
branch[i]->radius = radii1[i];
branch[i]->movement = (pts[i + 1] - pts[i - 1]) / 2;
branch[i]->is_processed = true;
if (branch[i]->parents.size() > 1 || (branch[i]->movement.x() > max_move || branch[i]->movement.y() > max_move) ||
(total_height > thresh_tall_branch && branch[i]->dist_mm_to_top < thresh_dist_to_top))
branch[i]->need_extra_wall = true;
BOOST_LOG_TRIVIAL(info) << "smooth_nodes: layer_nr=" << layer_nr << ", i=" << i << ", pt=" << pt << ", movement=" << branch[i]->movement << ", radius=" << branch[i]->radius;
}
}
@@ -3489,13 +3484,6 @@ void TreeSupport::smooth_nodes()
std::swap(pts, pts1);
std::swap(radii, radii1);
}
else {
// interpolate need_extra_wall in the end
for (size_t i = 1; i < branch.size() - 1; i++) {
if (branch[i - 1]->need_extra_wall && branch[i + 1]->need_extra_wall)
branch[i]->need_extra_wall = true;
}
}
}
}
}
-1
View File
@@ -135,7 +135,6 @@ struct SupportNode
TreeNodeType type = eCircle;
bool is_corner = false;
bool is_processed = false;
bool need_extra_wall = false;
bool is_sharp_tail = false;
bool valid = true;
ExPolygon overhang; // when type==ePolygon, set this value to get original overhang area
+2 -22
View File
@@ -18,7 +18,6 @@
#include <array>
#include "ChromaStack.hpp"
#include "Point.hpp"
#include "TriangleSelector.hpp"
@@ -354,33 +353,16 @@ struct TextureDisplacementLayer
// for the boundary to be drawn on.
bool color_enabled = false;
// ChromaStack (see ChromaStack.hpp): when set, `image_data` is not a texture the user picked but the
// height map chroma_solve() computed, so that the whole displacement pipeline - preview, UV
// editor, bake - goes on reading one grayscale height map and needs to know nothing about colour
// stacks. The picture it was solved from is kept here instead, because the solve has to be redone
// whenever the stack, the print's layer height or the picture itself changes.
//
// A ChromaStack layer gets its colour from filament swaps at fixed Z, which is what `chroma`
// records and what the object's height ranges carry out. That is a different mechanism from
// `color_enabled` (per-triangle filament assignment), and the two are mutually exclusive: the
// panel turns colour off on a ChromaStack layer, since painting the relief in one filament per
// triangle would hide the very stack the heights were solved against.
bool chroma_enabled = false;
ChromaStack chroma;
std::shared_ptr<std::vector<unsigned char>> chroma_source;
bool empty() const { return !image_data || image_data->empty(); }
template<class Archive> void save(Archive &ar) const
{
std::string blob = image_data ? std::string(image_data->begin(), image_data->end()) : std::string();
std::string source = chroma_source ? std::string(chroma_source->begin(), chroma_source->end()) : std::string();
ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled,
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, chroma_enabled, chroma,
source);
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled);
}
template<class Archive> void load(Archive &ar)
{
@@ -388,14 +370,12 @@ struct TextureDisplacementLayer
int tile_method_int = 0;
int projection_method_int = 0;
int blend_mode_int = 0;
std::string source;
ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled,
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
view_project_matrix, color_enabled, chroma_enabled, chroma, source);
view_project_matrix, color_enabled);
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
chroma_source = source.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(source.begin(), source.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
blend_mode = static_cast<TextureBlendMode>(blend_mode_int);
+1 -1
View File
@@ -75,7 +75,7 @@ std::vector<float> Layers::get_zs() const
size_t Layers::get_layer_id_at(float z) const
{
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z < z; });
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z > z; });
return std::distance(m_items.begin(), iter);
}
+2 -2
View File
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
m_printer_name_to_preset.clear();
for (const Preset &printer_preset : printer_presets) {
if (!printer_preset.is_visible) continue;
if (preset_bundle->printers.get_preset_base(printer_preset)->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"));
-2
View File
@@ -114,8 +114,6 @@ std::pair<bool, std::string> GLShadersManager::init()
valid &= append_shader("mm_gouraud", { prefix + "mm_gouraud.vs", prefix + "mm_gouraud.fs" });
// Fast shaded preview for the texture displacement gizmo (see libslic3r/TextureDisplacement.hpp).
valid &= append_shader("texture_displacement_shaded", { prefix + "texture_displacement_shaded.vs", prefix + "texture_displacement_shaded.fs" });
// ChromaStack: the printed colours on the model, read per fragment from its own height.
valid &= append_shader("chromastack", { prefix + "chromastack.vs", prefix + "chromastack.fs" });
// UV-check overlay for the same gizmo: a procedural checker or a distortion heatmap over the
// painted patch, to sanity-check the unwrap.
valid &= append_shader("texture_displacement_uvcheck", { prefix + "texture_displacement_uvcheck.vs", prefix + "texture_displacement_uvcheck.fs" });
-31
View File
@@ -2875,37 +2875,6 @@ void ObjectList::del_layer_from_object(const int obj_idx, const t_layer_height_r
changed_object(obj_idx);
}
void ObjectList::rebuild_layer_ranges(const int obj_idx)
{
if (obj_idx < 0 || obj_idx >= int(m_objects->size()))
return;
const wxDataViewItem obj_item = m_objects_model->GetItemById(obj_idx);
if (!obj_item.IsOk())
return;
const t_layer_config_ranges& ranges = object(obj_idx)->layer_config_ranges;
// Adding and deleting tree items fires selection events on GTK; suppress them as every other
// bulk edit of the tree here does.
const bool prevent_events = m_prevent_list_events;
m_prevent_list_events = true;
wxDataViewItem root = m_objects_model->GetLayerRootItem(obj_item);
if (root.IsOk()) {
m_objects_model->DeleteChildren(root);
if (ranges.empty())
m_objects_model->Delete(root);
else {
for (const auto& range : ranges)
add_layer_item(range.first, root);
Expand(root);
}
} else {
add_layer_root_item(obj_item);
}
m_prevent_list_events = prevent_events;
changed_object(obj_idx);
}
void ObjectList::del_layers_from_object(const int obj_idx)
{
object(obj_idx)->layer_config_ranges.clear();
-5
View File
@@ -321,11 +321,6 @@ public:
void del_settings_from_config(const wxDataViewItem& parent_item);
void del_instances_from_object(const int obj_idx);
void del_layer_from_object(const int obj_idx, const t_layer_height_range& layer_range);
// Mirrors an object's height ranges in the tree after they have been rewritten from outside the
// list - the texture displacement gizmo writes a whole set at once for a ChromaStack stack's
// filament swaps. Deliberately leaves the selection alone: changing it while a gizmo is open
// tears the gizmo down under the user.
void rebuild_layer_ranges(const int obj_idx);
void del_layers_from_object(const int obj_idx);
bool del_from_cut_object(bool is_connector, bool is_model_part = false, bool is_negative_volume = false);
bool del_subobject_from_object(const int obj_idx, const int idx, const int type);
File diff suppressed because it is too large. Load diff
@@ -264,11 +264,7 @@ private:
ModelVolume* texture_volume();
const ModelVolume* texture_volume() const;
// `chroma` picks the kind: a relief layer, a height map pushed into the surface, or a ChromaStack
// picture, a colour picture made of filament swaps. The kind is a property of the layer and is
// fixed when it is created - there is no panel-wide mode, so a plaque can carry a picture on its
// face and a knurl on its rim at the same time.
void add_texture_layer(bool chroma = false);
void add_texture_layer();
void remove_texture_layer(int slot);
// Moves the layer in `slot` to position `to_index` of the slot-ordered stack (an insertion index,
// 0..layer count), which is the order blend modes apply in. Layers keep their data; what moves is
@@ -439,109 +435,6 @@ private:
void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry);
void import_custom_texture(TextureDisplacementLayer &layer);
// ---- ChromaStack (see libslic3r/ChromaStack.hpp) ----
//
// A ChromaStack layer is an ordinary texture displacement layer whose height map is computed rather
// than imported: the picture the user picked is kept in `chroma_source`, the stack in
// `chroma`, and `image_data` holds whatever the last solve produced. Everything downstream -
// projection, preview, bake - therefore needs no changes at all, and this is the whole of the
// feature's GUI side: pick a picture, edit the stack, solve, hand the filament swaps to the
// object's height ranges.
// The project's loaded filaments as the colour model sees them: colour from the project, TD from
// each filament's preset. Index is the filament index the swaps are written from.
static std::vector<ChromaFilament> chroma_filaments();
// The layer height the print profile is set to, mm; 0 when there is no print preset. The solve is
// only valid for the layer height it was run at, so the panel compares it against the stack's.
static float print_layer_height();
// Where the active layer's painted patch sits, in the object's own printed coordinates - which is
// what the stack has to be lined up with, because a filament swap happens at a print Z and the
// relief grows upward from this patch.
struct ChromaPatch
{
bool valid = false;
float top_mm = 0.f; // the patch's surface above the bottom of the object
float spread_mm = 0.f; // how far from flat the patch is in Z; 0 is a horizontal face
// The patch's extent in the plane the texture is projected from, in the bake frame - i.e. the
// (x, y) the Z-facing plane of the tri-planar projection samples by. This is what the picture
// has to be placed against: tiling_scale and offset are in that space, not in the patch's.
Vec2f planar_min = Vec2f::Zero();
Vec2f planar_max = Vec2f::Zero();
};
ChromaPatch chroma_patch(const TextureDisplacementLayer &layer) const;
// The painted face's height in layers, which is what the stack's base has to be: -1 when nothing
// is painted yet or there is no layer height to divide by.
int chroma_measured_base(const TextureDisplacementLayer &layer) const;
// Extracting the painted patch is far too expensive to repeat once a frame, which is how often the
// panel wants the number. The paint's own timestamp and the transform the patch was measured in are
// between them everything the answer depends on.
mutable std::array<ChromaPatch, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_cache{};
mutable std::array<uint64_t, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_stamp{};
mutable std::array<Transform3d, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_trafo{};
// The patch is measured in two frames at once - the instance's, for the print height, and the
// bake's, for the placement - so both are part of what the cached answer depends on.
mutable std::array<Transform3d, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_bake{};
// Picks a colour picture for the layer, turns ChromaStack on, and seeds a stack from the loaded
// filaments and the patch if the layer has none yet. Solves straight away, so the relief appears
// without a second click.
void chroma_pick_picture(TextureDisplacementLayer &layer);
// Re-runs the solve and replaces the layer's height map with its result. False (with the reason
// shown to the user) when the layer has no picture, no stack or no relief layers.
bool chroma_solve_layer(TextureDisplacementLayer &layer);
// Writes the stack's filament swaps onto the object as height ranges, replacing whatever ranges it
// had. Takes its own undo snapshot.
void chroma_apply_swaps(const TextureDisplacementLayer &layer);
// The stack's swaps as the object's height ranges already express them: true when applying would
// change nothing, which is what the panel's hint keys off.
bool chroma_swaps_applied(const TextureDisplacementLayer &layer) const;
// The whole of a ChromaStack card between its header and More settings: the picture and the print
// it predicts, the base, the filaments and the colours they reach, and the two actions.
void render_chroma_card(TextureDisplacementLayer &layer, float card_pad, float label_w, float wrap_w);
// The surface drawn in the colours the stack will print, over whatever neutral surface is there:
// the live preview's displaced mesh before a bake, the volume's own mesh after one. Every triangle
// takes the palette colour of the printed layer its centroid falls in (chroma_level_at()), which
// is what the eye will see at that height - so the relief's terraces read as the picture, the walls
// between them show the bands they cut through, and looking straight down gives the blended colours
// the whole feature exists to produce.
//
// Nothing else in the application can show this: the colours are blends of translucent filaments
// that no single filament can make, so they cannot be expressed as the per-triangle filament
// assignment the 3D scene paints with. They exist only here and in the sliced preview.
void rebuild_chroma_colors(const TextureDisplacementLayer &layer);
bool render_chroma_colors(const TextureDisplacementLayer &layer);
GLModel m_chroma_color_model;
// The surface the overlay was built from. The mesh alone: the colours are not in it, so a change
// to the stack costs a palette upload rather than a mesh rebuild.
size_t m_chroma_color_key = 0;
// The stack's colours, one texel per printed layer, and what they were built from. A texture
// because a uniform array cannot hold a base of several hundred layers.
std::unique_ptr<GLTexture> m_chroma_palette_tex;
size_t m_chroma_palette_key = 0;
bool m_show_chroma_colors = true;
// What the last solve of each slot reported, for the panel. Not saved: it is a property of the
// solve, and a project is reopened with whatever height map the solve produced.
struct ChromaReport
{
bool valid = false;
double mean = 0.;
double max = 0.;
};
std::array<ChromaReport, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_report{};
// The predicted print, per slot: the palette colours the solve chose, as a panel thumbnail. The
// 3D view cannot show this - the colours do not exist until the print swaps filament - so it is
// the only place the user sees what the stack will actually produce.
std::array<std::unique_ptr<GLTexture>, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_preview;
// The source picture's thumbnail, beside the prediction, keyed by the image it was built from.
std::array<std::unique_ptr<GLTexture>, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_source_thumb;
std::array<const void *, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_source_key{};
// Per slot: the stack has been edited since the height map was solved, so the relief on screen is the
// previous stack's. Nothing else can show this - a stale height map looks like a perfectly good one.
std::array<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_stale{};
// Paint / Erase, from the panel. See get_left_button_state_type().
bool m_erase_mode = false;
// Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation.
+22
View File
@@ -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);
+7
View File
@@ -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
//
+4 -5
View File
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
using Section = ShortcutSection;
if (wxGetApp().is_editor()) {
page(_L("Global"), _L("Available anywhere in the window, 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);
+34
View File
@@ -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
{
+3
View File
@@ -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;
+28 -16
View File
@@ -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);
+8 -1
View File
@@ -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;
+2
View File
@@ -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; }
+6 -1
View File
@@ -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();
+1 -1
View File
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
{
m_printer_last_select = "";
update_select_layout(nullptr);
m_printer_box->GetPrinterComboBox()->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;
+29 -17
View File
@@ -474,17 +474,14 @@ void Selection::add_curr_plate()
if (!m_valid)
return;
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_plate(wxGetApp().plater()->get_partplate_list().get_curr_plate_index());
if (volume_idxs.empty())
return;
wxGetApp().plater()->take_snapshot(std::string("Selection-Add Curr Plate All!"));
m_mode = Instance;
clear();
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) {
if (plate && plate->contain_instance_totally(obj_idx, 0)) {
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_object(obj_idx);
do_add_volumes(volume_idxs);
}
}
do_add_volumes(volume_idxs);
update_type();
this->set_bounding_boxes_dirty();
@@ -511,20 +508,14 @@ void Selection::remove_curr_plate()
if (!m_valid)
return;
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
if (plate->empty())
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_plate(wxGetApp().plater()->get_partplate_list().get_curr_plate_index());
if (volume_idxs.empty())
return;
wxGetApp().plater()->take_snapshot(std::string("Selection-Delete Curr Plate All"));
m_mode = Instance;
clear();
for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) {
if (plate && plate->contain_instance(obj_idx, 0)) {
std::vector<unsigned int> volume_idxs = get_volume_idxs_from_object(obj_idx);
do_add_volumes(volume_idxs);
}
}
do_add_volumes(volume_idxs);
update_type();
this->set_bounding_boxes_dirty();
@@ -2270,6 +2261,27 @@ std::vector<unsigned int> Selection::get_volume_idxs_from_volume(unsigned int ob
return idxs;
}
std::vector<unsigned int> Selection::get_volume_idxs_from_plate(const int plate_idx) const
{
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx);
std::vector<unsigned int> idxs;
if (!plate)
return idxs;
for (int obj_idx = 0; obj_idx < (int)m_model->objects.size(); obj_idx++) {
const ModelObject* obj = m_model->objects[obj_idx];
for (int inst_idx = 0; inst_idx < (int)obj->instances.size(); inst_idx++) {
// on this plate or overlapping it in some way (fully inside, oversized or partly outside)
if (plate->contain_instance(obj_idx, inst_idx) || plate->intersect_instance(obj_idx, inst_idx)) {
std::vector<unsigned int> inst_idxs = get_volume_idxs_from_instance(obj_idx, inst_idx);
idxs.insert(idxs.end(), inst_idxs.begin(), inst_idxs.end());
}
}
}
return idxs;
}
std::vector<unsigned int> Selection::get_missing_volume_idxs_from(const std::vector<unsigned int>& volume_idxs) const
{
std::vector<unsigned int> idxs;
+2
View File
@@ -409,6 +409,8 @@ public:
std::vector<unsigned int> get_volume_idxs_from_instance(unsigned int object_idx, unsigned int instance_idx) const;
// returns the idx of the volume corresponding to the volume with the given idxs
std::vector<unsigned int> get_volume_idxs_from_volume(unsigned int object_idx, unsigned int instance_idx, unsigned int volume_idx) const;
// returns the list of idxs of the volumes of all instances on the given plate or overlapping it. (fully inside, oversized or partly outside)
std::vector<unsigned int> get_volume_idxs_from_plate(const int plate_idx) const;
// returns the list of idxs of the volumes contained in the selection but not in the given list
std::vector<unsigned int> get_missing_volume_idxs_from(const std::vector<unsigned int>& volume_idxs) const;
// returns the list of idxs of the volumes contained in the given list but not in the selection
+1 -1
View File
@@ -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;
-1
View File
@@ -4538,7 +4538,6 @@ void TabFilament::build()
optgroup->append_single_option_line("filament_adhesiveness_category", "material_basic_information#adhesiveness-category");
optgroup->append_single_option_line("filament_density", "material_basic_information#density");
optgroup->append_single_option_line("filament_transmission_distance", "material_basic_information#transmission-distance");
optgroup->append_single_option_line("filament_shrink", "material_basic_information#shrinkage-xy");
optgroup->append_single_option_line("filament_shrinkage_compensation_z", "material_basic_information#shrinkage-z");
optgroup->append_single_option_line("filament_cost", "material_basic_information#price");
+23 -1
View File
@@ -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()
+1
View File
@@ -68,6 +68,7 @@ protected:
#elif defined(__LINUX__)
wxString GetLinuxDistroName();
wxString GetLinuxDisplayServer();
wxString GetAppDisplayBackend();
#endif
wxString GetPackageType();
wxString GetCPUinfo();
+3 -3
View File
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
void ComboBox::ForceDropdownOpen()
{
if (!IsEnabled())
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();
+220 -60
View File
@@ -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!
+19 -3
View File
@@ -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
+13
View File
@@ -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.
+382
View File
@@ -22,6 +22,7 @@
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/Polyline.hpp"
#include <limits>
#include <string>
@@ -994,3 +995,384 @@ TEST_CASE("A hole whose outer wall touches the contour's runs with it", "[Perime
CHECK(layers[i].holes_ccw.front() == ccw);
}
}
namespace {
// The outer radius of round_tube().
const double tube_radius = 11.5;
// Every setting the tube walls below are measured against. With 0.5mm walls a 1mm tube wall holds only the two
// outer walls, touching, and every further 0.5mm makes room for one more inner wall.
DynamicPrintConfig tube_config(const char *wall_generator, int wall_loops)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "wall_generator", wall_generator },
{ "wall_loops", wall_loops },
{ "wall_sequence", "inner wall/outer wall" },
{ "wall_direction", "ccw" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "nozzle_diameter", "0.4" },
{ "outer_wall_line_width", 0.5 },
{ "inner_wall_line_width", 0.5 },
{ "precise_outer_wall", true },
{ "detect_thin_wall", false },
{ "only_one_wall_top", false },
{ "only_one_wall_first_layer", false },
{ "brim_type", "no_brim" },
{ "detect_overhang_wall", true },
{ "overhang_reverse", false },
{ "overhang_reverse_threshold", "0%" },
});
return config;
}
// A 2mm tall round tube whose wall is `wall` mm thick.
Print &round_tube(Print &print, Model &model, const DynamicPrintConfig &config, double wall)
{
ModelObject *object = model.add_object();
object->name = "round_tube.stl";
object->add_volume(make_cylinder(tube_radius, 2.), ModelVolumeType::MODEL_PART, false);
object->add_volume(make_cylinder(tube_radius - wall, 2.), ModelVolumeType::NEGATIVE_VOLUME, false);
object->add_instance();
object->ensure_on_bed();
print.auto_assign_extruders(object);
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
return print;
}
// The side of the tube wall a wall loop lies on. The centre is the middle of the tube wall, where Arachne lays the
// inner wall both sides share when the tube wall is too narrow for a pair of them.
enum class TubeSide { Contour, Hole, Centre };
struct TubeLoop {
TubeSide side;
int inset;
bool ccw;
};
// The wall loops of every layer of round_tube() in extrusion order.
std::vector<std::vector<TubeLoop>> tube_loops(const Print &print, double wall)
{
const double centre = tube_radius - 0.5 * wall;
std::vector<std::vector<TubeLoop>> out;
for (const Layer *layer : print.objects().front()->layers()) {
const Vec2d axis = get_extents(layer->lslices).center().cast<double>();
std::vector<TubeLoop> &loops = out.emplace_back();
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->perimeters.flatten().entities) {
if (! entity->is_loop())
continue;
const ExtrusionLoop *loop = static_cast<const ExtrusionLoop*>(entity);
const Polygon polygon = loop->polygon();
double radius = 0.;
for (const Point &p : polygon.points)
radius += unscaled<double>((p.cast<double>() - axis).norm());
radius /= double(polygon.points.size());
const TubeSide side = radius > centre + 0.1 ? TubeSide::Contour : radius < centre - 0.1 ? TubeSide::Hole : TubeSide::Centre;
loops.push_back({ side, loop->inset_idx, polygon.is_counter_clockwise() });
}
}
return out;
}
// The insets one side of a wall is printed in, given its deepest inset: inside out for Inner/Outer, outside in for
// Outer/Inner, and for Inner/Outer/Inner the third wall onwards inside out, then the outer wall, then the first inner
// wall. Inner/Outer/Inner prints its first layer Inner/Outer, and outside in when there is no third wall.
std::vector<int> wall_sequence_insets(const std::string &wall_sequence, bool first_layer, int deepest)
{
std::vector<int> insets;
if (wall_sequence == "inner wall/outer wall" || (wall_sequence == "inner-outer-inner wall" && first_layer)) {
for (int inset = deepest; inset >= 0; -- inset)
insets.push_back(inset);
} else if (wall_sequence == "outer wall/inner wall" || deepest < 2) {
for (int inset = 0; inset <= deepest; ++ inset)
insets.push_back(inset);
} else {
for (int inset = deepest; inset >= 2; -- inset)
insets.push_back(inset);
insets.push_back(0);
insets.push_back(1);
}
return insets;
}
// Whether a wall of round_tube() runs with the contour: all but the walls of the hole, and those too when the hole
// is a thin wall hole, its outer wall touching the contour's with nothing between them.
bool runs_with_contour(const TubeLoop &loop, bool thin_wall_hole)
{
return loop.side != TubeSide::Hole || thin_wall_hole;
}
// The insets of the walls on one side of a round_tube() layer, the shared centre line included, in extrusion order.
std::vector<int> side_insets(const std::vector<TubeLoop> &loops, TubeSide side)
{
std::vector<int> insets;
for (const TubeLoop &loop : loops)
if (loop.side == side || loop.side == TubeSide::Centre)
insets.push_back(loop.inset);
return insets;
}
// The walls of a round_tube() layer in extrusion order, as side and inset: "c0 c1 h0 h1" is the contour's outer wall,
// then its first inner wall, then the same for the hole, and "m" marks the wall in the middle both sides share.
std::string wall_order(const std::vector<TubeLoop> &loops)
{
std::string out;
for (const TubeLoop &loop : loops)
out += std::string(out.empty() ? "" : " ") + (loop.side == TubeSide::Contour ? "c" : loop.side == TubeSide::Hole ? "h" : "m") +
std::to_string(loop.inset);
return out;
}
} // namespace
// Each side of a tube wall prints its walls in the order the wall sequence asks for, from two walls a side up to
// four. The inner wall Arachne lays in the middle of a wall too narrow for a pair of them is an inner wall of both
// sides; Classic fills that middle with gap fill instead, so it has one wall less a side there.
TEST_CASE("Each side of a tube wall prints its walls in the wall sequence order", "[Perimeters]")
{
struct TubeWall {
double wall;
int wall_loops;
// Walls a side, a shared inner wall included.
int arachne_walls;
int classic_walls;
};
const TubeWall tube = GENERATE(values<TubeWall>({
{ 1.5, 3, 2, 1 }, { 2.0, 2, 2, 2 }, { 2.5, 3, 3, 2 }, { 3.0, 3, 3, 3 }, { 4.0, 4, 4, 4 } }));
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_sequence = GENERATE("inner wall/outer wall", "outer wall/inner wall", "inner-outer-inner wall");
CAPTURE(tube.wall, tube.wall_loops, wall_generator, wall_sequence);
DynamicPrintConfig config = tube_config(wall_generator, tube.wall_loops);
config.set_deserialize_strict("wall_sequence", wall_sequence);
Print print;
Model model;
round_tube(print, model, config, tube.wall);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, tube.wall);
// 2mm at 0.2mm layers.
REQUIRE(layers.size() == 10);
const int walls = std::string(wall_generator) == "arachne" ? tube.arachne_walls : tube.classic_walls;
for (size_t i = 0; i < layers.size(); ++ i) {
for (const TubeSide side : { TubeSide::Contour, TubeSide::Hole }) {
const bool contour_side = side == TubeSide::Contour;
CAPTURE(i, contour_side);
const std::vector<int> insets = side_insets(layers[i], side);
REQUIRE(int(insets.size()) == walls);
CHECK(insets == wall_sequence_insets(wall_sequence, i == 0, walls - 1));
}
}
}
// The outer wall of a thin wall hole touches the contour's with nothing between them. The hole is printed first, so
// the contour leans on it, unless the wall sequence puts the outer walls first.
TEST_CASE("A thin wall hole is printed before the contour unless the outer walls go first", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_sequence = GENERATE("inner wall/outer wall", "outer wall/inner wall", "inner-outer-inner wall");
CAPTURE(wall_generator, wall_sequence);
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict("wall_sequence", wall_sequence);
Print print;
Model model;
round_tube(print, model, config, 1.);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, 1.);
REQUIRE(layers.size() == 10);
const TubeSide first = wall_sequence == "outer wall/inner wall" ? TubeSide::Contour : TubeSide::Hole;
for (size_t i = 0; i < layers.size(); ++ i) {
CAPTURE(i);
REQUIRE(layers[i].size() == 2);
REQUIRE(layers[i][0].side != layers[i][1].side);
CHECK(layers[i].front().side == first);
}
}
// A 1.5mm tube wall leaves Inner/Outer/Inner no third wall to sandwich, only the inner wall Arachne lays in the middle
// for both sides, or Classic's gap fill. Past the first layer it prints the hole first, then the contour, then the
// middle.
TEST_CASE("Inner/Outer/Inner prints the hole of a narrow tube wall first and the middle last", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict("wall_sequence", "inner-outer-inner wall");
Print print;
Model model;
round_tube(print, model, config, 1.5);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, 1.5);
REQUIRE(layers.size() == 10);
// Classic's gap fill is not a wall loop, and goes after the walls.
const std::string expected = std::string(wall_generator) == "arachne" ? "h0 c0 m1" : "h0 c0";
for (size_t i = 1; i < layers.size(); ++ i) {
CAPTURE(i);
CHECK(wall_order(layers[i]) == expected);
}
}
// The contour's walls run in the wall direction and the hole's against it, except that the walls of a thin wall
// hole run with the contour, as does the inner wall Arachne shares between both sides of a 1.5mm tube wall.
TEST_CASE("Tube walls run in the wall direction and hole walls against it", "[Perimeters]")
{
const double wall = GENERATE(1.0, 1.5, 2.0, 3.0);
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_direction = GENERATE("ccw", "cw");
CAPTURE(wall, wall_generator, wall_direction);
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict("wall_direction", wall_direction);
Print print;
Model model;
round_tube(print, model, config, wall);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, wall);
REQUIRE(layers.size() == 10);
const bool thin_wall_hole = wall == 1.;
for (size_t i = 0; i < layers.size(); ++ i) {
CAPTURE(i);
REQUIRE(std::any_of(layers[i].begin(), layers[i].end(), [](const TubeLoop &loop) { return loop.side == TubeSide::Contour; }));
REQUIRE(std::any_of(layers[i].begin(), layers[i].end(), [](const TubeLoop &loop) { return loop.side == TubeSide::Hole; }));
for (const TubeLoop &loop : layers[i]) {
CAPTURE(int(loop.side), loop.inset);
CHECK(loop.ccw == (runs_with_contour(loop, thin_wall_hole) == (wall_direction == "ccw")));
}
}
}
// Reverse on even with no threshold reverses every wall of every other layer, or only the inner walls when it is
// restricted to them, and leaves the order of the walls alone.
TEST_CASE("Reverse on even reverses the tube walls of every other layer", "[Perimeters]")
{
const double wall = GENERATE(1.0, 1.5, 3.0);
const char *wall_generator = GENERATE("classic", "arachne");
const bool reverse_internal_only = GENERATE(false, true);
CAPTURE(wall, wall_generator, reverse_internal_only);
auto slice = [&](bool overhang_reverse) {
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict({
{ "overhang_reverse", overhang_reverse },
{ "overhang_reverse_internal_only", reverse_internal_only },
});
Print print;
Model model;
round_tube(print, model, config, wall);
return tube_loops(print, wall);
};
const std::vector<std::vector<TubeLoop>> plain = slice(false);
const std::vector<std::vector<TubeLoop>> reversed = slice(true);
REQUIRE(plain.size() == 10);
REQUIRE(reversed.size() == plain.size());
for (size_t i = 0; i < plain.size(); ++ i) {
CAPTURE(i);
REQUIRE(reversed[i].size() == plain[i].size());
for (size_t j = 0; j < plain[i].size(); ++ j) {
CAPTURE(j);
CHECK(reversed[i][j].side == plain[i][j].side);
CHECK(reversed[i][j].inset == plain[i][j].inset);
const bool flips = i % 2 == 1 && (! reverse_internal_only || plain[i][j].inset > 0);
CHECK(reversed[i][j].ccw == (plain[i][j].ccw != flips));
}
}
}
namespace {
DynamicPrintConfig brim_tube_config(const char *wall_generator, const std::string &wall_sequence, const char *brim_type, double brim_width)
{
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict({
{ "wall_sequence", wall_sequence },
{ "brim_type", brim_type },
{ "brim_width", brim_width },
});
return config;
}
} // namespace
// A brim against the outer walls makes the first layer print them first, whatever the wall sequence, so the brim runs
// straight on into the outer wall: each side of the tube wall outside in, and the contour before a thin wall hole.
// Brims that may leave the outer walls bare keep the wall sequence, and so do the layers above.
TEST_CASE("A brim against the outer walls makes the first layer print them first", "[Perimeters]")
{
struct Brim {
const char *type;
double width;
bool outer_walls_first;
};
const Brim brim = GENERATE(values<Brim>({
{ "no_brim", 5., false }, { "inner_only", 5., false }, { "auto_brim", 5., false }, { "outer_only", 0., false },
{ "outer_only", 5., true }, { "outer_and_inner", 5., true }, { "leading_edge_only", 5., true } }));
const double wall = GENERATE(1.0, 3.0);
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_sequence = GENERATE("inner wall/outer wall", "inner-outer-inner wall");
CAPTURE(brim.type, brim.width, wall, wall_generator, wall_sequence);
Print print;
Model model;
round_tube(print, model, brim_tube_config(wall_generator, wall_sequence, brim.type, brim.width), wall);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, wall);
REQUIRE(layers.size() == 10);
// The thin wall hole has its outer wall a side, the 3mm tube wall three walls a side.
const size_t walls = wall == 1. ? 1 : 3;
for (const size_t i : { size_t(0), size_t(1) }) {
const bool outer_walls_first = brim.outer_walls_first && i == 0;
for (const TubeSide side : { TubeSide::Contour, TubeSide::Hole }) {
const bool contour_side = side == TubeSide::Contour;
CAPTURE(i, contour_side);
const std::vector<int> insets = side_insets(layers[i], side);
REQUIRE(insets.size() == walls);
CHECK(insets == wall_sequence_insets(outer_walls_first ? "outer wall/inner wall" : wall_sequence, i == 0, int(walls) - 1));
}
if (wall == 1.)
CHECK(layers[i].front().side == (outer_walls_first ? TubeSide::Contour : TubeSide::Hole));
}
}
// Changing the brim of a sliced print regenerates the walls when the first layer's wall order changes with it, so
// the print gets the walls a slice from scratch with the new brim gets.
TEST_CASE("Changing the brim updates the wall order of the first layer", "[Perimeters]")
{
struct BrimChange {
const char *from_type;
double from_width;
const char *to_type;
double to_width;
};
const BrimChange change = GENERATE(values<BrimChange>({
{ "outer_only", 0., "outer_only", 5. }, { "outer_only", 5., "outer_only", 0. },
{ "no_brim", 5., "outer_and_inner", 5. }, { "outer_and_inner", 5., "inner_only", 5. } }));
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(change.from_type, change.from_width, change.to_type, change.to_width, wall_generator);
const std::string wall_sequence = "inner wall/outer wall";
Print print;
Model model;
round_tube(print, model, brim_tube_config(wall_generator, wall_sequence, change.from_type, change.from_width), 3.);
const std::string before = wall_order(tube_loops(print, 3.).front());
print.apply(model, brim_tube_config(wall_generator, wall_sequence, change.to_type, change.to_width));
print.process();
const std::string after = wall_order(tube_loops(print, 3.).front());
Print fresh_print;
Model fresh_model;
round_tube(fresh_print, fresh_model, brim_tube_config(wall_generator, wall_sequence, change.to_type, change.to_width), 3.);
const std::string fresh = wall_order(tube_loops(fresh_print, 3.).front());
// The change does turn the first layer around.
REQUIRE(before != fresh);
CHECK(after == fresh);
}
+59
View File
@@ -735,6 +735,65 @@ TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
}
// The final print statistics are known only after the whole G-code is generated, yet every custom G-code,
// the file header included, gets the same values as filename_format.
TEST_CASE("Print statistics in custom G-code match the final print statistics", "[Print][Regression]")
{
const std::string gcode_key = GENERATE(as<std::string>{}, "file_start_gcode", "machine_start_gcode", "machine_end_gcode");
// Bambu printers use another tag table and G-code layout.
const bool is_bbl = GENERATE(false, true);
CAPTURE(gcode_key, is_bbl);
const std::vector<std::string> number_keys = { "total_weight", "used_filament", "total_cost", "total_wipe_tower_cost",
"total_wipe_tower_filament" };
const std::vector<std::string> time_keys = { "print_time", "normal_print_time", "silent_print_time" };
std::string templ;
for (const std::vector<std::string> *keys : { &number_keys, &time_keys })
for (const std::string &key : *keys)
templ += "; stat " + key + "={" + key + "}\n";
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ gcode_key, templ },
{ "filament_density", "1.25" },
{ "filament_cost", "20" },
{ "time_cost", "2" },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.is_BBL_printer() = is_bbl;
std::string gcode;
REQUIRE_NOTHROW(gcode = Slic3r::Test::gcode(print));
// Match whole lines only: the config block repeats the template on a single line, and Bambu printers
// write that block before the start G-code.
const std::string lines = "\n" + gcode;
const auto value_of = [&lines](const std::string &key) {
const std::string prefix = "\n; stat " + key + "=";
const size_t start = lines.find(prefix);
REQUIRE(start != std::string::npos);
const size_t begin = start + prefix.size();
return lines.substr(begin, lines.find('\n', begin) - begin);
};
// The same statistics filename_format is expanded with.
DynamicConfig stats = print.print_statistics().config();
// A 20mm cube with filament_density 1.25 weighs something, unlike the running extruded_weight_total in the header.
CHECK(stats.opt_float("total_weight") > 0.);
for (const std::string &key : number_keys) {
CAPTURE(key);
const std::string value = value_of(key);
REQUIRE(value.find('@') == std::string::npos);
// Written with two decimals.
CHECK_THAT(std::stod(value), Catch::Matchers::WithinAbs(stats.opt_float(key), 0.006));
}
for (const std::string &key : time_keys) {
CAPTURE(key);
CHECK(value_of(key) == stats.opt_string(key));
}
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
{
Print print;
-1
View File
@@ -37,7 +37,6 @@ add_executable(${_TEST_NAME}_tests
test_fill_plane_path.cpp
test_fill_tpms_adaptive.cpp
test_geometry.cpp
test_chromastack.cpp
test_imex_arrange.cpp
test_imex_helpers.cpp
test_imex_zones.cpp
-379
View File
@@ -1,379 +0,0 @@
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <vector>
#include "libslic3r/Color.hpp"
#include "libslic3r/ChromaStack.hpp"
#include "libslic3r/PNGReadWrite.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
namespace {
ChromaFilament filament(int index, const ColorRGB &color, float td_mm)
{
ChromaFilament f;
f.filament = index;
f.color = color;
f.td_mm = td_mm;
return f;
}
ChromaBand band(const ChromaFilament &f, int layers)
{
ChromaBand b;
b.filament = f;
b.layers = layers;
return b;
}
// An opaque filament (TD 1 hides what is under it within 0.1 mm) and a translucent one (TD 8 needs
// most of a millimetre), which is the whole range the model has to behave sensibly over.
const ChromaFilament WHITE_OPAQUE = filament(0, ColorRGB(1.f, 1.f, 1.f), 1.f);
const ChromaFilament BLACK_OPAQUE = filament(1, ColorRGB(0.f, 0.f, 0.f), 1.f);
const ChromaFilament BLACK_TRANSLUCENT = filament(2, ColorRGB(0.f, 0.f, 0.f), 8.f);
float luminance(const ColorRGB &c) { return 0.2126f * c.r() + 0.7152f * c.g() + 0.0722f * c.b(); }
} // namespace
TEST_CASE("A stack's palette has one entry per printed layer", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 4;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 10) };
// The base is backing under the picture, not part of a band: the bands are the 12 layers the
// picture is built from, and the stack is those on top of the 4 the model already has.
REQUIRE(stack.relief_layers() == 12);
REQUIRE(stack.total_layers() == 16);
REQUIRE_THAT(stack.depth_mm(), WithinAbs(12 * 0.08f, 1e-6f));
const std::vector<ColorRGB> palette = chroma_palette(stack);
REQUIRE(palette.size() == 17);
// Index 0 is the base's own colour, so a column one layer tall is exactly its filament - whatever
// that filament's transmission distance.
REQUIRE(palette[0] == palette[1]);
REQUIRE_THAT(luminance(palette[1]), WithinAbs(1.f, 1e-3f));
}
TEST_CASE("A run of one filament gives the same palette however it is split into bands", "[ChromaStack]")
{
// The property that justifies compositing layer by layer at all: transmittance multiplies, so
// cutting a run in two must not change the colours. A model without it would make the palette
// depend on how the stack was typed in.
ChromaStack one;
one.layer_height_mm = 0.1f;
one.base_layers = 0;
one.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
ChromaStack split = one;
split.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 3), band(BLACK_TRANSLUCENT, 5) };
const std::vector<ColorRGB> a = chroma_palette(one);
const std::vector<ColorRGB> b = chroma_palette(split);
REQUIRE(a.size() == b.size());
for (size_t i = 0; i < a.size(); ++i)
REQUIRE_THAT(luminance(a[i]), WithinAbs(luminance(b[i]), 1e-5f));
}
TEST_CASE("A translucent filament darkens gradually and an opaque one at once", "[ChromaStack]")
{
ChromaStack translucent;
translucent.layer_height_mm = 0.08f;
translucent.base_layers = 0;
translucent.bands = { band(WHITE_OPAQUE, 1), band(BLACK_TRANSLUCENT, 12) };
const std::vector<ColorRGB> slow = chroma_palette(translucent);
// Monotonically darker, and still short of the filament's own black after a single layer: that
// gradient is what a ChromaStack stack prints its midtones with.
for (size_t i = 2; i < slow.size(); ++i)
REQUIRE(luminance(slow[i]) < luminance(slow[i - 1]));
REQUIRE(luminance(slow[2]) > 0.5f);
// Twelve layers of a TD 8 filament pass under 3% of the light, which is the dark end of the ramp
// rather than true black - and that is the honest prediction, not a rounding artifact.
REQUIRE(luminance(slow.back()) < 0.25f);
ChromaStack opaque;
opaque.layer_height_mm = 0.08f;
opaque.base_layers = 0;
opaque.bands = { band(WHITE_OPAQUE, 1), band(BLACK_OPAQUE, 12) };
const std::vector<ColorRGB> fast = chroma_palette(opaque);
// TD 1 is opaque within about 0.1 mm, so two layers of it leave little of the white showing.
REQUIRE(luminance(fast[3]) < 0.2f);
}
TEST_CASE("Solving a picture of the palette's own colors reproduces it exactly", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
const std::vector<ColorRGB> palette = chroma_palette(stack);
const int relief = stack.relief_layers();
// One pixel per reachable height, in order.
std::vector<uint8_t> rgb;
for (int i = 0; i <= relief; ++i) {
const ColorRGB &c = palette[size_t(stack.base_layers + i)];
rgb.push_back(uint8_t(std::lround(c.r() * 255.f)));
rgb.push_back(uint8_t(std::lround(c.g() * 255.f)));
rgb.push_back(uint8_t(std::lround(c.b() * 255.f)));
}
const ChromaSolve solve = chroma_solve(rgb.data(), relief + 1, 1, stack);
REQUIRE_FALSE(solve.empty());
REQUIRE(solve.width == relief + 1);
REQUIRE(solve.levels.size() == size_t(relief + 1));
// Every pixel comes back as the colour it was generated from, and the error is rounding only.
//
// The colour rather than the height: the bottom band prints in the same filament as the base, so
// its first layers show exactly the colour the base already showed, and any of those heights is a
// correct answer for that pixel. Asserting the height would be asserting which of several equally
// right answers the search happens to reach first.
for (int i = 0; i <= relief; ++i) {
const int chosen = int(solve.levels[size_t(i)]);
REQUIRE(chosen >= stack.base_layers);
REQUIRE(chosen <= stack.total_layers());
const ColorRGB &got = palette[size_t(chosen)];
const ColorRGB &want = palette[size_t(stack.base_layers + i)];
REQUIRE_THAT(got.r(), WithinAbs(want.r(), 2.f / 255.f));
REQUIRE_THAT(got.g(), WithinAbs(want.g(), 2.f / 255.f));
REQUIRE_THAT(got.b(), WithinAbs(want.b(), 2.f / 255.f));
}
// Only 8-bit rounding of the palette separates the two, which near black is still under one dE.
REQUIRE(solve.max_delta_e < 2.);
// The height map's ends are the base and the top of the stack, which is what ties a grey level to
// a printed layer.
REQUIRE(solve.grey.front() == 0);
REQUIRE(solve.grey.back() == 255);
}
TEST_CASE("A picture the filaments cannot reach is reported as a poor match", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
// Saturated red against a stack of white and black: no height can produce it, and the panel's
// warning depends on the solve saying so rather than silently picking a grey.
const std::vector<uint8_t> red{ 255, 0, 0 };
const ChromaSolve solve = chroma_solve(red.data(), 1, 1, stack);
REQUIRE_FALSE(solve.empty());
REQUIRE(solve.mean_delta_e > 15.);
}
TEST_CASE("Dithering keeps every pixel in the stack's range", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
stack.dither = true;
// A flat mid-grey: the error diffusion has to spread it over neighbouring heights without ever
// leaving the stack, which is what a clamped palette index would quietly get wrong.
std::vector<uint8_t> grey(size_t(16 * 16) * 3, 128);
const ChromaSolve solve = chroma_solve(grey.data(), 16, 16, stack);
REQUIRE_FALSE(solve.empty());
REQUIRE(solve.levels.size() == size_t(16 * 16));
const auto [lo, hi] = std::minmax_element(solve.levels.begin(), solve.levels.end());
REQUIRE(int(*lo) >= stack.base_layers);
REQUIRE(int(*hi) <= stack.total_layers());
}
TEST_CASE("The solved height map is an 8-bit grayscale PNG of the picture's size", "[ChromaStack]")
{
// The displacement pipeline reads one thing from a layer: an 8-bit grayscale PNG. A solve that
// produced anything else would leave the relief silently flat.
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 6) };
std::vector<uint8_t> picture(size_t(8 * 4) * 3);
for (size_t i = 0; i < picture.size(); ++i)
picture[i] = uint8_t(i % 256);
const ChromaSolve solve = chroma_solve(picture.data(), 8, 4, stack);
const std::vector<unsigned char> png = chroma_encode_height_png(solve);
REQUIRE_FALSE(png.empty());
const png::ReadBuf buf{ png.data(), png.size() };
png::ImageGreyscale decoded;
REQUIRE(png::is_png(buf));
REQUIRE(png::decode_png(buf, decoded));
REQUIRE(decoded.cols == 8);
REQUIRE(decoded.rows == 4);
REQUIRE(decoded.buf == solve.grey);
}
TEST_CASE("Each band becomes a height range, and a repeated filament is not a swap", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.1f;
stack.base_layers = 0;
stack.bands = { band(WHITE_OPAQUE, 5), band(BLACK_OPAQUE, 3), band(BLACK_OPAQUE, 2) };
const std::vector<ChromaRange> ranges = chroma_ranges(stack, 0.);
// Three bands, two filaments: the second and third merge, because nothing is swapped between them.
REQUIRE(ranges.size() == 2);
REQUIRE_THAT(ranges[0].z_min, WithinAbs(0., 1e-9));
REQUIRE_THAT(ranges[0].z_max, WithinAbs(0.5, 1e-9));
REQUIRE(ranges[0].filament == WHITE_OPAQUE.filament);
REQUIRE_THAT(ranges[1].z_min, WithinAbs(0.5, 1e-9));
REQUIRE_THAT(ranges[1].z_max, WithinAbs(1.0, 1e-9));
REQUIRE(ranges[1].filament == BLACK_OPAQUE.filament);
}
TEST_CASE("A default stack puts the lightest filament at the bottom", "[ChromaStack]")
{
// Light to dark is what gives the long tonal ramp; the order is the one thing a seeded stack has
// to get right, since everything else is a layer count the user can drag.
const std::vector<ChromaFilament> loaded{ BLACK_OPAQUE, WHITE_OPAQUE,
filament(3, ColorRGB(0.5f, 0.5f, 0.5f), 4.f) };
const ChromaStack stack = chroma_default_stack(loaded, 0.08f, 4, 24);
REQUIRE(stack.bands.size() == 3);
REQUIRE(stack.bands.front().filament.filament == WHITE_OPAQUE.filament);
REQUIRE(stack.bands.back().filament.filament == BLACK_OPAQUE.filament);
REQUIRE(stack.base_layers == 4);
// The base is printed in the bottom band, so the relief is what was asked for and the base sits
// underneath it.
REQUIRE(stack.total_layers() == 4 + 24);
REQUIRE(stack.relief_layers() == 24);
REQUIRE_THAT(stack.layer_height_mm, WithinRel(0.08f));
}
TEST_CASE("An empty stack or picture solves to nothing", "[ChromaStack]")
{
ChromaStack empty;
REQUIRE(chroma_palette(empty).empty());
REQUIRE(chroma_ranges(empty, 0.).empty());
// A base with nothing on it: there is no picture to solve, and no filament to print the base in.
ChromaStack base_only;
base_only.layer_height_mm = 0.08f;
base_only.base_layers = 4;
REQUIRE(base_only.relief_layers() == 0);
REQUIRE(chroma_palette(base_only).empty());
const std::vector<uint8_t> pixel{ 128, 128, 128 };
REQUIRE(chroma_solve(pixel.data(), 1, 1, base_only).empty());
ChromaStack usable;
usable.layer_height_mm = 0.08f;
usable.base_layers = 2;
usable.bands = { band(WHITE_OPAQUE, 2), band(BLACK_OPAQUE, 4) };
REQUIRE(chroma_solve(nullptr, 4, 4, usable).empty());
}
TEST_CASE("The base extends the bottom band's range down to the first layer", "[ChromaStack]")
{
// The base is not a swap of its own: it prints in whatever is loaded at the start, which is the
// bottom band's filament, so the first range has to reach the build plate.
ChromaStack stack;
stack.layer_height_mm = 0.1f;
stack.base_layers = 20;
stack.bands = { band(WHITE_OPAQUE, 5), band(BLACK_OPAQUE, 5) };
const std::vector<ChromaRange> ranges = chroma_ranges(stack, 0.);
REQUIRE(ranges.size() == 2);
REQUIRE_THAT(ranges[0].z_min, WithinAbs(0., 1e-9));
REQUIRE_THAT(ranges[0].z_max, WithinAbs(2.5, 1e-9)); // 20 base + 5 band layers at 0.1 mm
REQUIRE_THAT(ranges[1].z_min, WithinAbs(2.5, 1e-9));
REQUIRE_THAT(ranges[1].z_max, WithinAbs(3.0, 1e-9));
REQUIRE(ranges.back().z_max == Catch::Approx(double(stack.total_layers()) * double(stack.layer_height_mm)));
}
TEST_CASE("A surface's color follows the printed layer it sits at", "[ChromaStack]")
{
// What the preview colours by: a swap happens at one print Z, so the layer a surface sits in is
// what decides its colour, whatever the height map says.
ChromaStack stack;
stack.layer_height_mm = 0.1f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 3), band(BLACK_OPAQUE, 5) };
REQUIRE(chroma_level_at(stack, 0.05) == 1); // inside the first layer
REQUIRE(chroma_level_at(stack, 0.10) == 1); // exactly its top, which that layer printed
REQUIRE(chroma_level_at(stack, 0.15) == 2);
REQUIRE(chroma_level_at(stack, 0.50) == 5); // the top of the base plus the white band
// Clamped at both ends: below the plate and above the stack there is nothing else to show.
REQUIRE(chroma_level_at(stack, -1.) == 1);
REQUIRE(chroma_level_at(stack, 99.) == stack.total_layers());
}
TEST_CASE("How fast a filament covers is its TD against the layer height", "[ChromaStack]")
{
// The number that decides whether a band is a gradient or a step, and the one the panel reports:
// a filament hides what is under it at about a tenth of its TD, so how many layers that is
// depends entirely on how thick they are.
REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.2f), WithinRel(2.f, 1e-4f));
REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.08f), WithinRel(5.f, 1e-4f));
REQUIRE_THAT(chroma_layers_to_opaque(8.f, 0.08f), WithinRel(10.f, 1e-4f));
// An opaque filament at a coarse layer height covers within one layer, which is where a stack
// stops having any colours between its filaments at all.
REQUIRE(chroma_layers_to_opaque(1.f, 0.2f) < 1.f);
REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.f), WithinAbs(0.f, 1e-6f));
}
TEST_CASE("A finer layer height buys colors between the filaments", "[ChromaStack]")
{
// The whole of the user-visible problem in one assertion: the same stack, the same filaments, two
// layer heights. The coarse one steps from white to black in two layers; the fine one fades.
const auto distinct = [](float layer_height) {
ChromaStack stack;
stack.layer_height_mm = layer_height;
stack.base_layers = 0;
stack.bands = { band(WHITE_OPAQUE, 1), band(filament(5, ColorRGB(0.f, 0.f, 0.f), 8.f), 10) };
const std::vector<ColorRGB> palette = chroma_palette(stack);
int n = 0;
for (size_t i = 1; i < palette.size(); ++i)
if (std::abs(luminance(palette[i]) - luminance(palette[i - 1])) > 0.02f)
++n;
return n;
};
REQUIRE(distinct(0.08f) > distinct(0.3f));
}
TEST_CASE("Layers past a filament's covering depth add no color", "[ChromaStack]")
{
// The band is twice as long as the filament needs to hide what is under it, so half of it shows a
// colour the layer below already showed. That is invisible in the stack and obvious here.
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 0;
stack.bands = { band(WHITE_OPAQUE, 1), band(filament(5, ColorRGB(0.f, 0.f, 0.f), 4.f), 10) };
// Trimming keeps every layer that still changes the colour and drops the rest, so the stack gets
// shorter and loses none of its colours.
const int distinct = chroma_distinct_levels(stack);
const int before = stack.relief_layers();
chroma_trim_bands(stack);
REQUIRE(stack.relief_layers() <= before);
REQUIRE(chroma_distinct_levels(stack) == distinct);
// The white band is a single layer and has nothing to give up.
REQUIRE(stack.bands.front().layers == 1);
// A light band over a dark one is the case where layers really are wasted: it saturates within a
// few layers, and the rest of a long band shows what the layer below already showed.
ChromaStack light_over_dark;
light_over_dark.layer_height_mm = 0.08f;
light_over_dark.base_layers = 0;
light_over_dark.bands = { band(filament(5, ColorRGB(0.f, 0.f, 0.f), 4.f), 1), band(WHITE_OPAQUE, 14) };
const int kept = chroma_distinct_levels(light_over_dark);
chroma_trim_bands(light_over_dark);
REQUIRE(light_over_dark.bands.back().layers < 14);
REQUIRE(chroma_distinct_levels(light_over_dark) == kept);
}
+49
View File
@@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
}
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
using Catch::Matchers::WithinAbs;
SECTION("float") {
ConfigOptionFloat opt(7.5);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
}
SECTION("int") {
ConfigOptionInt opt(3);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK(opt.value == 3);
}
SECTION("percent") {
ConfigOptionPercent opt(40.);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
}
SECTION("float or percent") {
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
CHECK_FALSE(opt.percent);
}
SECTION("point") {
ConfigOptionPoint opt(Vec2d(1., 2.));
REQUIRE_FALSE(opt.deserialize("5,abc"));
CHECK(opt.value == Vec2d(1., 2.));
}
SECTION("point3") {
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
CHECK(opt.value == Vec3d(1., 2., 3.));
}
}
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
using Catch::Matchers::WithinAbs;
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE(opt.deserialize("50%"));
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
CHECK(opt.percent);
ConfigOptionPoint point;
REQUIRE(point.deserialize("3x4"));
CHECK(point.value == Vec2d(3., 4.));
}
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys;
@@ -985,6 +985,37 @@ TEST_CASE("A user filament synced to the cloud and back keeps its values and uns
}
}
TEST_CASE("A project filament storing one value for a per-variant key applies it to every variant", "[Preset][Bundle][Regression]")
{
PresetCollection filaments(Preset::TYPE_FILAMENT, Preset::filament_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
Preset &parent = add_inmemory_preset(filaments, "Parent Filament");
parent.config.set_key_value("filament_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow"}));
parent.config.set_key_value("adaptive_pressure_advance_model", new ConfigOptionStrings({"parent standard", "parent high flow"}));
parent.config.set_deserialize_strict("pressure_advance", "0.02,0.03");
parent.is_system = true;
// A filament saved before these keys became per-variant lists both variants but stores each key once.
const std::string model = "0.025,15.6,5000";
DynamicPrintConfig config;
config.set_key_value(BBL_JSON_KEY_INHERITS, new ConfigOptionString("Parent Filament"));
config.set_key_value("filament_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow"}));
config.set_key_value("adaptive_pressure_advance_model", new ConfigOptionStrings({model}));
config.set_deserialize_strict("pressure_advance", "0.016");
auto embedded = std::make_unique<Preset>(Preset::TYPE_FILAMENT, "Project Filament", false);
embedded->config = std::move(config);
embedded->is_project_embedded = true;
std::vector<Preset *> project_presets{embedded.get()};
PresetsConfigSubstitutions substitutions;
filaments.load_project_embedded_presets(project_presets, PRESET_FILAMENT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
const Preset *loaded = filaments.find_preset("Project Filament");
REQUIRE(loaded != nullptr);
CHECK(loaded->config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values == std::vector<std::string>{model, model});
check_double_vector(loaded->config.option<ConfigOptionFloats>("pressure_advance")->values, {0.016, 0.016});
}
TEST_CASE("Repeated loads of a user preset directory produce the same presets", "[Preset][Bundle]")
{
ScopedTemporaryDir temp_dir;
@@ -1188,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;
+1 -1
View File
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_creality_cfs_match.cpp
test_dev_mapping.cpp
test_device_access.cpp
test_moonraker_discovery.cpp
test_moonraker_printer_agent.cpp
test_network_discovery.cpp
test_filament_bitmap_utils.cpp
test_colour_picker_field.cpp
+105 -3
View File
@@ -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");
}
@@ -0,0 +1,580 @@
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <boost/asio/buffer.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/beast/core/error.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/http/message.hpp>
#include <boost/beast/http/read.hpp>
#include <boost/beast/http/status.hpp>
#include <boost/beast/http/string_body.hpp>
#include <boost/beast/http/write.hpp>
#include <boost/beast/websocket/error.hpp>
#include <boost/beast/websocket/rfc6455.hpp>
#include <boost/beast/websocket/stream.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/IPrinterAgent.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
using namespace Slic3r;
namespace {
namespace beast = boost::beast;
namespace http = beast::http;
namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
using namespace std::chrono_literals;
class DiscoveryAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::device_info;
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
return true;
}
};
class StreamAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::start_status_stream;
using MoonrakerPrinterAgent::stop_status_stream;
using MoonrakerPrinterAgent::ws_timings;
protected:
bool init_device_info(
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
{
device_info = MoonrakerDeviceInfo{};
device_info.dev_id = dev_id;
device_info.base_url = "http://" + dev_ip + ":" + port;
return true;
}
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
info.dev_name = "Test printer";
return true;
}
};
enum class Behavior {
SilentBeforeHandshake, // Accepts the connection and never answers
Silent, // Completes the upgrade, then never reads or sends
AnswersPings, // Reports a status, then answers pings and sends nothing else
AnswersPingsWithoutStatus, // Answers pings but never reports a status
Chatters, // Reports a status, then answers pings and sends notifications that carry no status
HangsUp, // Reports a status, then closes the websocket
HangsUpWithoutAnswer, // Closes the websocket without sending anything
IgnoresClose, // Reports Klippy disconnected, then never answers the close
StallsObjectList, // Never answers the stream's object list request
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
};
class FakePrinter
{
public:
explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
{
m_accept_thread = std::thread([this] { accept_loop(); });
}
~FakePrinter()
{
release();
m_stopping = true;
// Wakes the blocking accept.
tcp::socket wake{m_accept_ioc};
beast::error_code ec;
wake.connect(m_acceptor.local_endpoint(), ec);
m_accept_thread.join();
for (std::thread& connection : m_connections)
connection.join();
}
std::string host() const { return "127.0.0.1"; }
std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
std::string base_url() const { return "http://" + host() + ":" + port(); }
// A connection is ready once it reaches the state its behavior waits in.
bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
}
bool wait_for_http_requests(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
}
// Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
bool wait_for_http_hang_ups(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
}
int close_frames()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_close_frames;
}
// Hangs up every connection that is waiting.
void release()
{
std::lock_guard<std::mutex> lock(m_mutex);
m_released = true;
m_changed.notify_all();
}
private:
void accept_loop()
{
for (;;) {
// Each connection gets its own io_context, so a connection that runs one never runs another's handlers.
auto ioc = std::make_unique<net::io_context>();
tcp::socket socket{*ioc};
beast::error_code ec;
m_acceptor.accept(socket, ec);
if (ec || m_stopping)
return;
m_connections.emplace_back(
[this, ioc = std::move(ioc), socket = std::move(socket)]() mutable { serve(*ioc, std::move(socket)); });
}
}
void serve(net::io_context& ioc, tcp::socket socket)
{
if (m_behavior == Behavior::SilentBeforeHandshake) {
mark_ready();
wait_for_release();
return;
}
beast::flat_buffer buffer;
http::request<http::string_body> request;
beast::error_code ec;
http::read(socket, buffer, request, ec);
if (ec)
return;
// The agent also queries the printer's status and object list over plain HTTP.
if (!websocket::is_upgrade(request)) {
serve_http(socket, request);
return;
}
websocket::stream<tcp::socket> ws{std::move(socket)};
ws.accept(request, ec);
// Without its object list the stream never sends the subscribe request.
const int requests = m_behavior == Behavior::StallsObjectList ? 1 : 2;
for (int i = 0; i < requests && !ec; ++i) {
beast::flat_buffer message;
ws.read(message, ec);
}
if (!ec && (m_behavior == Behavior::AnswersPings || m_behavior == Behavior::Chatters || m_behavior == Behavior::HangsUp)) {
const std::string status =
nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"result", {{"status", {{"print_stats", {{"state", "standby"}}}}}}}}.dump();
ws.write(net::buffer(status), ec);
}
if (ec)
return;
switch (m_behavior) {
case Behavior::AnswersPings:
case Behavior::AnswersPingsWithoutStatus:
case Behavior::Chatters:
mark_ready();
answer_pings(ioc, ws, m_behavior == Behavior::Chatters);
break;
case Behavior::HangsUp:
case Behavior::HangsUpWithoutAnswer:
ws.close(websocket::close_code::normal, ec);
mark_ready();
break;
case Behavior::IgnoresClose: {
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"}, {"method", "notify_klippy_disconnected"}}.dump();
ws.write(net::buffer(notification), ec);
mark_ready();
wait_for_release();
break;
}
case Behavior::StallsObjectList: answer_pings(ioc, ws, false); break;
default: mark_ready(); wait_for_release();
}
}
void serve_http(tcp::socket& socket, const http::request<http::string_body>& request)
{
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_requests;
m_changed.notify_all();
}
if (m_behavior == Behavior::StallsObjectList) {
mark_ready();
wait_for_release();
return;
}
if (m_behavior == Behavior::StallsStatusQuery) {
wait_for_release();
beast::error_code ec;
socket.close(ec);
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_hung_up;
m_changed.notify_all();
return;
}
http::response<http::string_body> response{http::status::not_found, request.version()};
response.prepare_payload();
beast::error_code ec;
http::write(socket, response, ec);
}
// Keeps a read pending, which answers pings, until released or closed, and with `chatter` also sends a notification every 50 ms.
void answer_pings(net::io_context& ioc, websocket::stream<tcp::socket>& ws, bool chatter)
{
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"},
{"method", "notify_proc_stat_update"},
{"params", nlohmann::json::array({nlohmann::json::object()})}}
.dump();
beast::flat_buffer message;
bool reading = true;
bool writing = false;
std::function<void()> read_next = [&] {
ws.async_read(message, [&](beast::error_code ec, std::size_t) {
message.clear();
if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames;
}
if (ec)
reading = false;
else
read_next();
});
};
read_next();
auto next_notification = std::chrono::steady_clock::now();
while (reading && !wait_for_release(10ms)) {
if (chatter && !writing && std::chrono::steady_clock::now() >= next_notification) {
writing = true;
next_notification += 50ms;
ws.async_write(net::buffer(notification), [&](beast::error_code, std::size_t) { writing = false; });
}
ioc.restart();
ioc.run_for(20ms);
}
beast::error_code ignored;
ws.next_layer().close(ignored);
while (reading || writing) {
ioc.restart();
ioc.run_for(20ms);
}
}
void mark_ready()
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_ready;
m_changed.notify_all();
}
void wait_for_release()
{
std::unique_lock<std::mutex> lock(m_mutex);
m_changed.wait(lock, [this] { return m_released; });
}
bool wait_for_release(std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [this] { return m_released; });
}
const Behavior m_behavior;
net::io_context m_accept_ioc;
tcp::acceptor m_acceptor{m_accept_ioc, {net::ip::make_address("127.0.0.1"), 0}};
std::atomic<bool> m_stopping{false};
std::thread m_accept_thread;
std::vector<std::thread> m_connections;
std::mutex m_mutex;
std::condition_variable m_changed;
int m_ready = 0;
int m_http_requests = 0;
int m_http_hung_up = 0;
int m_close_frames = 0;
bool m_released = false;
};
// Records when the agent dispatches a status report.
class Reports
{
public:
void attach(StreamAgent& agent)
{
agent.set_on_local_message_fn([this](std::string, std::string) {
std::lock_guard<std::mutex> lock(m_mutex);
m_times.push_back(std::chrono::steady_clock::now());
m_changed.notify_all();
});
}
bool wait_for(std::size_t count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_times.size() >= count; });
}
std::vector<std::chrono::steady_clock::time_point> times()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_times;
}
private:
std::mutex m_mutex;
std::condition_variable m_changed;
std::vector<std::chrono::steady_clock::time_point> m_times;
};
// A printer and a stream agent connected to it.
struct StreamFixture
{
explicit StreamFixture(Behavior behavior) : printer(behavior) {}
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
~StreamFixture() { printer.release(); }
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
bool stops_within(std::chrono::milliseconds limit)
{
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
printer.release();
stopped.wait();
return in_time;
}
FakePrinter printer;
StreamAgent agent{""};
};
} // namespace
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerPrinterAgent][Discovery]")
{
DiscoveryAgent agent("");
unsigned announcements = 0;
nlohmann::json announcement;
agent.set_on_ssdp_msg_fn([&](std::string payload) {
++announcements;
announcement = nlohmann::json::parse(payload);
});
agent.start_discovery(true, false);
REQUIRE(announcements == 0);
REQUIRE(agent.fetch_count == 0);
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
}
TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
{
// An open websocket gets the 200 ms close timeout, and a stalled object list request up to 1 s of curl progress to see the stop.
const auto [behavior, limit] = GENERATE(table<Behavior, std::chrono::milliseconds>({
{Behavior::SilentBeforeHandshake, 500ms},
{Behavior::Silent, 1000ms},
{Behavior::StallsObjectList, 2000ms},
}));
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.agent.ws_timings.close = 200ms;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(limit));
}
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
{
// A stop while the stream reads, and one while it waits on the object list request.
const Behavior behavior = GENERATE(Behavior::AnswersPings, Behavior::StallsObjectList);
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s));
CHECK(f.printer.close_frames() == 1);
}
TEST_CASE("Stopping the status stream does not wait out the reconnect delay", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 10s;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// Lets the stream reach its reconnect wait.
std::this_thread::sleep_for(200ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects when the printer stops answering", "[MoonrakerPrinterAgent][StatusStream]")
{
// An unanswered close ends at whichever of the handshake and close timeouts is shorter.
const auto [behavior, handshake, close] = GENERATE(table<Behavior, std::chrono::milliseconds, std::chrono::milliseconds>({
{Behavior::Silent, 300ms, 1000ms},
{Behavior::IgnoresClose, 300ms, 1000ms},
{Behavior::IgnoresClose, 1000ms, 200ms},
}));
CAPTURE(static_cast<int>(behavior), handshake.count(), close.count());
StreamFixture f(behavior);
f.agent.ws_timings.handshake = handshake;
f.agent.ws_timings.close = close;
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.reconnect_delay = 20ms;
f.start();
CHECK(f.printer.wait_for_ready_connections(2));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects at the shortest delay while the printer answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 50ms;
f.start();
// Twelve 50 ms waits; a backoff that kept doubling would need over 10 s.
CHECK(f.printer.wait_for_ready_connections(12, 3s));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream keeps retrying a printer that never answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUpWithoutAnswer);
f.agent.ws_timings.reconnect_delay = 1ms;
f.start();
// The backoff stops doubling at 32 ms, so 20 attempts take well under the wait.
CHECK(f.printer.wait_for_ready_connections(20));
CHECK(f.stops_within(3s));
}
TEST_CASE("A callback on the stream thread can stop the status stream", "[MoonrakerPrinterAgent][StatusStream]")
{
std::promise<void> stopped;
std::once_flag stopped_once;
StreamFixture f(Behavior::AnswersPings);
f.agent.set_on_local_message_fn([&](std::string, std::string) {
std::call_once(stopped_once, [&] {
f.agent.stop_status_stream();
stopped.set_value();
});
});
f.start();
REQUIRE(stopped.get_future().wait_for(5s) == std::future_status::ready);
CHECK(f.stops_within(3s));
}
TEST_CASE("A quiet printer that is still up keeps reporting its status", "[MoonrakerPrinterAgent][StatusStream]")
{
Reports reports;
StreamFixture f(Behavior::AnswersPings);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 300ms;
reports.attach(f.agent);
f.start();
// The status the printer sent, then two heartbeats.
REQUIRE(reports.wait_for(3));
const auto times = reports.times();
// The steady clock is compared in whole milliseconds, so allow 1 ms of rounding.
CHECK(times[1] - times[0] >= 299ms);
CHECK(times[2] - times[1] >= 299ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream sends no heartbeat while the printer has nothing new to report", "[MoonrakerPrinterAgent][StatusStream]")
{
const Behavior behavior = GENERATE(Behavior::Chatters, Behavior::AnswersPingsWithoutStatus);
CAPTURE(static_cast<int>(behavior));
Reports reports;
StreamFixture f(behavior);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 800ms;
reports.attach(f.agent);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// The chattering printer's status, and nothing from the one that never sent a status.
const std::size_t expected = behavior == Behavior::Chatters ? 1 : 0;
std::this_thread::sleep_for(1500ms);
CHECK(reports.times().size() == expected);
CHECK(f.stops_within(3s));
}
TEST_CASE("A disconnect during the connection queries keeps the status stream from starting", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::StallsStatusQuery);
PrinterConnectionParams params;
params.dev_id = "printer";
params.host = f.printer.host();
params.port = f.printer.port();
f.agent.connect_printer(params);
REQUIRE(f.printer.wait_for_http_requests(1));
f.agent.disconnect_printer();
f.printer.release();
// The query has returned once the printer hangs up on it, and a stream would connect right after.
REQUIRE(f.printer.wait_for_http_hang_ups(1));
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
}
+16 -2
View File
@@ -615,8 +615,22 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
// Ensure the next import executes the new submodule rather than reusing a stale package child.
write_package("'new'");
boost::system::error_code ec;
fs::remove_all(package_root / "__pycache__", ec);
{
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
// interpreter caches it.
PythonGILState gil;
REQUIRE(gil);
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
REQUIRE(util);
for (const char* file : {"__init__.py", "sub.py"}) {
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
REQUIRE(source);
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
REQUIRE(cached);
boost::system::error_code ec;
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
}
}
paths.clear();
modules.clear();
+64
View File
@@ -126,6 +126,55 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
}
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
{
CHECK(KeyChord{ 'A' }.edits_text());
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_SPACE }.edits_text());
CHECK(KeyChord{ WXK_BACK }.edits_text());
CHECK(KeyChord{ WXK_DELETE }.edits_text());
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_HOME }.edits_text());
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RETURN }.edits_text());
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
CHECK_FALSE(KeyChord{}.edits_text());
#ifdef __APPLE__
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#else
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#endif
}
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
{
#ifdef _WIN32
@@ -186,6 +235,21 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
}
}
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
{
ShortcutRegistry registry;
size_t checked = 0;
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
const ShortcutInfo& info = shortcut_info(Shortcut(i));
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
continue;
CAPTURE(info.key);
CHECK_FALSE(registry.binding(info.id).edits_text());
++checked;
}
CHECK(checked > 0);
}
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
{
ShortcutRegistry registry;