mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-19 13:41:03 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c74ab393b | ||
|
|
948e6637fb | ||
|
|
40761ec6e2 | ||
|
|
186cd8e65f | ||
|
|
8bc0eec4aa | ||
|
|
ec30dad1cf | ||
|
|
f1428b72b8 | ||
|
|
3880f2c58e |
No files matched your search
@@ -574,8 +574,7 @@ DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the
|
||||
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
|
||||
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
|
||||
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
|
||||
DMABUF renderer. On a hypervisor guest (`/proc/cpuinfo` `hypervisor` flag) and WebKitGTK >= 2.44 it sets
|
||||
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` alone, since virtual GPUs cannot back the hardware path either. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
|
||||
DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
|
||||
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
|
||||
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
|
||||
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
|
||||
|
||||
@@ -568,18 +568,13 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
|
||||
## WebKitGTK on Linux sessions
|
||||
|
||||
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
|
||||
sets the DMABUF workarounds and then branches on `GDK_BACKEND`:
|
||||
sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`:
|
||||
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
|
||||
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
|
||||
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
|
||||
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
|
||||
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
|
||||
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
|
||||
- **Hypervisor guests**, same position: when `is_hypervisor_guest()` (the `hypervisor` flag in
|
||||
`/proc/cpuinfo`) and WebKitGTK >= 2.44, it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` non-replacing.
|
||||
Virtual GPUs (VirtualBox VMSVGA, VMware, QEMU) cannot back the hardware DMABUF path either, and
|
||||
FORCE_SHM alone was confirmed to fix web views in VirtualBox; the `WEBKIT_FORCE_DMABUF_RENDERER` pair
|
||||
is not needed without the Debian `isNVIDIA()` patch. The flag is x86-only, so ARM guests are not covered.
|
||||
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
|
||||
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
|
||||
@@ -595,9 +590,9 @@ Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_
|
||||
(see `references/platforms.md`), not these environment variables.
|
||||
|
||||
**Pitfall.**
|
||||
- **Rule:** On NVIDIA and hypervisor guests reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver and virtual GPUs cannot back WebKit's
|
||||
hardware DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
|
||||
- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware
|
||||
DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
|
||||
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
|
||||
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
|
||||
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
|
||||
|
||||
@@ -21,6 +21,8 @@ on:
|
||||
- 'scripts/test_build_win.ps1'
|
||||
- 'build_release_macos.sh'
|
||||
- 'scripts/build_preset_cache.*'
|
||||
- 'signpath/**'
|
||||
- 'scripts/*.ps1'
|
||||
- 'scripts/flatpak/**'
|
||||
- 'scripts/msix/**'
|
||||
- 'tests/**'
|
||||
@@ -38,11 +40,13 @@ on:
|
||||
- 'version.inc'
|
||||
- ".github/workflows/build_*.yml"
|
||||
- ".github/workflows/unit_tests*.yml"
|
||||
- 'signpath/**'
|
||||
- 'build_linux.sh'
|
||||
- 'build_win.bat'
|
||||
- 'scripts/test_build_win.ps1'
|
||||
- 'build_release_macos.sh'
|
||||
- 'scripts/build_preset_cache.*'
|
||||
- 'scripts/*.ps1'
|
||||
- 'scripts/flatpak/**'
|
||||
- 'scripts/msix/**'
|
||||
- 'tests/**'
|
||||
|
||||
@@ -424,6 +424,10 @@ jobs:
|
||||
"BUILD_DIR=build" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
|
||||
"ARCH_SUFFIX=_x64" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
|
||||
}
|
||||
# Release branches ship binaries and installers signed through SignPath with the
|
||||
# release certificate. Every signing request waits for a manual approval in
|
||||
# SignPath, so main and pull request builds stay unsigned.
|
||||
"SIGN_WINDOWS=${{ github.repository == 'OrcaSlicer/OrcaSlicer' && startsWith(github.ref, 'refs/heads/release/') && !vars.SELF_HOSTED }}" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
|
||||
|
||||
- name: setup MSVC
|
||||
if: runner.os == 'Windows'
|
||||
@@ -504,19 +508,157 @@ jobs:
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
# makensis generates the uninstaller while it compiles the installer, so a first
|
||||
# installer build exports it to be signed with the binaries, and "Create installer
|
||||
# Win" embeds the signed copy. See src/dev-utils/platform/msw/uninstaller-signing.nsh.
|
||||
- name: Export the Windows uninstaller for SignPath
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
working-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundle = "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer".Replace('\', '/')
|
||||
# Forward slashes: cpack drops the backslashes from makensis arguments.
|
||||
$uninstaller = "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/Uninstall.exe".Replace('\', '/')
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $uninstaller) | Out-Null
|
||||
cpack -G NSIS -D CPACK_INSTALL_CMAKE_PROJECTS= -D "CPACK_INSTALLED_DIRECTORIES=$bundle;." -D "CPACK_NSIS_EXECUTABLE_PRE_ARGUMENTS=/DORCA_EXPORT_UNINSTALLER=$uninstaller"
|
||||
|
||||
# Signs OrcaSlicer's own binaries, the uninstaller and every bundled executable, DLL
|
||||
# and Python extension module that ships without a signature; files their vendor
|
||||
# signed, such as the Microsoft runtime, keep that signature. See signpath/README.md.
|
||||
- name: Stage Windows binaries for SignPath
|
||||
id: stage-windows-binaries
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ env.ver }}" -replace '^V', ''
|
||||
./scripts/stage-for-signpath.ps1 -Bundle "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer" -Path "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/Uninstall.exe" -Destination "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/unsigned-binaries" -ProductVersion $version
|
||||
"version=$version" | Out-File -Append -FilePath $env:GITHUB_OUTPUT -Encoding utf8
|
||||
|
||||
- name: Upload unsigned Windows binaries for SignPath
|
||||
id: upload-windows-binaries
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: signpath_windows_binaries${{ env.ARCH_SUFFIX }}_unsigned
|
||||
path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/unsigned-binaries
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# Waits until the request is approved in SignPath.
|
||||
- name: Sign Windows binaries with SignPath
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: OrcaSlicer
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: windows-binaries-v1
|
||||
github-artifact-id: ${{ steps.upload-windows-binaries.outputs.artifact-id }}
|
||||
parameters: |
|
||||
version: "${{ steps.stage-windows-binaries.outputs.version }}"
|
||||
wait-for-completion: true
|
||||
wait-for-completion-timeout-in-seconds: 10800
|
||||
output-artifact-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries
|
||||
|
||||
- name: Verify Windows binary signatures
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: ./scripts/verify-authenticode.ps1 -ArtifactDirectory '${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries'
|
||||
|
||||
- name: Replace Windows binaries with signed output
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: ./scripts/restore-from-signpath.ps1 -SignedDirectory "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries" -Bundle "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer" -Exclude Uninstall.exe
|
||||
|
||||
# NSIS is x86-only; it runs (and the installer it emits runs) under ARM64's
|
||||
# x86 emulation, packaging the native arm64 payload from build-arm64.
|
||||
# The installer packs the installed OrcaSlicer/ tree, which the portable zip and the
|
||||
# MSIX are made from too, instead of re-running the install rules: those take
|
||||
# CPython and uv from the dependency tree and the download cache, which keep the
|
||||
# unsigned copies.
|
||||
- name: Create installer Win
|
||||
if: runner.os == 'Windows' && !vars.SELF_HOSTED
|
||||
working-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }}
|
||||
run: |
|
||||
cpack -G NSIS
|
||||
$bundle = "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer".Replace('\', '/')
|
||||
$signedUninstaller = @()
|
||||
if ($env:SIGN_WINDOWS -eq 'true') {
|
||||
$uninstaller = "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries/Uninstall.exe".Replace('\', '/')
|
||||
$signedUninstaller = "-D", "CPACK_NSIS_EXECUTABLE_PRE_ARGUMENTS=/DORCA_SIGNED_UNINSTALLER=$uninstaller"
|
||||
}
|
||||
cpack -G NSIS -D CPACK_INSTALL_CMAKE_PROJECTS= -D "CPACK_INSTALLED_DIRECTORIES=$bundle;." @signedUninstaller
|
||||
|
||||
# Installs into a scratch directory and checks that every executable and DLL an
|
||||
# installation puts on disk, the uninstaller included, carries a valid signature,
|
||||
# ours or its vendor's. The hosted runner is discarded afterwards, so the
|
||||
# installation is left in place.
|
||||
- name: Verify every binary the installer installs is signed
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installDir = Join-Path "${{ runner.temp }}" "orca-install-check"
|
||||
$installer = "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe"
|
||||
# NSIS requires /D= to come last.
|
||||
$setup = Start-Process -FilePath $installer -ArgumentList "/S", "/D=$installDir" -Wait -PassThru
|
||||
if ($setup.ExitCode -ne 0) { throw "The installer exited with code $($setup.ExitCode)." }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $installDir "Uninstall.exe") -PathType Leaf)) {
|
||||
throw "The installation in '$installDir' has no Uninstall.exe."
|
||||
}
|
||||
./scripts/verify-authenticode.ps1 -ArtifactDirectory $installDir
|
||||
|
||||
- name: Stage Windows installer for SignPath
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: ./scripts/stage-for-signpath.ps1 -Path "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe" -Destination "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/unsigned-installer" -ProductVersion "${{ steps.stage-windows-binaries.outputs.version }}"
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
id: upload-windows-installer
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: signpath_windows_installer${{ env.ARCH_SUFFIX }}_unsigned
|
||||
path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/unsigned-installer
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign Windows installer with SignPath
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: OrcaSlicer
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: windows-installer-v1
|
||||
github-artifact-id: ${{ steps.upload-windows-installer.outputs.artifact-id }}
|
||||
parameters: |
|
||||
version: "${{ steps.stage-windows-binaries.outputs.version }}"
|
||||
wait-for-completion: true
|
||||
wait-for-completion-timeout-in-seconds: 3600
|
||||
output-artifact-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-installer
|
||||
|
||||
- name: Verify Windows installer signature
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: ./scripts/verify-authenticode.ps1 -ArtifactDirectory '${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-installer' -Files 'OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe'
|
||||
|
||||
- name: Replace Windows installer with signed output
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: Copy-Item -LiteralPath "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-installer/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe" -Destination "${{ github.workspace }}/${{ env.BUILD_DIR }}" -Force
|
||||
|
||||
- name: Pack app
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }}
|
||||
shell: cmd
|
||||
run: '"C:/Program Files/7-Zip/7z.exe" a -tzip OrcaSlicer_Windows_${{ env.ver }}${{ env.ARCH_SUFFIX }}_portable.zip ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$zipPath = "OrcaSlicer_Windows_${{ env.ver }}${{ env.ARCH_SUFFIX }}_portable.zip"
|
||||
Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue
|
||||
& "C:/Program Files/7-Zip/7z.exe" a -tzip $zipPath "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
- name: Pack PDB
|
||||
if: runner.os == 'Windows' && inputs.arch != 'arm64' && !vars.SELF_HOSTED
|
||||
@@ -530,6 +672,7 @@ jobs:
|
||||
with:
|
||||
name: OrcaSlicer_Windows_${{ env.ver }}${{ env.ARCH_SUFFIX }}_portable
|
||||
path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifacts Win installer
|
||||
if: runner.os == 'Windows' && !vars.SELF_HOSTED
|
||||
|
||||
@@ -232,31 +232,6 @@ jobs:
|
||||
mkdir -p ${{ runner.temp }}/profile-check-results
|
||||
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
||||
|
||||
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
|
||||
# one long line per setting, for check_profiles_comment.yml to post.
|
||||
- name: Diff custom G-code
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
|
||||
results=${{ runner.temp }}/profile-check-results
|
||||
diff=${{ runner.temp }}/gcode_diff.md
|
||||
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
|
||||
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
|
||||
if [ -s "$diff" ]; then
|
||||
{
|
||||
echo "<!-- profile-gcode-diff-comment -->"
|
||||
cat "$diff"
|
||||
echo ""
|
||||
# The comment workflow finds this line as the only one starting with <sub>.
|
||||
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
|
||||
} > "$results/gcode_diff.md"
|
||||
fi
|
||||
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
|
||||
touch "$results/gcode_diff_done.txt"
|
||||
|
||||
- name: Prepare comment artifact
|
||||
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||
run: |
|
||||
|
||||
@@ -38,7 +38,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
if [ ! -f pr_number.txt ]; then
|
||||
echo "No pr_number.txt in artifact, skipping."
|
||||
@@ -50,72 +49,18 @@ jobs:
|
||||
echo "Invalid PR number: $PR_NUMBER"
|
||||
exit 1
|
||||
fi
|
||||
# PR code wrote the artifact, so only a run for the named PR's current head may post.
|
||||
if [ -z "$HEAD_SHA" ]; then
|
||||
echo "The triggering run names no head commit."
|
||||
exit 1
|
||||
fi
|
||||
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
|
||||
echo "Cannot look up PR #${PR_NUMBER}."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
|
||||
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Lists every bot comment on the PR, one JSON object per line.
|
||||
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
|
||||
|
||||
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
|
||||
OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
||||
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
||||
for comment_id in $OLD_IDS; do
|
||||
echo "Deleting outdated profile-validation comment ${comment_id}"
|
||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||
done
|
||||
|
||||
# Post a new comment only when validation failed (pr_comment.md present).
|
||||
# A failed post fails the job only after the G-code diff is handled.
|
||||
failed=0
|
||||
if [ -f pr_comment.md ]; then
|
||||
gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
|
||||
gh pr comment "$PR_NUMBER" --body-file pr_comment.md
|
||||
else
|
||||
echo "Validation succeeded; cleaned up prior comments without posting."
|
||||
fi
|
||||
|
||||
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
|
||||
update_gcode_diff_comment() {
|
||||
if [ ! -f gcode_diff_done.txt ]; then
|
||||
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
|
||||
return 0
|
||||
fi
|
||||
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
|
||||
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
|
||||
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
|
||||
rm gcode_diff.md
|
||||
fi
|
||||
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
|
||||
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
|
||||
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
|
||||
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
|
||||
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
|
||||
return 0
|
||||
fi
|
||||
# Edits in place when only the <sub> line differs, since edits notify nobody.
|
||||
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
|
||||
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
|
||||
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
for comment_id in $GCODE_IDS; do
|
||||
echo "Deleting outdated G-code diff comment ${comment_id}"
|
||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||
done
|
||||
if [ -f gcode_diff.md ]; then
|
||||
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
|
||||
fi
|
||||
}
|
||||
update_gcode_diff_comment
|
||||
exit "$failed"
|
||||
+17
-13
@@ -288,19 +288,6 @@ if (APPLE)
|
||||
# Type"); Ninja/-Wall does not, and under -Werror it fails Xcode builds on code CI accepts.
|
||||
set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION "NO")
|
||||
|
||||
# The Xcode generator ignores CMAKE_<LANG>_COMPILER_LAUNCHER, so hand the launcher to
|
||||
# Xcode's own Compiler Launcher setting, which wraps every clang compile (C, C++,
|
||||
# Objective-C++ and the precompiled header) with one program: the C++ launcher wins when
|
||||
# both are set. The setting takes a single program path, not a CMake launcher list.
|
||||
# Xcode versions without it ignore it and build uncached.
|
||||
if (CMAKE_GENERATOR STREQUAL "Xcode")
|
||||
if (CMAKE_CXX_COMPILER_LAUNCHER)
|
||||
set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_CXX_COMPILER_LAUNCHER}")
|
||||
elseif (CMAKE_C_COMPILER_LAUNCHER)
|
||||
set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_C_COMPILER_LAUNCHER}")
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}")
|
||||
elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN")
|
||||
@@ -1368,6 +1355,23 @@ set (CPACK_NSIS_EXECUTABLES_DIRECTORY ".")
|
||||
set(CPACK_PACKAGE_EXECUTABLES "orca-slicer;OrcaSlicer")
|
||||
set(CPACK_CREATE_DESKTOP_LINKS "orca-slicer")
|
||||
set (CPACK_RESOURCE_FILE_LICENSE ${CMAKE_SOURCE_DIR}/LICENSE.txt) # must also include in install command
|
||||
# Version information of the installer and of the uninstaller it writes, the same as the
|
||||
# binaries it installs carry (src/dev-utils/platform/msw/OrcaSlicer-version.rc.in):
|
||||
# SignPath only signs them when ProductName and ProductVersion match the artifact
|
||||
# configurations in signpath/. The include lets the release workflow embed a signed
|
||||
# uninstaller. The NSIS strings are single-quoted and the paths use forward slashes
|
||||
# because CPack writes this value into CPackConfig.cmake unescaped; !include only accepts
|
||||
# a forward-slash path through !addincludedir.
|
||||
string (CONCAT CPACK_NSIS_DEFINES
|
||||
"VIProductVersion '${ORCA_VERSION_MAJOR}.${ORCA_VERSION_MINOR}.${ORCA_VERSION_PATCH}.0'\n"
|
||||
"VIFileVersion '${ORCA_VERSION_MAJOR}.${ORCA_VERSION_MINOR}.${ORCA_VERSION_PATCH}.0'\n"
|
||||
"VIAddVersionKey /LANG=1033 'CompanyName' 'SoftFever'\n"
|
||||
"VIAddVersionKey /LANG=1033 'FileDescription' '${SLIC3R_APP_NAME} Installer'\n"
|
||||
"VIAddVersionKey /LANG=1033 'FileVersion' '${SoftFever_VERSION}'\n"
|
||||
"VIAddVersionKey /LANG=1033 'ProductName' '${SLIC3R_APP_NAME}'\n"
|
||||
"VIAddVersionKey /LANG=1033 'ProductVersion' '${SoftFever_VERSION}'\n"
|
||||
"!addincludedir '${CMAKE_SOURCE_DIR}/src/dev-utils/platform/msw'\n"
|
||||
"!include 'uninstaller-signing.nsh'\n")
|
||||
|
||||
set(CPACK_WIX_UPGRADE_GUID "058245e8-20e0-4a95-9ab7-1acfe17ad511")
|
||||
set(CPACK_GENERATOR NSIS)
|
||||
|
||||
@@ -111,54 +111,6 @@ else
|
||||
export CMAKE_POLICY_COMPAT=""
|
||||
fi
|
||||
|
||||
# Compiler cache, as in build_linux.sh. The program comes from CMAKE_CCACHE, else from a
|
||||
# launcher already set in the environment (the CI build sets ccache there), else sccache or
|
||||
# ccache from PATH; one program serves both languages. Each run applies its own choice to the
|
||||
# build directories, replacing a launcher an earlier run or a hand-run configure recorded.
|
||||
CMAKE_CCACHE_ARGS=()
|
||||
CCACHE_VAR=""
|
||||
for var in CMAKE_CCACHE CMAKE_CXX_COMPILER_LAUNCHER CMAKE_C_COMPILER_LAUNCHER; do
|
||||
if [ -n "${!var:-}" ]; then
|
||||
CCACHE_VAR=$var
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$CCACHE_VAR" ]; then
|
||||
CCACHE_PROGRAM=${!CCACHE_VAR}
|
||||
echo "Checking ${CCACHE_VAR} environment variable for compiler cache program..."
|
||||
CMAKE_CCACHE=$(command -v "${CCACHE_PROGRAM}") || {
|
||||
echo "${CCACHE_VAR} environment variable is set to '${CCACHE_PROGRAM}' but it was not found in PATH."
|
||||
CMAKE_CCACHE=""
|
||||
}
|
||||
elif command -v sccache >/dev/null 2>&1 ; then
|
||||
CMAKE_CCACHE=$(command -v sccache)
|
||||
elif command -v ccache >/dev/null 2>&1 ; then
|
||||
CMAKE_CCACHE=$(command -v ccache)
|
||||
fi
|
||||
if [ -n "${CMAKE_CCACHE}" ] ; then
|
||||
echo "${CMAKE_CCACHE} found, enabling compiler caching..."
|
||||
CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER="${CMAKE_CCACHE}" -DCMAKE_CXX_COMPILER_LAUNCHER="${CMAKE_CCACHE}")
|
||||
# The same ccache settings as the CI build. Without this sloppiness ccache refuses every
|
||||
# compile that uses the precompiled header, which is most of OrcaSlicer, and skips headers
|
||||
# a checkout has just written. It is added to the sloppiness already configured, and
|
||||
# CCACHE_SLOPPINESS or CCACHE_(NO)DEPEND set in the environment win.
|
||||
if [[ "$(basename "${CMAKE_CCACHE}")" == ccache ]] ; then
|
||||
if [[ -z "${CCACHE_SLOPPINESS+x}" ]] ; then
|
||||
CCACHE_SLOPPINESS=$("${CMAKE_CCACHE}" --get-config sloppiness 2>/dev/null || true)
|
||||
export CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS:+${CCACHE_SLOPPINESS},}pch_defines,time_macros,include_file_mtime,include_file_ctime"
|
||||
fi
|
||||
# Hash the includes the compiler reports instead of preprocessing every miss.
|
||||
if [[ -z "${CCACHE_DEPEND+x}${CCACHE_NODEPEND+x}" ]] ; then
|
||||
export CCACHE_DEPEND=1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Note: ccache or sccache are not found. Install either of them for faster rebuilds."
|
||||
# Clear a launcher recorded by an earlier configure. The entries must be set empty rather
|
||||
# than removed: CMake fills a missing launcher entry from the environment.
|
||||
CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER:STRING= -DCMAKE_CXX_COMPILER_LAUNCHER:STRING=)
|
||||
fi
|
||||
|
||||
echo "Build params:"
|
||||
echo " - ARCH: $ARCH"
|
||||
echo " - BUILD_CONFIG: $BUILD_CONFIG"
|
||||
@@ -210,7 +162,6 @@ function build_deps() {
|
||||
-DCMAKE_OSX_ARCHITECTURES:STRING="${_ARCH}" \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||
${CMAKE_POLICY_COMPAT}
|
||||
fi
|
||||
cmake --build . --config "$BUILD_CONFIG" --target deps
|
||||
@@ -313,7 +264,6 @@ function build_slicer() {
|
||||
-DCMAKE_OSX_ARCHITECTURES="${_ARCH}" \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||
${CMAKE_POLICY_COMPAT}
|
||||
fi
|
||||
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
|
||||
|
||||
@@ -53,13 +53,6 @@ Space counts as typing. The speed dial's default is the one bare Space, and
|
||||
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
|
||||
combo boxes), so it opens the dial from the canvases and the tab strip only.
|
||||
|
||||
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
|
||||
while a text field has the focus (a wx text entry, spin control, web view or ImGui
|
||||
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
|
||||
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
|
||||
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
|
||||
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
|
||||
|
||||
## Which event a chord matches
|
||||
|
||||
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
|
||||
|
||||
@@ -10657,9 +10657,6 @@ msgstr ""
|
||||
msgid "Send Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "No available printers"
|
||||
msgstr ""
|
||||
|
||||
msgid "The name length exceeds the limit."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Snapmaker",
|
||||
"version": "02.04.00.18",
|
||||
"version": "02.04.00.17",
|
||||
"force_update": "0",
|
||||
"description": "Snapmaker configurations",
|
||||
"machine_model_list": [
|
||||
|
||||
@@ -49,8 +49,5 @@
|
||||
],
|
||||
"pressure_advance": [
|
||||
"0.02"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
@@ -46,8 +46,5 @@
|
||||
],
|
||||
"fan_min_speed": [
|
||||
"0"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
"250"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"70"
|
||||
"178"
|
||||
],
|
||||
"fan_max_speed": [
|
||||
"20"
|
||||
|
||||
@@ -4,8 +4,5 @@
|
||||
"inherits": "fdm_filament_petg",
|
||||
"from": "system",
|
||||
"filament_id": "OFW8lqnb",
|
||||
"instantiation": "false",
|
||||
"temperature_vitrification": [
|
||||
"70"
|
||||
]
|
||||
"instantiation": "false"
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
"250"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"70"
|
||||
"178"
|
||||
],
|
||||
"fan_max_speed": [
|
||||
"20"
|
||||
|
||||
@@ -34,8 +34,5 @@
|
||||
],
|
||||
"nozzle_temperature": [
|
||||
"220"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -34,8 +34,5 @@
|
||||
],
|
||||
"filament_cooling_final_speed": [
|
||||
"60"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -34,8 +34,5 @@
|
||||
],
|
||||
"nozzle_temperature": [
|
||||
"220"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -40,8 +40,5 @@
|
||||
],
|
||||
"nozzle_temperature": [
|
||||
"220"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -31,8 +31,5 @@
|
||||
],
|
||||
"slow_down_layer_time": [
|
||||
"8"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"45"
|
||||
]
|
||||
}
|
||||
@@ -50,8 +50,5 @@
|
||||
"additional_cooling_fan_speed": [
|
||||
"70",
|
||||
"70"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
@@ -137,7 +137,7 @@
|
||||
"240"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
"60"
|
||||
],
|
||||
"nozzle_temperature_range_low": [
|
||||
"200"
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
"-1"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
"60"
|
||||
],
|
||||
"textured_cool_plate_temp": [
|
||||
"40"
|
||||
|
||||
@@ -29,8 +29,5 @@
|
||||
"additional_cooling_fan_speed": [
|
||||
"70",
|
||||
"70"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
@@ -49,8 +49,5 @@
|
||||
"additional_cooling_fan_speed": [
|
||||
"70",
|
||||
"70"
|
||||
],
|
||||
"temperature_vitrification": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.14",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.32",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.42",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"20",
|
||||
"12"
|
||||
],
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
||||
"machine_tool_change_time": "5",
|
||||
"max_layer_height": [
|
||||
"0.56",
|
||||
|
||||
@@ -45,10 +45,28 @@
|
||||
<color type="primary" scheme_preference="dark">#00695C</color>
|
||||
</branding>
|
||||
<releases>
|
||||
<release version="2.5.0-dev" date="2026-06-22" type="development">
|
||||
<url type="details">https://github.com/OrcaSlicer/OrcaSlicer/releases/tag/nightly-builds</url>
|
||||
<release version="2.5.0-alpha" date="2026-10-14" type="development">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_5_0_alpha</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog.</p>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.5.0-alpha release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.4.2" date="2026-07-26">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_4_2</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.4.2 release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.4.1" date="2026-06-26">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_4_1</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.4.1 release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.4.0" date="2026-06-20">
|
||||
<url type="details">https://www.orcaslicer.com/wiki/releases/release_2_4_0</url>
|
||||
<description>
|
||||
<p>See the release page for detailed changelog of OrcaSlicer 2.4.0 release.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.3.2" date="2025-03-23">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
|
||||
[string]$ArtifactDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-RelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Root,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$rootWithSeparator = $Root.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
||||
$rootUri = [Uri]$rootWithSeparator
|
||||
$pathUri = [Uri]$Path
|
||||
|
||||
[Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace("/", [IO.Path]::DirectorySeparatorChar)
|
||||
}
|
||||
|
||||
$artifactRoot = (Resolve-Path -LiteralPath $ArtifactDirectory).Path
|
||||
$binaries = Get-ChildItem -LiteralPath $artifactRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".dll") } |
|
||||
Sort-Object -Property FullName
|
||||
|
||||
if (-not $binaries) {
|
||||
Write-Warning "No .exe or .dll files found under '$artifactRoot'."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$binaries | ForEach-Object {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
|
||||
|
||||
[pscustomobject]@{
|
||||
RelativePath = Get-RelativePath -Root $artifactRoot -Path $_.FullName
|
||||
Status = $signature.Status
|
||||
SignatureType = $signature.SignatureType
|
||||
Signer = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { "" }
|
||||
SignerThumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { "" }
|
||||
Timestamped = [bool]$signature.TimeStamperCertificate
|
||||
TimeStamper = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { "" }
|
||||
}
|
||||
} | Format-Table -AutoSize
|
||||
@@ -12,7 +12,6 @@ commands:
|
||||
trim delete profile files no <vendor>.json list references
|
||||
update-index regenerate the *_list sections of <vendor>.json
|
||||
legacy-compat write the printers user presets can be migrated back to
|
||||
gcode-diff print a readable markdown diff of changed custom G-code
|
||||
|
||||
options shared by several commands:
|
||||
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
|
||||
@@ -95,14 +94,11 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import functools
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from collections import Counter, defaultdict
|
||||
@@ -196,7 +192,6 @@ VECTOR_KEYS = {
|
||||
|
||||
PRINT_CONFIG_CPP = os.path.normpath(
|
||||
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
|
||||
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
@@ -318,11 +313,11 @@ REMEDY_HINTS = {
|
||||
}
|
||||
|
||||
|
||||
def print_error(msg, file=None):
|
||||
print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
|
||||
def print_error(msg):
|
||||
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
|
||||
|
||||
def print_warning(msg, file=None):
|
||||
print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
|
||||
def print_warning(msg):
|
||||
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
|
||||
|
||||
def print_info(msg):
|
||||
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
||||
@@ -3214,223 +3209,6 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gcode-diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
|
||||
GCODE_DIFF_BUDGET = 60000
|
||||
# The file names a section's summary shows before "and N more".
|
||||
GCODE_DIFF_SUMMARY_FILES = 3
|
||||
|
||||
GCODE_DIFF_HEADER = (
|
||||
"## Custom G-code changes\n"
|
||||
"\n"
|
||||
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
|
||||
"diff of each changed G-code or other text setting.\n")
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _text_option_keys():
|
||||
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
|
||||
(every *_gcode option, and every one it marks multiline, such as notes), plus the
|
||||
ones Tab.cpp edits as code, such as filename_format."""
|
||||
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
keys = set()
|
||||
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
|
||||
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
|
||||
if match and (match.group(1).endswith("_gcode")
|
||||
or re.search(r"def->multiline\s*=\s*true", body)):
|
||||
keys.add(match.group(1))
|
||||
if "machine_start_gcode" not in keys:
|
||||
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
|
||||
# Tab.cpp only adds keys, so a missing file is not an error.
|
||||
try:
|
||||
with open(TAB_CPP, encoding="utf-8") as f:
|
||||
gui = f.read()
|
||||
except OSError:
|
||||
gui = ""
|
||||
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
|
||||
gui, re.DOTALL))
|
||||
return frozenset(keys)
|
||||
|
||||
|
||||
def _git(cwd, *args, stdin=None):
|
||||
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
|
||||
capture_output=True).stdout
|
||||
|
||||
|
||||
def _blobs_at(profiles_dir, specs):
|
||||
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
|
||||
profiles_dir) pairs, all read by one git cat-file process."""
|
||||
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
|
||||
out = _git(profiles_dir, "cat-file", "--batch",
|
||||
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
|
||||
blobs, pos = {}, 0
|
||||
for spec in specs:
|
||||
end = out.index(b"\n", pos)
|
||||
header = out[pos:end]
|
||||
pos = end + 1
|
||||
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
|
||||
if header.endswith((b" missing", b" ambiguous")):
|
||||
blobs[spec] = None
|
||||
continue
|
||||
_oid, kind, size = header.split()
|
||||
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
|
||||
pos += int(size) + 1
|
||||
return blobs
|
||||
|
||||
|
||||
def _parse_profile(raw, rel, where):
|
||||
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
|
||||
if raw is None:
|
||||
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8-sig"))
|
||||
except ValueError as e:
|
||||
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _changed_profiles(profiles_dir, base, head):
|
||||
"""(old path, new path) of each JSON file under profiles_dir that differs between
|
||||
base and head (the working tree, untracked files included, when None); the absent
|
||||
side of an added or deleted file is None, and so is a side that is not JSON. Renames
|
||||
are paired."""
|
||||
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
|
||||
*([head] if head else []), "--", ".")
|
||||
fields = out.decode("utf-8").split("\0")
|
||||
pairs, i = [], 0
|
||||
while i + 1 < len(fields):
|
||||
status = fields[i]
|
||||
old = new = fields[i + 1]
|
||||
if status[0] in "RC":
|
||||
new = fields[i + 2]
|
||||
i += 3
|
||||
else:
|
||||
i += 2
|
||||
old = None if status[0] == "A" or not old.endswith(".json") else old
|
||||
new = None if status[0] == "D" or not new.endswith(".json") else new
|
||||
if old or new:
|
||||
pairs.append((old, new))
|
||||
if head is None:
|
||||
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
|
||||
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
|
||||
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
|
||||
return pairs
|
||||
|
||||
|
||||
def _text_lines(value):
|
||||
return [line.rstrip() for line in value.split("\n")] if value else []
|
||||
|
||||
|
||||
def _text_changes(old, new):
|
||||
"""(setting, unified diff) for every G-code or other text value that differs
|
||||
between two profiles; a vector option with several values gives one per value."""
|
||||
changes = []
|
||||
for key in list(new) + [k for k in old if k not in new]:
|
||||
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
|
||||
if a == b or not all(isinstance(v, str) for v in a + b):
|
||||
continue
|
||||
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
|
||||
continue
|
||||
width = max(len(a), len(b))
|
||||
for i in range(width):
|
||||
diff = list(difflib.unified_diff(
|
||||
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
|
||||
lineterm=""))[2:] # drops the ---/+++ file header
|
||||
if diff:
|
||||
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
|
||||
return changes
|
||||
|
||||
|
||||
def _gcode_diff_section(files, setting, diff):
|
||||
# A fence longer than any backtick run in the G-code cannot be closed by it.
|
||||
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
|
||||
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
|
||||
def code(text):
|
||||
return f"<code>{html.escape(text, quote=False)}</code>"
|
||||
names, listing = ", ".join(map(code, files)), ""
|
||||
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
|
||||
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
|
||||
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
|
||||
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
|
||||
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
|
||||
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
|
||||
|
||||
|
||||
def gcode_diff(profiles_dir, base, head=None, vendors=None):
|
||||
"""Markdown with one collapsed diff per changed G-code setting between base and
|
||||
head, or "" when none changed. Files whose diff of a setting is identical share
|
||||
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
|
||||
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
|
||||
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
|
||||
specs = ([(base, old) for old, _ in pairs if old]
|
||||
+ [(head, new) for _, new in pairs if new and head])
|
||||
blobs = _blobs_at(profiles_dir, specs) if specs else {}
|
||||
|
||||
def load(ref, rel):
|
||||
if rel is None:
|
||||
return {}
|
||||
if ref:
|
||||
return _parse_profile(blobs[(ref, rel)], rel, ref)
|
||||
try:
|
||||
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
|
||||
except OSError as e:
|
||||
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
groups = {} # (setting, diff) -> files, in the order git lists them
|
||||
for old_rel, new_rel in pairs:
|
||||
old, new = load(base, old_rel), load(head, new_rel)
|
||||
if not isinstance(old, dict) or not isinstance(new, dict):
|
||||
continue
|
||||
for setting, diff in _text_changes(old, new):
|
||||
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
|
||||
if not groups:
|
||||
return ""
|
||||
|
||||
sections = [_gcode_diff_section(files, setting, diff)
|
||||
for (setting, diff), files in groups.items()]
|
||||
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
|
||||
shown = 0
|
||||
for section in sections:
|
||||
budget -= len(section.encode("utf-8"))
|
||||
if budget < 0:
|
||||
break
|
||||
shown += 1
|
||||
note, hidden = "", len(sections) - shown
|
||||
if hidden:
|
||||
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
|
||||
"shown because the comment would be too long for GitHub. See the Files "
|
||||
"changed tab for the rest.\n")
|
||||
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
|
||||
|
||||
|
||||
def _default_gcode_diff_base(profiles_dir, head):
|
||||
for main in ("upstream/main", "origin/main"):
|
||||
try:
|
||||
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
|
||||
|
||||
|
||||
def run_gcode_diff(profiles_dir, vendors, base, head):
|
||||
try:
|
||||
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
|
||||
head, vendors)
|
||||
# UnicodeDecodeError means git listed a file name that is not UTF-8.
|
||||
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
|
||||
stderr = getattr(e, "stderr", None)
|
||||
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
|
||||
return 1
|
||||
sys.stdout.write(markdown)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3455,9 +3233,6 @@ examples:
|
||||
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
|
||||
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
|
||||
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
|
||||
orca_profile_tool.py gcode-diff > gcode.md
|
||||
the changed custom G-code of the working tree since it left upstream/main,
|
||||
as markdown with one collapsed diff per setting
|
||||
|
||||
after adding, renaming or deleting profile files, run in this order:
|
||||
normalize -> update-index -> generate-id -> check
|
||||
@@ -3612,25 +3387,6 @@ def build_parser():
|
||||
legacy_cmd.add_argument("--check", action="store_true",
|
||||
help="fail if the map differs from what --base implies; write nothing")
|
||||
|
||||
gcode_cmd = add(
|
||||
"gcode-diff", [vendor_opt, profiles_opt],
|
||||
"print a readable markdown diff of changed custom G-code",
|
||||
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
|
||||
"setting that differs between --base and --head in each changed profile\n"
|
||||
"file, one collapsed section per setting. Files whose diff of a setting is\n"
|
||||
"identical share a section. Trailing spaces are removed from every line, and\n"
|
||||
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
|
||||
"when no such setting changed. CI posts it on profile PRs.\n"
|
||||
"\n"
|
||||
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
|
||||
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
|
||||
"string value that spans several lines.")
|
||||
gcode_cmd.add_argument("--base", metavar="REF",
|
||||
help="compare against this git ref (default: the merge base "
|
||||
"with upstream/main, else origin/main)")
|
||||
gcode_cmd.add_argument("--head", metavar="REF",
|
||||
help="compare this git ref (default: the working tree)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -3667,9 +3423,6 @@ def main(argv=None):
|
||||
if args.command == "legacy-compat":
|
||||
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
|
||||
|
||||
if args.command == "gcode-diff":
|
||||
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
|
||||
|
||||
if args.command == "fix-variant":
|
||||
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
|
||||
strict=args.strict)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Copies the files SignPath signed back over the originals in the portable bundle they
|
||||
# were staged from with stage-for-signpath.ps1 -Bundle: the root of the signed artifact
|
||||
# holds OrcaSlicer's own binaries, third-party/ the other files under their paths in the
|
||||
# bundle. Root files named in -Exclude came from outside the bundle, such as the exported
|
||||
# uninstaller, and stay in -SignedDirectory.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
|
||||
[string]$SignedDirectory,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
|
||||
[string]$Bundle,
|
||||
|
||||
[string[]]$Exclude = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$signedRoot = (Resolve-Path -LiteralPath $SignedDirectory).Path.TrimEnd('\', '/')
|
||||
$thirdPartyPrefix = "third-party" + [IO.Path]::DirectorySeparatorChar
|
||||
$files = Get-ChildItem -LiteralPath $signedRoot -Recurse -File |
|
||||
Where-Object { $_.DirectoryName -ne $signedRoot -or $_.Name -notin $Exclude }
|
||||
if (-not $files) {
|
||||
throw "No signed files found in '$signedRoot'."
|
||||
}
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relative = $file.FullName.Substring($signedRoot.Length + 1)
|
||||
if ($relative.StartsWith($thirdPartyPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$relative = $relative.Substring($thirdPartyPrefix.Length)
|
||||
}
|
||||
|
||||
# Every signed file replaces one that was staged, so a file without an original means
|
||||
# the artifact does not have the layout stage-for-signpath.ps1 produces.
|
||||
$target = Join-Path $Bundle $relative
|
||||
if (-not (Test-Path -LiteralPath $target -PathType Leaf)) {
|
||||
throw "Signed file '$relative' has no original in '$Bundle'."
|
||||
}
|
||||
Copy-Item -LiteralPath $file.FullName -Destination $target -Force
|
||||
}
|
||||
|
||||
Write-Host "Restored $(@($files).Count) signed files into '$Bundle'."
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copies the files a SignPath signing request covers into the directory that request
|
||||
# uploads, after checking their version information. SignPath refuses to sign a file
|
||||
# whose ProductName or ProductVersion differs from its artifact configuration (see
|
||||
# signpath/), and reports it only once the request has been approved, so the check runs
|
||||
# before anything is submitted.
|
||||
#
|
||||
# -Path stages the given files at the root of -Destination.
|
||||
#
|
||||
# -Bundle stages a portable OrcaSlicer bundle for signpath/windows-binaries-v1.xml:
|
||||
# OrcaSlicer's own binaries (-OwnBinaries, plus any -Path files from outside the bundle,
|
||||
# such as the exported uninstaller) at the root, where their version information is
|
||||
# checked, and every other executable, DLL and Python extension module that carries no
|
||||
# signature under third-party/, at its path in the bundle. Files signed by their vendor,
|
||||
# such as the Microsoft runtime, keep that signature. restore-from-signpath.ps1 copies the
|
||||
# signed files back.
|
||||
[CmdletBinding(DefaultParameterSetName = "Path")]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Path")]
|
||||
[Parameter(ParameterSetName = "Bundle")]
|
||||
[string[]]$Path,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Bundle")]
|
||||
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
|
||||
[string]$Bundle,
|
||||
|
||||
[Parameter(ParameterSetName = "Bundle")]
|
||||
[string[]]$OwnBinaries = @("orca-slicer.exe", "OrcaSlicer.dll"),
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProductVersion,
|
||||
|
||||
[string]$ProductName = "OrcaSlicer"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-VersionInfo {
|
||||
param([string]$File)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $File -PathType Leaf)) {
|
||||
throw "File to sign was not found: $File"
|
||||
}
|
||||
|
||||
$info = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $File).Path)
|
||||
if ($info.ProductName -ne $ProductName -or $info.ProductVersion -ne $ProductVersion) {
|
||||
throw "'$File' has ProductName '$($info.ProductName)' and ProductVersion '$($info.ProductVersion)'; SignPath expects '$ProductName' and '$ProductVersion'."
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
||||
|
||||
$ownFiles = @($Path | Where-Object { $_ })
|
||||
if ($PSCmdlet.ParameterSetName -eq "Bundle") {
|
||||
$bundleRoot = (Resolve-Path -LiteralPath $Bundle).Path.TrimEnd('\', '/')
|
||||
$ownFiles = @($OwnBinaries | ForEach-Object { Join-Path $bundleRoot $_ }) + $ownFiles
|
||||
}
|
||||
|
||||
foreach ($file in $ownFiles) {
|
||||
Test-VersionInfo $file
|
||||
Copy-Item -LiteralPath $file -Destination $Destination -Force
|
||||
Write-Host "Staged $(Split-Path -Leaf $file) ($ProductName $ProductVersion)"
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ParameterSetName -eq "Path") {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$thirdParty = Get-ChildItem -LiteralPath $bundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in ".exe", ".dll", ".pyd" } |
|
||||
Where-Object { $_.DirectoryName -ne $bundleRoot -or $_.Name -notin $OwnBinaries } |
|
||||
Where-Object { (Get-AuthenticodeSignature -LiteralPath $_.FullName).Status -eq "NotSigned" }
|
||||
|
||||
foreach ($file in $thirdParty) {
|
||||
$relative = $file.FullName.Substring($bundleRoot.Length + 1)
|
||||
$target = Join-Path (Join-Path $Destination "third-party") $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
|
||||
Copy-Item -LiteralPath $file.FullName -Destination $target -Force
|
||||
}
|
||||
Write-Host "Staged $(@($thirdParty).Count) unsigned third-party files under third-party/"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
||||
normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
|
||||
subcommand dispatch that reaches them (stdlib unittest, no external deps).
|
||||
normalize, trim, update-index, fix-variant and check commands, and the subcommand
|
||||
dispatch that reaches them (stdlib unittest, no external deps).
|
||||
|
||||
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
||||
|
||||
@@ -14,12 +14,9 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
@@ -44,24 +41,7 @@ class Tree:
|
||||
os.makedirs(self.profiles)
|
||||
|
||||
def cleanup(self):
|
||||
root = os.path.abspath(self.dir)
|
||||
|
||||
def writable_retry(func, path, _exc):
|
||||
# Windows refuses to delete read-only files such as git's objects, and POSIX
|
||||
# refuses to delete the entries of a read-only directory.
|
||||
if not os.path.lexists(path): # already removed, as on a second cleanup
|
||||
return
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
try:
|
||||
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
|
||||
os.chmod(parent, stat.S_IRWXU)
|
||||
if not os.path.islink(path): # chmod would follow it out of the tree
|
||||
os.chmod(path, stat.S_IRWXU)
|
||||
func(path)
|
||||
except (OSError, ValueError): # commonpath raises ValueError across drives
|
||||
pass
|
||||
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
|
||||
shutil.rmtree(self.dir, **{hook: writable_retry})
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def index_path(self, vendor):
|
||||
return os.path.join(self.profiles, vendor + ".json")
|
||||
@@ -1429,292 +1409,6 @@ class TestDispatch(TreeCase):
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gcode-diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(shutil.which("git"), "git not installed")
|
||||
class TestGcodeDiff(TreeCase):
|
||||
START = "G28\nM104 S200\nG1 Z5"
|
||||
|
||||
def git(self, *args):
|
||||
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
|
||||
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
|
||||
|
||||
def printer(self, name, **extra):
|
||||
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
|
||||
|
||||
def commit(self):
|
||||
self.git("add", "-A")
|
||||
self.git("commit", "-q", "--allow-empty", "-m", "x")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# git's list of the variables that tie a process to a repository, asked with no
|
||||
# GIT_* set since a stray GIT_DIR fails the query.
|
||||
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
cls.repo_env_vars = set(subprocess.run(
|
||||
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
|
||||
capture_output=True, text=True).stdout.split())
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
|
||||
# tool's included, at the real repository instead of the temp tree.
|
||||
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
|
||||
patcher = mock.patch.dict(os.environ, env, clear=True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.git("init", "-q")
|
||||
for name in ("A", "B", "C"):
|
||||
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
||||
self.printer("Gone", machine_end_gcode="M84")
|
||||
self.commit()
|
||||
|
||||
def diff(self):
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
return out
|
||||
|
||||
def test_identical_edits_share_one_section(self):
|
||||
for name in ("A", "B"):
|
||||
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
|
||||
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
|
||||
out = self.diff()
|
||||
self.assertEqual(out.count("<details>"), 2)
|
||||
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
|
||||
"<code>machine_start_gcode</code>", out)
|
||||
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
|
||||
# Trailing spaces are removed.
|
||||
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
|
||||
self.assertIn("\n\n```diff\n@@ ", out)
|
||||
self.assertIn("\n```\n\n</details>", out)
|
||||
|
||||
def test_nothing_is_printed_without_a_text_change(self):
|
||||
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
|
||||
self.assertEqual(self.diff(), "")
|
||||
|
||||
def test_added_and_deleted_files_show_the_whole_value(self):
|
||||
# An untracked file counts as added; the validator's user dir is not a profile.
|
||||
self.printer("New", machine_start_gcode="G28\nG29")
|
||||
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
|
||||
os.remove(self.t.path("V", "machine/Gone.json"))
|
||||
out = self.diff()
|
||||
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
|
||||
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
||||
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
||||
self.assertNotIn("U.json", out)
|
||||
|
||||
def test_a_multi_line_value_counts_whatever_its_key(self):
|
||||
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
|
||||
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
|
||||
|
||||
def test_a_file_name_cannot_mention_a_user(self):
|
||||
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
|
||||
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
|
||||
|
||||
def test_head_compares_two_commits(self):
|
||||
self.printer("A", machine_start_gcode="G28")
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
||||
|
||||
def test_a_renamed_file_diffs_against_its_old_self(self):
|
||||
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
|
||||
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
|
||||
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
|
||||
|
||||
def test_a_file_renamed_off_json_loses_its_values(self):
|
||||
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
||||
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
||||
|
||||
def test_a_file_renamed_onto_json_is_new(self):
|
||||
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
|
||||
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
|
||||
self.commit()
|
||||
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
|
||||
|
||||
def test_a_batch_read_survives_paths_that_are_not_files(self):
|
||||
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
|
||||
("HEAD", "V/machine/Gone.json")])
|
||||
self.assertIsNone(blobs[("HEAD", "V/machine")])
|
||||
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
|
||||
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
|
||||
"M84")
|
||||
|
||||
def test_each_value_of_a_vector_option_is_its_own_setting(self):
|
||||
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
|
||||
self.commit()
|
||||
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
|
||||
out = self.diff()
|
||||
self.assertEqual(out.count("<details>"), 1)
|
||||
self.assertIn("<code>filament_start_gcode[1]</code>", out)
|
||||
|
||||
def test_vendor_limits_the_files(self):
|
||||
self.printer("A", machine_start_gcode="G28")
|
||||
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("V/machine/A.json", out)
|
||||
self.assertNotIn("W/machine/X.json", out)
|
||||
|
||||
def test_vendor_includes_files_moved_out_of_it(self):
|
||||
self.t.add_vendor("W")
|
||||
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
|
||||
data = self.t.read("W", "machine/A.json")
|
||||
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
|
||||
self.commit()
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
|
||||
"--vendor", "V")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
||||
|
||||
def same_edit(self, names):
|
||||
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
|
||||
for name in names:
|
||||
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
||||
self.commit()
|
||||
for name in names:
|
||||
self.printer(name, machine_start_gcode="G28")
|
||||
out = self.diff()
|
||||
return re.search(r"<summary>(.*)</summary>", out).group(1), out
|
||||
|
||||
def test_a_long_file_list_moves_into_the_section(self):
|
||||
summary, out = self.same_edit("ABCDE")
|
||||
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
|
||||
self.assertNotIn("D.json", summary)
|
||||
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
|
||||
out)
|
||||
|
||||
def test_four_files_are_all_named_in_the_summary(self):
|
||||
summary, _out = self.same_edit("ABCD")
|
||||
self.assertIn("<code>V/machine/D.json</code> - ", summary)
|
||||
self.assertNotIn("more", summary)
|
||||
|
||||
def test_an_error_goes_to_stderr_only(self):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(out, "")
|
||||
self.assertIn("no-such-ref", err.getvalue())
|
||||
|
||||
def test_a_file_name_that_is_not_utf8_is_an_error(self):
|
||||
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
|
||||
contextlib.redirect_stderr(err):
|
||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
||||
self.assertEqual((rc, out), (1, ""))
|
||||
self.assertIn("invalid continuation byte", err.getvalue())
|
||||
|
||||
def test_output_is_cut_to_whole_sections(self):
|
||||
for name in ("A", "B", "C"):
|
||||
self.printer(name, machine_start_gcode=name * 300)
|
||||
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
|
||||
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
|
||||
out = self.diff()
|
||||
self.assertEqual(out.count("<details>"), 2)
|
||||
self.assertEqual(out.count("</details>"), 2)
|
||||
self.assertIn("\n1 of 3 sections is not shown", out)
|
||||
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
|
||||
self.assertIn("\n3 of 3 sections are not shown", self.diff())
|
||||
|
||||
def test_cleanup_removes_the_repo(self):
|
||||
# setUp's commit leaves read-only git objects, and a read-only directory blocks
|
||||
# deletion on POSIX.
|
||||
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
|
||||
self.t.cleanup()
|
||||
self.assertFalse(os.path.exists(self.t.dir))
|
||||
with mock.patch.object(os, "chmod") as chmod:
|
||||
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
|
||||
chmod.assert_not_called()
|
||||
|
||||
def test_only_repository_variables_are_stripped(self):
|
||||
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
|
||||
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
|
||||
|
||||
def test_cleanup_leaves_symlink_targets_alone(self):
|
||||
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
|
||||
self.addCleanup(shutil.rmtree, outside, True)
|
||||
target = os.path.join(outside, "f")
|
||||
open(target, "w").close()
|
||||
os.chmod(target, stat.S_IRUSR)
|
||||
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
|
||||
machine = self.t.path("V", "machine")
|
||||
try:
|
||||
os.symlink(target, os.path.join(machine, "link"))
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("cannot create symlinks here")
|
||||
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
|
||||
self.t.cleanup()
|
||||
self.assertFalse(os.path.exists(self.t.dir))
|
||||
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
|
||||
|
||||
def test_a_git_hook_environment_is_ignored(self):
|
||||
scripts = os.path.join(REPO_ROOT, "scripts")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "unittest",
|
||||
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
|
||||
cwd=scripts, capture_output=True, text=True,
|
||||
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_the_options_are_read_from_the_engine(self):
|
||||
keys = apt._text_option_keys()
|
||||
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
|
||||
"notes", "post_process"} <= keys)
|
||||
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
|
||||
|
||||
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
|
||||
|
||||
def keys_from(self, print_config, tab=None):
|
||||
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
|
||||
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
|
||||
for path, text in zip(paths, (print_config, tab)):
|
||||
if text is not None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
apt._text_option_keys.cache_clear()
|
||||
self.addCleanup(apt._text_option_keys.cache_clear)
|
||||
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
|
||||
mock.patch.object(apt, "TAB_CPP", paths[1]):
|
||||
return apt._text_option_keys()
|
||||
|
||||
def test_multiline_is_read_whatever_its_spacing(self):
|
||||
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
|
||||
' def->multiline = true;\n')
|
||||
self.assertIn("printer_notes", keys)
|
||||
|
||||
def test_the_code_options_of_tab_cpp_are_read(self):
|
||||
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
|
||||
'option.opt.is_code = true;\n'
|
||||
'option = optgroup->get_option("layer_height");\n')
|
||||
self.assertIn("filename_format", keys)
|
||||
self.assertNotIn("layer_height", keys)
|
||||
|
||||
def test_a_missing_tab_cpp_is_not_an_error(self):
|
||||
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
|
||||
|
||||
def test_markdown_in_a_file_name_stays_literal(self):
|
||||
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
|
||||
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
|
||||
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
|
||||
[string]$ArtifactDirectory,
|
||||
|
||||
# Paths relative to -ArtifactDirectory. Without them every executable, DLL and Python
|
||||
# extension module under it is verified.
|
||||
[string[]]$Files,
|
||||
|
||||
[string]$SignToolPath,
|
||||
|
||||
# Accept signatures whose certificate chain terminates in an untrusted root.
|
||||
# Required for the SignPath test certificate (self-signed). Do NOT pass this
|
||||
# with the release certificate, so release builds enforce a fully trusted chain.
|
||||
[switch]$AllowUntrustedRoot
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-SignToolPath {
|
||||
param(
|
||||
[string]$ExplicitPath
|
||||
)
|
||||
|
||||
if ($ExplicitPath) {
|
||||
if (Test-Path -LiteralPath $ExplicitPath -PathType Leaf) {
|
||||
return (Resolve-Path -LiteralPath $ExplicitPath).Path
|
||||
}
|
||||
|
||||
throw "SignTool was not found at '$ExplicitPath'."
|
||||
}
|
||||
|
||||
$fromPath = Get-Command -Name "signtool.exe" -ErrorAction SilentlyContinue
|
||||
if ($fromPath) {
|
||||
return $fromPath.Source
|
||||
}
|
||||
|
||||
$candidateRoots = @(
|
||||
"${env:ProgramFiles(x86)}\Windows Kits\10\bin",
|
||||
"${env:ProgramFiles}\Windows Kits\10\bin"
|
||||
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) }
|
||||
|
||||
foreach ($root in $candidateRoots) {
|
||||
$candidate = Get-ChildItem -LiteralPath $root -Recurse -Filter "signtool.exe" -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match "\\(x64|arm64)\\signtool\.exe$" } |
|
||||
Sort-Object -Property FullName -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($candidate) {
|
||||
return $candidate.FullName
|
||||
}
|
||||
}
|
||||
|
||||
throw "signtool.exe was not found. Install the Windows SDK or pass -SignToolPath."
|
||||
}
|
||||
|
||||
$artifactRoot = (Resolve-Path -LiteralPath $ArtifactDirectory).Path.TrimEnd('\', '/')
|
||||
$signtool = Resolve-SignToolPath -ExplicitPath $SignToolPath
|
||||
|
||||
if (-not $Files) {
|
||||
$Files = Get-ChildItem -LiteralPath $artifactRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in ".exe", ".dll", ".pyd" } |
|
||||
ForEach-Object { $_.FullName.Substring($artifactRoot.Length + 1) }
|
||||
if (-not $Files) {
|
||||
throw "No executables or DLLs found in '$artifactRoot'."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Using SignTool: $signtool"
|
||||
Write-Host "Verifying Authenticode signatures of $(@($Files).Count) files in: $artifactRoot"
|
||||
|
||||
foreach ($relativePath in $Files) {
|
||||
$filePath = Join-Path $artifactRoot $relativePath
|
||||
if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) {
|
||||
throw "Expected signed file was not found: $filePath"
|
||||
}
|
||||
|
||||
# Capture signtool output without letting native stderr (redirected via 2>&1)
|
||||
# raise a terminating NativeCommandError under $ErrorActionPreference = 'Stop'.
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $signtool verify /pa /all /tw /v $filePath 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $filePath
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Verified $relativePath ($($signature.SignerCertificate.Subject))"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($AllowUntrustedRoot) {
|
||||
# signtool interleaves its stdout (chain details) and stderr (the error)
|
||||
# unpredictably, so the error text cannot be matched reliably. Use the
|
||||
# structured Get-AuthenticodeSignature result instead: accept only when the
|
||||
# file is genuinely signed and the sole problem is that the chain terminates
|
||||
# in an untrusted root (i.e. the self-signed SignPath test certificate).
|
||||
$isSigned = ($signature.SignatureType -eq "Authenticode") -and ($null -ne $signature.SignerCertificate)
|
||||
$untrustedRootOnly = $signature.StatusMessage -match "terminated in a root certificate which is not trusted"
|
||||
if ($isSigned -and $untrustedRootOnly) {
|
||||
Write-Host "Accepted $relativePath ($($signature.SignerCertificate.Subject)): signed, but its certificate chains to an untrusted root (expected for the SignPath test certificate)."
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
# The full chain and error details only for a file that did not verify.
|
||||
$output | ForEach-Object { Write-Host " $_" }
|
||||
throw "SignTool verification failed for '$relativePath' with exit code $exitCode."
|
||||
}
|
||||
|
||||
Write-Host "Authenticode verification passed."
|
||||
|
||||
# signtool exits non-zero for the untrusted test-cert root even when we accept
|
||||
# it above, leaving $LASTEXITCODE = 1. The GitHub Actions pwsh wrapper exits
|
||||
# with that lingering code, so reset it to report success explicitly.
|
||||
exit 0
|
||||
@@ -0,0 +1,23 @@
|
||||
# SignPath configurations
|
||||
|
||||
Windows builds of `release/*` branches in `OrcaSlicer/OrcaSlicer` are code signed through [SignPath](https://about.signpath.io/), with the certificate the SignPath Foundation issues to open source projects. The XML files here are copies of the project's artifact configurations in SignPath, which the workflow refers to by slug only; a change to one has to be made in SignPath too, under the same slug.
|
||||
|
||||
Every signing request uses the `release-signing` policy and waits in SignPath until it is approved, so each Windows build of a release branch needs two approvals per architecture: the binaries, then the installer. Builds of `main` and pull requests are not signed.
|
||||
|
||||
## What gets signed
|
||||
|
||||
Every executable, DLL and Python extension module in the bundle ends up with a valid signature, so Windows features that check each loaded binary, such as Smart App Control, find none unsigned. Files their vendor already signed keep that signature: the Microsoft runtime DLLs and `WebView2Loader.dll` (Microsoft) and CPython's bundled OpenSSL and libffi (Python Software Foundation). Everything else is signed with the project's certificate: OrcaSlicer's own `orca-slicer.exe` and `OrcaSlicer.dll`, the libraries built from upstream sources (OCCT, FFmpeg, FreeType, GMP, MPFR, CPython), the downloaded `uv.exe`, and the NSIS installer with the `Uninstall.exe` it installs.
|
||||
|
||||
The SignPath Foundation's conditions ask projects to sign only binaries built from their own source and to leave upstream projects' binaries unsigned, which they allow inside a signed installer. Signing the upstream libraries departs from that, by the maintainers' choice. Narrowing the signed set back to OrcaSlicer's own files means dropping the `third-party` file set from `windows-binaries-v1` and the third-party staging from `scripts/stage-for-signpath.ps1`.
|
||||
|
||||
The Foundation also requires metadata restrictions on signed files. OrcaSlicer's own binaries, the installer and the uninstaller must have the ProductName `OrcaSlicer` and the ProductVersion passed in with the request, which the workflow takes from `SoftFever_VERSION` in `version.inc`. The binaries get that version information from `src/dev-utils/platform/msw/OrcaSlicer-version.rc.in`, the installer and its uninstaller from `CPACK_NSIS_DEFINES` in `CMakeLists.txt`. The upstream files carry their own projects' metadata, so their file set has no restriction. `scripts/stage-for-signpath.ps1` checks the version information before a request is submitted, since SignPath only rejects a mismatch after the request has been approved.
|
||||
|
||||
## `windows-binaries-v1`
|
||||
|
||||
Signs the binaries of the installed bundle, `${{ env.BUILD_DIR }}/OrcaSlicer`, and the installer's uninstaller. `scripts/stage-for-signpath.ps1 -Bundle` stages them for upload: `orca-slicer.exe`, `OrcaSlicer.dll` and `Uninstall.exe` at the root of the artifact ZIP, where the metadata restrictions apply, and every other unsigned `.exe`, `.dll` and `.pyd` of the bundle under `third-party/`, at its path in the bundle. `scripts/restore-from-signpath.ps1` copies the signed files back over the originals. The portable ZIP, the MSIX package and the installer are all packed from that bundle; `cpack` is pointed at it with `CPACK_INSTALLED_DIRECTORIES` rather than re-running the install rules, which would take CPython and `uv.exe` from their unsigned sources again.
|
||||
|
||||
makensis generates the uninstaller while it compiles the installer, so it cannot be signed after the fact like the other files. Before the binaries are submitted, the workflow builds the installer once to export its uninstaller, and the final `cpack` embeds the signed copy in its place, through the hooks in `src/dev-utils/platform/msw/uninstaller-signing.nsh`. Both builds pack the same files with the same script, so the uninstaller is the same one the final installer would have generated. After the final `cpack`, the workflow installs the installer silently into a scratch directory on the runner and verifies that every binary the installation puts on disk, the uninstaller included, carries a valid signature.
|
||||
|
||||
## `windows-installer-v1`
|
||||
|
||||
Signs the NSIS installer `cpack` produces, `OrcaSlicer_Windows_Installer_<version>_<arch>.exe`, uploaded as the only file of its artifact.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<parameters>
|
||||
<parameter name="version" required="true" />
|
||||
</parameters>
|
||||
<zip-file>
|
||||
<pe-file-set product-name="OrcaSlicer" product-version="${version}">
|
||||
<include path="orca-slicer.exe" />
|
||||
<include path="OrcaSlicer.dll" />
|
||||
<include path="Uninstall.exe" />
|
||||
<for-each>
|
||||
<authenticode-sign />
|
||||
</for-each>
|
||||
</pe-file-set>
|
||||
<directory path="third-party">
|
||||
<pe-file-set>
|
||||
<include path="**/*.exe" min-matches="0" max-matches="unbounded" />
|
||||
<include path="**/*.dll" min-matches="0" max-matches="unbounded" />
|
||||
<include path="**/*.pyd" min-matches="0" max-matches="unbounded" />
|
||||
<for-each>
|
||||
<authenticode-sign />
|
||||
</for-each>
|
||||
</pe-file-set>
|
||||
</directory>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<parameters>
|
||||
<parameter name="version" required="true" />
|
||||
</parameters>
|
||||
<zip-file>
|
||||
<pe-file path="OrcaSlicer_Windows_Installer_*.exe" product-name="OrcaSlicer" product-version="${version}">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
+6
-2
@@ -140,7 +140,11 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/dev-utils/platform/msw/OrcaSlicer.rc.
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/dev-utils/platform/msw/OrcaSlicer.manifest.in ${CMAKE_CURRENT_BINARY_DIR}/OrcaSlicer.manifest @ONLY)
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/dev-utils/platform/osx/Info.plist.in ${CMAKE_CURRENT_BINARY_DIR}/Info.plist @ONLY)
|
||||
if (WIN32)
|
||||
add_library(OrcaSlicer SHARED OrcaSlicer.cpp OrcaSlicer.hpp)
|
||||
foreach (_orca_rc_binary IN ITEMS orca-slicer.exe OrcaSlicer.dll)
|
||||
set(ORCA_RC_ORIGINAL_FILENAME ${_orca_rc_binary})
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/dev-utils/platform/msw/OrcaSlicer-version.rc.in ${CMAKE_CURRENT_BINARY_DIR}/${_orca_rc_binary}.version.rc @ONLY)
|
||||
endforeach ()
|
||||
add_library(OrcaSlicer SHARED OrcaSlicer.cpp OrcaSlicer.hpp ${CMAKE_CURRENT_BINARY_DIR}/OrcaSlicer.dll.version.rc)
|
||||
else ()
|
||||
add_executable(OrcaSlicer OrcaSlicer.cpp OrcaSlicer.hpp)
|
||||
endif ()
|
||||
@@ -216,7 +220,7 @@ if (WIN32)
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -municode")
|
||||
endif()
|
||||
|
||||
add_executable(OrcaSlicer_app_gui WIN32 OrcaSlicer_app_msvc.cpp ${CMAKE_CURRENT_BINARY_DIR}/OrcaSlicer.rc)
|
||||
add_executable(OrcaSlicer_app_gui WIN32 OrcaSlicer_app_msvc.cpp ${CMAKE_CURRENT_BINARY_DIR}/OrcaSlicer.rc ${CMAKE_CURRENT_BINARY_DIR}/orca-slicer.exe.version.rc)
|
||||
# Generate debug symbols even in release mode.
|
||||
if(MSVC)
|
||||
target_link_options(OrcaSlicer_app_gui PUBLIC "$<$<CONFIG:RELEASE>:/DEBUG>")
|
||||
|
||||
@@ -1126,19 +1126,6 @@ static void load_downward_settings_list_from_config(std::string config_file, std
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __WXGTK__
|
||||
// Virtual GPUs (VirtualBox, VMware, QEMU) cannot back WebKit's hardware DMABUF renderer.
|
||||
static bool is_hypervisor_guest()
|
||||
{
|
||||
std::ifstream cpuinfo("/proc/cpuinfo");
|
||||
std::string line;
|
||||
while (std::getline(cpuinfo, line))
|
||||
if (boost::starts_with(line, "flags"))
|
||||
return line.find(" hypervisor") != std::string::npos;
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
int CLI::run(int argc, char **argv)
|
||||
{
|
||||
// Mark the main thread for the debugger and for runtime checks.
|
||||
@@ -1189,9 +1176,6 @@ int CLI::run(int argc, char **argv)
|
||||
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
|
||||
}
|
||||
|
||||
if (is_hypervisor_guest() && WebView::SupportsForceSharedMemory())
|
||||
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
|
||||
|
||||
if (x11_opt_in) {
|
||||
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
|
||||
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Version information of orca-slicer.exe and OrcaSlicer.dll, configured once per binary.
|
||||
// SignPath only signs a binary whose ProductName and ProductVersion match its artifact
|
||||
// configuration (signpath/windows-binaries-v1.xml), which the workflow passes the
|
||||
// SoftFever_VERSION from version.inc.
|
||||
1 VERSIONINFO
|
||||
FILEVERSION @ORCA_VERSION_MAJOR@,@ORCA_VERSION_MINOR@,@ORCA_VERSION_PATCH@,0
|
||||
PRODUCTVERSION @ORCA_VERSION_MAJOR@,@ORCA_VERSION_MINOR@,@ORCA_VERSION_PATCH@,0
|
||||
{
|
||||
BLOCK "StringFileInfo"
|
||||
{
|
||||
BLOCK "040904E4"
|
||||
{
|
||||
VALUE "CompanyName", "SoftFever"
|
||||
VALUE "FileDescription", "@SLIC3R_APP_NAME@"
|
||||
VALUE "FileVersion", "@SoftFever_VERSION@"
|
||||
VALUE "ProductName", "@SLIC3R_APP_NAME@"
|
||||
VALUE "ProductVersion", "@SoftFever_VERSION@"
|
||||
VALUE "InternalName", "@SLIC3R_APP_NAME@"
|
||||
VALUE "OriginalFilename", "@ORCA_RC_ORIGINAL_FILENAME@"
|
||||
}
|
||||
}
|
||||
BLOCK "VarFileInfo"
|
||||
{
|
||||
VALUE "Translation", 0x409, 1252
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,2 @@
|
||||
1 VERSIONINFO
|
||||
FILEVERSION @SLIC3R_VERSION@
|
||||
PRODUCTVERSION @SLIC3R_VERSION@
|
||||
{
|
||||
BLOCK "StringFileInfo"
|
||||
{
|
||||
BLOCK "040904E4"
|
||||
{
|
||||
VALUE "CompanyName", "SoftFever"
|
||||
VALUE "FileDescription", "@SLIC3R_APP_NAME@"
|
||||
VALUE "FileVersion", "@SLIC3R_BUILD_ID@"
|
||||
VALUE "ProductName", "@SLIC3R_APP_NAME@"
|
||||
VALUE "ProductVersion", "@SLIC3R_BUILD_ID@"
|
||||
VALUE "InternalName", "@SLIC3R_APP_NAME@"
|
||||
VALUE "LegalCopyright", ""
|
||||
VALUE "OriginalFilename", "orca-slicer.exe"
|
||||
}
|
||||
}
|
||||
BLOCK "VarFileInfo"
|
||||
{
|
||||
VALUE "Translation", 0x409, 1252
|
||||
}
|
||||
}
|
||||
2 ICON "@SLIC3R_RESOURCES_DIR@/images/OrcaSlicer.ico"
|
||||
1 24 "OrcaSlicer.manifest"
|
||||
1 24 "OrcaSlicer.manifest"
|
||||
@@ -0,0 +1,19 @@
|
||||
; Lets the Windows release workflow ship a signed uninstaller (see signpath/README.md).
|
||||
; makensis generates the uninstaller while it compiles the installer, so it cannot be
|
||||
; signed afterwards like the installed binaries. Instead a first compile exports it
|
||||
; (/DORCA_EXPORT_UNINSTALLER=<path>), the export is signed together with the binaries, and
|
||||
; the final compile embeds the signed copy in place of the uninstaller it generates
|
||||
; (/DORCA_SIGNED_UNINSTALLER=<path>). Both compiles run the same script over the same
|
||||
; installed files, so they generate the same uninstaller. A failed copy aborts the
|
||||
; compile rather than leave the uninstaller unsigned.
|
||||
;
|
||||
; cpack passes the paths with forward slashes, which cmd's copy does not accept for its
|
||||
; source, so they are converted first.
|
||||
!ifdef ORCA_EXPORT_UNINSTALLER
|
||||
!searchreplace ORCA_EXPORT_UNINSTALLER_PATH "${ORCA_EXPORT_UNINSTALLER}" "/" "\"
|
||||
!uninstfinalize 'cmd /c copy /y "%1" "${ORCA_EXPORT_UNINSTALLER_PATH}"' = 0
|
||||
!endif
|
||||
!ifdef ORCA_SIGNED_UNINSTALLER
|
||||
!searchreplace ORCA_SIGNED_UNINSTALLER_PATH "${ORCA_SIGNED_UNINSTALLER}" "/" "\"
|
||||
!uninstfinalize 'cmd /c copy /y "${ORCA_SIGNED_UNINSTALLER_PATH}" "%1"' = 0
|
||||
!endif
|
||||
@@ -812,8 +812,7 @@ 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 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.
|
||||
* Odd walls should always go after their enclosing wall polygons.
|
||||
*
|
||||
* \param outer_to_inner Whether the wall polygons with a lower inset_idx should go before those with a higher one.
|
||||
*/
|
||||
@@ -887,7 +886,12 @@ 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 ((nearby->inset_idx < here->inset_idx) == outer_to_inner) {
|
||||
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) {
|
||||
order_requirements.emplace(std::make_pair(nearby, here));
|
||||
} else {
|
||||
assert((nearby->inset_idx > here->inset_idx) == outer_to_inner);
|
||||
|
||||
@@ -3985,16 +3985,6 @@ 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();
|
||||
|
||||
@@ -120,13 +120,7 @@ const std::vector<std::string> GCodeProcessor::Reserved_Tags = {
|
||||
"@PRINT_TIME_HOUR@",
|
||||
"@PRINT_TIME_MINUTE@",
|
||||
"@PRINT_TIME_SEC@",
|
||||
"@USED_FILAMENT_LENGTH@",
|
||||
"@NORMAL_PRINT_TIME@",
|
||||
"@SILENT_PRINT_TIME@",
|
||||
"@TOTAL_WEIGHT@",
|
||||
"@TOTAL_COST@",
|
||||
"@TOTAL_WIPE_TOWER_COST@",
|
||||
"@TOTAL_WIPE_TOWER_FILAMENT@"
|
||||
"@USED_FILAMENT_LENGTH@"
|
||||
};
|
||||
|
||||
const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
|
||||
@@ -153,13 +147,7 @@ const std::vector<std::string> GCodeProcessor::Reserved_Tags_compatible = {
|
||||
"@PRINT_TIME_HOUR@",
|
||||
"@PRINT_TIME_MINUTE@",
|
||||
"@PRINT_TIME_SEC@",
|
||||
"@USED_FILAMENT_LENGTH@",
|
||||
"@NORMAL_PRINT_TIME@",
|
||||
"@SILENT_PRINT_TIME@",
|
||||
"@TOTAL_WEIGHT@",
|
||||
"@TOTAL_COST@",
|
||||
"@TOTAL_WIPE_TOWER_COST@",
|
||||
"@TOTAL_WIPE_TOWER_FILAMENT@"
|
||||
"@USED_FILAMENT_LENGTH@"
|
||||
};
|
||||
|
||||
|
||||
@@ -1276,22 +1264,19 @@ void GCodeProcessor::run_post_process()
|
||||
return ret;
|
||||
};
|
||||
|
||||
// Process inline placeholders (print_time_*, used_filament_length and the print statistics known only after export)
|
||||
// Process inline placeholders (print_time_total_sec, print_time_day, print_time_hour, print_time_minute, print_time_sec and used_filament_length)
|
||||
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;
|
||||
|
||||
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());
|
||||
}
|
||||
};
|
||||
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);
|
||||
|
||||
double print_time_total_sec = m_time_processor.machines[static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Normal)].time;
|
||||
if (print_time_total_sec < 0.0)
|
||||
@@ -1304,31 +1289,70 @@ void GCodeProcessor::run_post_process()
|
||||
int print_time_minute = (day_remainder_seconds % 3600) / 60;
|
||||
int print_time_sec = day_remainder_seconds % 60;
|
||||
|
||||
double total_filament_mm = 0.0;
|
||||
for (const auto& mm : filament_mm) {
|
||||
total_filament_mm += mm;
|
||||
// Replace print_time_total_sec
|
||||
size_t pos = gcode_line.find(print_time_total_placeholder);
|
||||
while (pos != std::string::npos) {
|
||||
char buf[64];
|
||||
sprintf(buf, "%.2f", print_time_total_sec);
|
||||
gcode_line.replace(pos, print_time_total_placeholder.length(), buf);
|
||||
processed = true;
|
||||
pos = gcode_line.find(print_time_total_placeholder, pos + strlen(buf));
|
||||
}
|
||||
|
||||
replace_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));
|
||||
// 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));
|
||||
}
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
@@ -601,12 +601,6 @@ 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)]; }
|
||||
|
||||
@@ -62,12 +62,7 @@ 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;
|
||||
|
||||
@@ -577,8 +572,7 @@ 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 &&
|
||||
!perimeter_generator.outer_walls_first_for_brim())
|
||||
if (thin_wall_hole && perimeter_generator.config->wall_sequence != WallSequence::OuterInner)
|
||||
std::reverse(extrusion_coll.entities.begin(), extrusion_coll.entities.end());
|
||||
}
|
||||
else {
|
||||
@@ -1696,24 +1690,13 @@ 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, a
|
||||
// narrow wall hole none for an inner wall, the room the next loop is offset by below.
|
||||
// ORCA: a thin wall hole has no room for gap fill or an inner wall anywhere beside its outer wall.
|
||||
if (i == 0 && ! holes[0].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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
//BBS: refer to superslicer
|
||||
@@ -1852,16 +1835,18 @@ 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;
|
||||
// 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())
|
||||
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))
|
||||
entities.reverse();
|
||||
// Orca: sandwich mode. Apply after 1st layer.
|
||||
else if ((this->config->wall_sequence == WallSequence::InnerOuterInner) && layer_id > 0 && !narrow_wall_hole_island){
|
||||
else if ((this->config->wall_sequence == WallSequence::InnerOuterInner) && layer_id > 0){
|
||||
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.
|
||||
@@ -2427,25 +2412,11 @@ std::vector<PerimeterGeneratorArachneExtrusion> reorderPerimetersByProximity(std
|
||||
|
||||
// Function to reorder perimeters starting from a given reference index
|
||||
auto reorderFromReference = [&](int referenceIndex) {
|
||||
// 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; });
|
||||
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);
|
||||
// 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
|
||||
@@ -2456,10 +2427,8 @@ std::vector<PerimeterGeneratorArachneExtrusion> reorderPerimetersByProximity(std
|
||||
std::iter_swap(maxIt, firstLevelTouchingIndices.end() - 1);
|
||||
}
|
||||
// Insert first level perimeters into reordered list
|
||||
for (int idx : outerIndices) {
|
||||
reordered.push_back(entities[idx]);
|
||||
includedIndices.insert(idx);
|
||||
}
|
||||
reordered.push_back(entities[referenceIndex]);
|
||||
includedIndices.insert(referenceIndex);
|
||||
|
||||
for (int idx : firstLevelTouchingIndices) {
|
||||
if (includedIndices.count(idx) == 0) {
|
||||
@@ -2788,7 +2757,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->outer_walls_first_for_brim();
|
||||
this->config->wall_sequence == WallSequence::OuterInner;
|
||||
}
|
||||
if (is_outer_wall_first) {
|
||||
start_perimeter = 0;
|
||||
@@ -3073,16 +3042,6 @@ 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);
|
||||
|
||||
@@ -164,8 +164,6 @@ 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);
|
||||
@@ -185,10 +183,6 @@ 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
|
||||
@@ -2293,7 +2293,6 @@ 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);
|
||||
|
||||
@@ -756,14 +756,7 @@ public:
|
||||
Preset& get_edited_preset() { return m_edited_preset; }
|
||||
const Preset& get_edited_preset() const { return m_edited_preset; }
|
||||
|
||||
// The root of the selected preset's inherits chain, or the selected preset itself when
|
||||
// the chain names a preset that is not loaded (e.g. a vendor that is not installed).
|
||||
const Preset& get_selected_preset_base() const
|
||||
{
|
||||
const Preset &selected = m_presets[m_idx_selected];
|
||||
const Preset *base = get_preset_base(selected);
|
||||
return base ? *base : selected;
|
||||
}
|
||||
const Preset& get_selected_preset_base() const { return *get_preset_base(m_presets[m_idx_selected]); }
|
||||
|
||||
// Return the last saved preset.
|
||||
// const Preset& get_saved_preset() const { return m_saved_preset; }
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include "I18N.hpp"
|
||||
#include "Layer.hpp"
|
||||
#include "MutablePolygon.hpp"
|
||||
#include "PerimeterGenerator.hpp"
|
||||
#include "PrintBase.hpp"
|
||||
#include "PrintConfig.hpp"
|
||||
#include "SLA/IndexedMesh.hpp"
|
||||
@@ -1465,13 +1464,15 @@ 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" || 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))
|
||||
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)
|
||||
steps.emplace_back(posPerimeters);
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -707,10 +707,8 @@ void tree_supports_generate_paths(
|
||||
false);
|
||||
}
|
||||
if (eec) {
|
||||
if (!eec->empty()) {
|
||||
std::reverse(eec->entities.begin(), eec->entities.end());
|
||||
dst.emplace_back(eec.release());
|
||||
}
|
||||
std::reverse(eec->entities.begin(), eec->entities.end());
|
||||
dst.emplace_back(eec.release());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1827,7 +1825,12 @@ 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)) {
|
||||
tree_supports_generate_paths(base_layer.extrusions, base_layer.polygons_to_extrude(), flow, support_params);
|
||||
// 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);
|
||||
done = true;
|
||||
}
|
||||
if (! done)
|
||||
|
||||
@@ -2149,9 +2149,7 @@ 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:
|
||||
@@ -2203,7 +2201,7 @@ void TreeSupport::draw_circles()
|
||||
size_t first_base_roof_area = 0;
|
||||
bool floor_interface_as_base = false;
|
||||
bool has_circle_node = false;
|
||||
ExPolygons extra_wall_areas;
|
||||
bool need_extra_wall = false;
|
||||
ExPolygons collision_sharp_tails;
|
||||
ExPolygons collision_base;
|
||||
auto get_collision = [&](bool sharp_tail) -> ExPolygons &{
|
||||
@@ -2281,6 +2279,7 @@ 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.
|
||||
@@ -2295,10 +2294,6 @@ 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 &&
|
||||
@@ -2535,8 +2530,7 @@ 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 = !area_groups.back().need_infill &&
|
||||
!extra_wall_areas.empty() && overlaps({expoly}, extra_wall_areas);
|
||||
area_groups.back().need_extra_wall = need_extra_wall && !area_groups.back().need_infill;
|
||||
}
|
||||
for (size_t roof_idx = 0; roof_idx < ts_layer->roof_areas.size(); ++roof_idx) {
|
||||
auto &expoly = ts_layer->roof_areas[roof_idx];
|
||||
@@ -3439,6 +3433,11 @@ 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;
|
||||
@@ -3448,17 +3447,20 @@ 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;
|
||||
@@ -3477,6 +3479,9 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -3484,6 +3489,13 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
|
||||
m_printer_name_to_preset.clear();
|
||||
for (const Preset &printer_preset : printer_presets) {
|
||||
if (!printer_preset.is_visible) continue;
|
||||
if (preset_bundle->printers.get_preset_base(printer_preset) != &printer_preset) continue;
|
||||
if (preset_bundle->printers.get_preset_base(printer_preset)->name != printer_preset.name) continue;
|
||||
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
|
||||
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
|
||||
printer_choice.push_back(from_u8(printer_model->value));
|
||||
@@ -4962,7 +4962,7 @@ void CreatePresetForPrinterDialog::get_visible_printer_and_compatible_filament_p
|
||||
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
|
||||
ConfigOptionStrings *filament_types;
|
||||
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
|
||||
if (filament_preset_base == &filament_preset || filament_preset_base == nullptr) {
|
||||
if (filament_preset_base == &filament_preset) {
|
||||
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
|
||||
} else {
|
||||
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
#include "Search.hpp"
|
||||
#include "BitmapCache.hpp"
|
||||
#include "GUI_App.hpp"
|
||||
#include "KeyChord.hpp"
|
||||
|
||||
#include <nanosvg/nanosvg.h>
|
||||
#include <nanosvg/nanosvgrast.h>
|
||||
@@ -2135,27 +2134,6 @@ bool ImGuiWrapper::want_any_input() const
|
||||
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::text_input_active() const
|
||||
{
|
||||
const ImGuiID id = ImGui::GetActiveID();
|
||||
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::modal_popup_open() const
|
||||
{
|
||||
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
|
||||
// frame drew it, which NewFrame() moves from Active to WasActive.
|
||||
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
|
||||
if (modal == nullptr)
|
||||
return false;
|
||||
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
|
||||
{
|
||||
return modal_popup_open() || (chord.edits_text() && text_input_active());
|
||||
}
|
||||
|
||||
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
|
||||
{
|
||||
auto item = m_custom_glyph_rects_ids.find(tex_id);
|
||||
|
||||
@@ -35,7 +35,6 @@ struct ImRect;
|
||||
namespace Slic3r {
|
||||
namespace GUI {
|
||||
|
||||
struct KeyChord;
|
||||
|
||||
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
|
||||
|
||||
@@ -220,12 +219,6 @@ public:
|
||||
bool want_keyboard() const;
|
||||
bool want_text_input() const;
|
||||
bool want_any_input() const;
|
||||
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
|
||||
bool text_input_active() const;
|
||||
// True while an ImGui modal popup is on screen.
|
||||
bool modal_popup_open() const;
|
||||
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
|
||||
bool owns_key(const KeyChord& chord) const;
|
||||
|
||||
// Optional inputs are used for set up value inside of an optional, with default value
|
||||
//
|
||||
|
||||
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
|
||||
using Section = ShortcutSection;
|
||||
|
||||
if (wxGetApp().is_editor()) {
|
||||
page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
|
||||
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, {
|
||||
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
|
||||
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
|
||||
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
|
||||
@@ -438,16 +438,17 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
|
||||
SetBackgroundColour(*wxWHITE);
|
||||
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
// A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
|
||||
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use.
|
||||
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
|
||||
const wxString advice = global_key_advice();
|
||||
const wxString typing = _L("Global shortcuts also apply while typing.");
|
||||
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
|
||||
m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
|
||||
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms.");
|
||||
m_rejection = rule + "\n" + advice;
|
||||
|
||||
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
|
||||
int width = FromDIP(450);
|
||||
for (const wxString& sentence : { rule, advice }) {
|
||||
for (const wxString& sentence : { typing, rule, advice }) {
|
||||
int extent = 0;
|
||||
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
|
||||
width = std::max(width, extent);
|
||||
|
||||
@@ -187,40 +187,6 @@ bool KeyChord::is_menu_accelerator() const
|
||||
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
|
||||
}
|
||||
|
||||
bool KeyChord::edits_text() const
|
||||
{
|
||||
if (!valid())
|
||||
return false;
|
||||
if (!is_menu_accelerator())
|
||||
return true;
|
||||
const int mods = modifiers & ~wxMOD_SHIFT;
|
||||
switch (key) {
|
||||
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
|
||||
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
|
||||
#ifdef __APPLE__
|
||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
|
||||
#else
|
||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
||||
#endif
|
||||
case WXK_INSERT:
|
||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
||||
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
|
||||
return mods == wxMOD_NONE;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (!is_printable(key))
|
||||
return false;
|
||||
#ifdef __APPLE__
|
||||
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
|
||||
return true; // Option types a character, Control runs the Cocoa text key bindings
|
||||
#else
|
||||
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
|
||||
return true; // AltGr types a character
|
||||
#endif
|
||||
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
|
||||
}
|
||||
|
||||
// Only a chord the desktop acts on while still delivering it to the app belongs here.
|
||||
bool KeyChord::is_system_shortcut() const
|
||||
{
|
||||
|
||||
@@ -35,9 +35,6 @@ struct KeyChord
|
||||
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
|
||||
// swallowing typing in text fields.
|
||||
bool is_menu_accelerator() const;
|
||||
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
|
||||
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
|
||||
bool edits_text() const;
|
||||
// True for a chord the desktop acts on although the app receives it, so a binding would
|
||||
// take it from the system.
|
||||
bool is_system_shortcut() const;
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
#include <wx/panel.h>
|
||||
#include <wx/settings.h>
|
||||
#include <wx/textentry.h>
|
||||
#include <wx/spinctrl.h>
|
||||
#include <wx/notebook.h>
|
||||
#include <wx/listbook.h>
|
||||
#include <wx/simplebook.h>
|
||||
@@ -108,8 +107,6 @@
|
||||
#include "../Utils/PrintHost.hpp"
|
||||
|
||||
#include "GUI_App.hpp"
|
||||
#include "ImGuiWrapper.hpp"
|
||||
#include "OpenGLManager.hpp"
|
||||
#include "Shortcuts.hpp"
|
||||
#include "UnsavedChangesDialog.hpp"
|
||||
#include "PublishSettingsDialog.hpp"
|
||||
@@ -187,31 +184,18 @@ enum class ERescaleTarget
|
||||
|
||||
namespace {
|
||||
|
||||
bool focus_is_text_field(wxWindow* focus)
|
||||
{
|
||||
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
|
||||
return true; // a web page can have its own text fields
|
||||
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
|
||||
return true; // the native Windows spin control is not a wxTextEntry
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
|
||||
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
|
||||
{
|
||||
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
|
||||
return nullptr;
|
||||
return wxGetApp().imgui();
|
||||
}
|
||||
|
||||
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
|
||||
// controls, so only take Space when the focused window has no keyboard-activation meaning of its
|
||||
// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
|
||||
// itself does too, so Space still opens the dial on any page.
|
||||
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
|
||||
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
|
||||
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
|
||||
bool focus_keeps_space(wxWindow* focus)
|
||||
{
|
||||
if (!focus)
|
||||
return false;
|
||||
if (dynamic_cast<wxTextEntryBase*>(focus))
|
||||
return true; // typing a space into a text field
|
||||
if (dynamic_cast<wxWebView*>(focus))
|
||||
return true; // web content scrolls and hosts its own text fields
|
||||
if (dynamic_cast<::Button*>(focus))
|
||||
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
|
||||
if (dynamic_cast<StaticBox*>(focus))
|
||||
@@ -832,12 +816,16 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
|
||||
|
||||
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
|
||||
{
|
||||
// CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
|
||||
// the keys ImGui owns to the focused canvas.
|
||||
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so
|
||||
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame:
|
||||
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
|
||||
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
|
||||
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
|
||||
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
|
||||
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
|
||||
wxWindow* const focus = wxWindow::FindFocus();
|
||||
if (chord.edits_text() && focus_is_text_field(focus))
|
||||
return false;
|
||||
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
|
||||
ImGuiWrapper* const imgui = wxGetApp().imgui();
|
||||
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr)
|
||||
return false;
|
||||
|
||||
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
|
||||
|
||||
@@ -427,8 +427,6 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
|
||||
return m_context;
|
||||
}
|
||||
|
||||
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
|
||||
|
||||
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
||||
{
|
||||
int antialiasing_samples = 4;
|
||||
@@ -472,17 +470,12 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
||||
attribList[samples_value_idx] = 0;
|
||||
}
|
||||
|
||||
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
|
||||
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS);
|
||||
// The GL canvas paints its entire surface, so background erasing is unnecessary.
|
||||
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
|
||||
{
|
||||
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
|
||||
}
|
||||
|
||||
void OpenGLManager::detect_multisample(int* attribList)
|
||||
{
|
||||
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
|
||||
|
||||
@@ -101,8 +101,6 @@ public:
|
||||
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
|
||||
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
|
||||
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
|
||||
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
|
||||
static bool is_scene_canvas(const wxWindow* window);
|
||||
static const GLInfo& get_gl_info() { return s_gl_info; }
|
||||
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
|
||||
|
||||
|
||||
@@ -21178,12 +21178,7 @@ void Plater::print_job_finished(wxCommandEvent &evt)
|
||||
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
|
||||
if (!dev) return;
|
||||
|
||||
const std::string dev_id = evt.GetString().ToStdString();
|
||||
MachineObject* selected = dev->get_selected_machine();
|
||||
// After a LAN print, the completion event reselects the same printer.
|
||||
// Keep the active session while asynchronous status traffic may still be in flight.
|
||||
if (!selected || selected->get_dev_id() != dev_id || selected->connection_type() != "lan")
|
||||
dev->set_selected_machine(dev_id);
|
||||
dev->set_selected_machine(evt.GetString().ToStdString());
|
||||
p->main_frame->request_select_tab(TAB_ID_MONITOR);
|
||||
// Selects the status page on a built Device tab; one built by the switch starts there.
|
||||
MonitorPanel* curr_monitor = MonitorPanel::if_built();
|
||||
|
||||
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
|
||||
{
|
||||
m_printer_last_select = "";
|
||||
update_select_layout(nullptr);
|
||||
m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
|
||||
m_printer_box->GetPrinterComboBox()->SetTextLabel("");
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||
|
||||
@@ -474,14 +474,17 @@ 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();
|
||||
do_add_volumes(volume_idxs);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
update_type();
|
||||
this->set_bounding_boxes_dirty();
|
||||
@@ -508,14 +511,20 @@ void Selection::remove_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())
|
||||
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
|
||||
if (plate->empty())
|
||||
return;
|
||||
|
||||
wxGetApp().plater()->take_snapshot(std::string("Selection-Delete Curr Plate All"));
|
||||
m_mode = Instance;
|
||||
clear();
|
||||
do_add_volumes(volume_idxs);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
update_type();
|
||||
this->set_bounding_boxes_dirty();
|
||||
@@ -2261,27 +2270,6 @@ 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;
|
||||
|
||||
@@ -409,8 +409,6 @@ 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
|
||||
|
||||
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
|
||||
}
|
||||
else {
|
||||
m_printer_last_select = "";
|
||||
m_comboBox_printer->SetValue(_L("No available printers"));
|
||||
m_comboBox_printer->SetTextLabel("");
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
#endif
|
||||
|
||||
#ifdef __LINUX__
|
||||
#include "LinuxDisplayBackend.hpp"
|
||||
#include <wx/textfile.h>
|
||||
#include <wx/tokenzr.h>
|
||||
#include <cstdlib>
|
||||
@@ -202,11 +201,7 @@ TroubleshootDialog::TroubleshootDialog()
|
||||
return std::vector<wxString>{GetOStype()};
|
||||
return std::vector<wxString>{
|
||||
GetOSinfo(),
|
||||
#ifdef __LINUX__
|
||||
GetPackageType() + GetAppDisplayBackend(),
|
||||
#else
|
||||
GetPackageType(),
|
||||
#endif
|
||||
GetCPUinfo(),
|
||||
GetRAMinfo() + " RAM",
|
||||
GetGPUinfo(),
|
||||
@@ -439,15 +434,10 @@ wxString TroubleshootDialog::GetTimestamp()
|
||||
|
||||
wxString TroubleshootDialog::GetSysInfoAll()
|
||||
{
|
||||
wxString package = GetPackageType();
|
||||
#ifdef __LINUX__
|
||||
package += GetAppDisplayBackend();
|
||||
#endif
|
||||
|
||||
wxString info;
|
||||
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
|
||||
+ "Build : " + wxString(build_commit_label) + "\n"
|
||||
+ "Package : " + package + "\n"
|
||||
+ "Package : " + GetPackageType() + "\n"
|
||||
+ "Platform : " + GetOSinfo() + "\n"
|
||||
+ "Processor : " + GetCPUinfo() + "\n"
|
||||
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
|
||||
@@ -761,18 +751,6 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
wxString TroubleshootDialog::GetAppDisplayBackend()
|
||||
{
|
||||
// Display backend actually used by the app (GDK), not the session type
|
||||
wxString display;
|
||||
switch (get_linux_display_backend()) {
|
||||
case LinuxDisplayBackend::X11: display = "X11"; break;
|
||||
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
|
||||
default: break;
|
||||
}
|
||||
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
|
||||
}
|
||||
#endif
|
||||
|
||||
wxString TroubleshootDialog::GetPackageType()
|
||||
|
||||
@@ -68,7 +68,6 @@ protected:
|
||||
#elif defined(__LINUX__)
|
||||
wxString GetLinuxDistroName();
|
||||
wxString GetLinuxDisplayServer();
|
||||
wxString GetAppDisplayBackend();
|
||||
#endif
|
||||
wxString GetPackageType();
|
||||
wxString GetCPUinfo();
|
||||
|
||||
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
|
||||
|
||||
void ComboBox::ForceDropdownOpen()
|
||||
{
|
||||
if (!IsEnabled() || items.empty())
|
||||
if (!IsEnabled())
|
||||
return;
|
||||
|
||||
if (!drop_down) {
|
||||
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
|
||||
SetFocus();
|
||||
if (drop_down) {
|
||||
drop.Hide();
|
||||
} else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
|
||||
} else if (drop.HasDismissLongTime()) {
|
||||
drop.autoPosition();
|
||||
drop_down = true;
|
||||
drop.Popup(&drop);
|
||||
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
|
||||
case WXK_SPACE:
|
||||
if (drop_down) {
|
||||
drop.DismissAndNotify();
|
||||
} else if (!items.empty() && drop.HasDismissLongTime()) {
|
||||
} else if (drop.HasDismissLongTime()) {
|
||||
drop.autoPosition();
|
||||
drop_down = true;
|
||||
drop.Popup();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "bambu_networking.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/DeviceManager.hpp"
|
||||
@@ -20,19 +19,14 @@
|
||||
#include <boost/algorithm/string/trim.hpp>
|
||||
#include <boost/asio/buffer.hpp>
|
||||
#include <boost/asio/connect.hpp>
|
||||
#include <boost/asio/error.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/beast/core.hpp>
|
||||
#include <boost/beast/core/tcp_stream.hpp>
|
||||
#include <boost/beast/http/field.hpp>
|
||||
#include <boost/beast/core/flat_buffer.hpp>
|
||||
#include <boost/beast/core/error.hpp>
|
||||
#include <boost/beast/core/buffers_to_string.hpp>
|
||||
#include <boost/beast/core/stream_traits.hpp>
|
||||
#include <boost/beast/core/string_type.hpp>
|
||||
#include <boost/beast/websocket.hpp>
|
||||
#include <boost/beast/websocket/stream.hpp>
|
||||
#include <boost/beast/websocket/stream_base.hpp>
|
||||
@@ -43,7 +37,6 @@
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cctype>
|
||||
@@ -127,31 +120,6 @@ std::string map_moonraker_state(std::string state)
|
||||
return "IDLE";
|
||||
}
|
||||
|
||||
// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
|
||||
template<class Initiate>
|
||||
beast::error_code run_until_done(net::io_context& ioc, const std::atomic<bool>& stop, Initiate&& initiate)
|
||||
{
|
||||
// An abort that runs before the operation starts finds nothing to cancel
|
||||
if (stop.load()) {
|
||||
return net::error::operation_aborted;
|
||||
}
|
||||
beast::error_code result;
|
||||
bool done = false;
|
||||
initiate([&](beast::error_code ec, auto&&...) {
|
||||
result = ec;
|
||||
done = true;
|
||||
});
|
||||
ioc.restart();
|
||||
while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
|
||||
return done ? result : beast::error_code{net::error::operation_aborted};
|
||||
}
|
||||
|
||||
uint64_t steady_now_ms()
|
||||
{
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -1330,8 +1298,6 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
|
||||
}
|
||||
http.timeout_connect(5)
|
||||
.timeout_max(10)
|
||||
// Gives up once the status stream, the only caller, stops
|
||||
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
|
||||
.on_complete([&](std::string body, unsigned status) {
|
||||
if (status == 200) {
|
||||
response_body = body;
|
||||
@@ -1502,47 +1468,19 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
ws_stop.store(false);
|
||||
ws_thread = std::thread(
|
||||
[this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
|
||||
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); });
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::stop_status_stream()
|
||||
{
|
||||
std::thread stopped;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
ws_stop.store(true);
|
||||
if (ws_abort) {
|
||||
ws_abort();
|
||||
}
|
||||
// A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
|
||||
if (ws_thread.get_id() != std::this_thread::get_id()) {
|
||||
stopped = std::move(ws_thread);
|
||||
}
|
||||
}
|
||||
if (stopped.joinable()) {
|
||||
stopped.join();
|
||||
ws_stop.store(true);
|
||||
if (ws_thread.joinable()) {
|
||||
ws_thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::join_stopped_status_stream()
|
||||
{
|
||||
std::thread stopped;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
// A running stream belongs to a newer connection, so only one that is already stopping is joined
|
||||
if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
|
||||
stopped = std::move(ws_thread);
|
||||
}
|
||||
}
|
||||
if (stopped.joinable()) {
|
||||
stopped.join();
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
|
||||
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key)
|
||||
{
|
||||
WsEndpoint endpoint;
|
||||
if (!parse_ws_endpoint(base_url, endpoint)) {
|
||||
@@ -1554,108 +1492,25 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
return;
|
||||
}
|
||||
|
||||
net::io_context ioc;
|
||||
net::steady_timer backoff{ioc};
|
||||
std::function<void()> abort_connection; // Set while a connection attempt runs
|
||||
const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
|
||||
|
||||
// Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
if (ws_stop.load()) {
|
||||
return;
|
||||
}
|
||||
ws_abort = [&] {
|
||||
net::post(ioc, [&] {
|
||||
backoff.cancel();
|
||||
if (abort_connection) {
|
||||
abort_connection();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
const ScopeGuard clear_abort([this] {
|
||||
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||
ws_abort = nullptr;
|
||||
});
|
||||
|
||||
// Reconnection logic
|
||||
ws_reconnect_requested.store(false); // Reset reconnect flag
|
||||
int retry_count = 0;
|
||||
int retry_count = 0;
|
||||
const int max_retries = 10;
|
||||
const int base_delay_ms = 1000;
|
||||
|
||||
while (!ws_stop.load()) {
|
||||
while (!ws_stop.load() && retry_count < max_retries) {
|
||||
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
|
||||
|
||||
tcp::resolver resolver{ioc};
|
||||
websocket::stream<beast::tcp_stream> ws{ioc};
|
||||
beast::tcp_stream& stream = beast::get_lowest_layer(ws);
|
||||
net::steady_timer close_deadline{ioc};
|
||||
bool closing = false;
|
||||
bool deadline_pending = false;
|
||||
auto last_message = std::chrono::steady_clock::now();
|
||||
// Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
|
||||
const auto close_politely = [&](websocket::close_code code) {
|
||||
if (!ws.is_open() || closing) {
|
||||
return;
|
||||
}
|
||||
closing = true;
|
||||
deadline_pending = true;
|
||||
close_deadline.expires_after(timings.close);
|
||||
close_deadline.async_wait([&](beast::error_code ec) {
|
||||
deadline_pending = false;
|
||||
if (!ec) {
|
||||
beast::error_code ignored;
|
||||
stream.socket().close(ignored);
|
||||
}
|
||||
});
|
||||
ws.async_close(code, [&](beast::error_code) {
|
||||
closing = false;
|
||||
close_deadline.cancel();
|
||||
});
|
||||
};
|
||||
abort_connection = [&] {
|
||||
// Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
if (ws.is_open()) {
|
||||
close_politely(websocket::close_code::going_away);
|
||||
} else {
|
||||
beast::error_code ignored;
|
||||
stream.socket().close(ignored);
|
||||
}
|
||||
};
|
||||
const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
|
||||
|
||||
try {
|
||||
beast::error_code connect_ec = net::error::host_not_found;
|
||||
for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
|
||||
beast::error_code ignored;
|
||||
stream.socket().close(ignored); // A failed attempt leaves the socket open
|
||||
stream.expires_after(std::chrono::seconds(5));
|
||||
connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
|
||||
if (!connect_ec || ws_stop.load()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (connect_ec) {
|
||||
throw beast::system_error{connect_ec};
|
||||
}
|
||||
// Clears the connect deadline, which would otherwise close the socket 5 s after connecting
|
||||
stream.expires_never();
|
||||
net::io_context ioc;
|
||||
tcp::resolver resolver{ioc};
|
||||
beast::tcp_stream stream{ioc};
|
||||
|
||||
websocket::stream_base::timeout timeouts{};
|
||||
timeouts.handshake_timeout = timings.handshake;
|
||||
timeouts.idle_timeout = timings.idle;
|
||||
timeouts.keep_alive_pings = true;
|
||||
ws.set_option(timeouts);
|
||||
// Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
|
||||
ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
|
||||
if (kind == websocket::frame_type::pong && !closing &&
|
||||
std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
|
||||
send_heartbeat_if_due(dev_id, timings.heartbeat);
|
||||
}
|
||||
});
|
||||
stream.expires_after(std::chrono::seconds(10));
|
||||
auto const results = resolver.resolve(endpoint.host, endpoint.port);
|
||||
stream.connect(results);
|
||||
|
||||
websocket::stream<beast::tcp_stream> ws{std::move(stream)};
|
||||
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
|
||||
req.set(http::field::user_agent, "OrcaSlicer");
|
||||
if (!api_key.empty()) {
|
||||
@@ -1667,9 +1522,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
if (!endpoint.port.empty() && endpoint.port != "80") {
|
||||
host_header += ":" + endpoint.port;
|
||||
}
|
||||
if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
|
||||
throw beast::system_error{ec};
|
||||
}
|
||||
ws.handshake(host_header, endpoint.target);
|
||||
ws.text(true);
|
||||
|
||||
// Send client identification
|
||||
@@ -1681,10 +1534,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
identify["params"]["type"] = "agent";
|
||||
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
|
||||
identify["id"] = 0;
|
||||
const std::string identify_payload = identify.dump();
|
||||
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
|
||||
throw beast::system_error{ec};
|
||||
}
|
||||
ws.write(net::buffer(identify.dump()));
|
||||
|
||||
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
|
||||
std::set<std::string> available_objects;
|
||||
@@ -1736,17 +1586,28 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
}
|
||||
subscribe["params"]["objects"] = std::move(objects);
|
||||
subscribe["id"] = 1;
|
||||
const std::string subscribe_payload = subscribe.dump();
|
||||
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
|
||||
throw beast::system_error{ec};
|
||||
}
|
||||
ws.write(net::buffer(subscribe.dump()));
|
||||
|
||||
// Read loop
|
||||
while (!ws_stop.load()) {
|
||||
ws.next_layer().expires_after(std::chrono::seconds(2));
|
||||
beast::flat_buffer buffer;
|
||||
const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
|
||||
if (ws_stop.load()) {
|
||||
break;
|
||||
beast::error_code ec;
|
||||
ws.read(buffer, ec);
|
||||
if (ec == beast::error::timeout) {
|
||||
const auto now_ms = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
const auto last_ms = ws_last_emit_ms.load();
|
||||
if (last_ms == 0 || now_ms - last_ms >= 10000) {
|
||||
nlohmann::json message;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
|
||||
message = build_print_payload_locked();
|
||||
}
|
||||
dispatch_message(dev_id, message.dump());
|
||||
ws_last_emit_ms.store(now_ms);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ec == websocket::error::closed) {
|
||||
connection_lost = true;
|
||||
@@ -1757,9 +1618,6 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
connection_lost = true;
|
||||
break;
|
||||
}
|
||||
last_message = std::chrono::steady_clock::now();
|
||||
// A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
|
||||
retry_count = 0;
|
||||
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
|
||||
// Check if handle_ws_message triggered reconnection request
|
||||
if (ws_reconnect_requested.exchange(false)) {
|
||||
@@ -1767,18 +1625,20 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
if (!ws_stop.load()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
||||
|
||||
beast::error_code ec;
|
||||
ws.close(websocket::close_code::normal, ec);
|
||||
|
||||
// Only reset retry count on clean shutdown (not connection_lost)
|
||||
if (!connection_lost && !ws_stop.load()) {
|
||||
retry_count = 0;
|
||||
}
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
||||
connection_lost = true;
|
||||
}
|
||||
|
||||
close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
|
||||
ioc.restart();
|
||||
// Both handlers refer to this connection, so neither may be left queued for the next one
|
||||
while ((closing || deadline_pending) && ioc.run_one() != 0) {}
|
||||
|
||||
// Exit loop on clean shutdown
|
||||
if (!connection_lost) {
|
||||
break;
|
||||
@@ -1789,13 +1649,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
|
||||
const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
|
||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
|
||||
backoff.expires_after(delay);
|
||||
run([&](auto&& handler) { backoff.async_wait(handler); });
|
||||
// Exponential backoff before reconnection
|
||||
int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5));
|
||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")";
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
|
||||
retry_count++;
|
||||
}
|
||||
|
||||
if (retry_count >= max_retries) {
|
||||
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
|
||||
dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
|
||||
@@ -1869,7 +1733,8 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
const auto now_ms = steady_now_ms();
|
||||
const auto now_ms = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
const auto last_dispatch_ms = ws_last_dispatch_ms.load();
|
||||
|
||||
// Dispatch if: critical change OR throttle interval elapsed
|
||||
@@ -1890,24 +1755,6 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
|
||||
}
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
|
||||
{
|
||||
const auto now_ms = steady_now_ms();
|
||||
const auto last_ms = ws_last_emit_ms.load();
|
||||
// Re-sends only a status the printer has reported at least once
|
||||
if (last_ms == 0 || now_ms - last_ms < static_cast<uint64_t>(interval.count())) {
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json message;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
|
||||
message = build_print_payload_locked();
|
||||
}
|
||||
dispatch_message(dev_id, message.dump());
|
||||
ws_last_emit_ms.store(now_ms);
|
||||
}
|
||||
|
||||
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
|
||||
{
|
||||
if (!updates.is_object()) {
|
||||
@@ -2292,15 +2139,8 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
|
||||
}
|
||||
|
||||
join_stopped_status_stream();
|
||||
// Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
|
||||
if (is_stale()) {
|
||||
return;
|
||||
}
|
||||
start_status_stream(dev_id, base_url, api_key);
|
||||
}
|
||||
// Start WebSocket status stream
|
||||
start_status_stream(dev_id, base_url, api_key);
|
||||
#endif
|
||||
|
||||
// Success!
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "bambu_networking.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
@@ -86,16 +85,6 @@ protected:
|
||||
bool use_ssl = false;
|
||||
} device_info;
|
||||
|
||||
// Copied when the stream starts, so changes apply to the next start_status_stream()
|
||||
struct StatusStreamTimings
|
||||
{
|
||||
std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
|
||||
std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
|
||||
std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
|
||||
std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
|
||||
std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
|
||||
} ws_timings;
|
||||
|
||||
// Tray data for AMS payload building
|
||||
struct AmsTrayData {
|
||||
int slot_index = 0; // 0-based slot index
|
||||
@@ -113,8 +102,6 @@ protected:
|
||||
// Methods that derived classes may need to override or access
|
||||
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
|
||||
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
|
||||
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
||||
void stop_status_stream();
|
||||
|
||||
// State access for derived classes
|
||||
mutable std::recursive_mutex state_mutex;
|
||||
@@ -146,11 +133,10 @@ private:
|
||||
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
|
||||
void dispatch_printer_connected(const std::string& dev_id);
|
||||
void dispatch_message(const std::string& dev_id, const std::string& payload);
|
||||
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
|
||||
void join_stopped_status_stream();
|
||||
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
|
||||
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
||||
void stop_status_stream();
|
||||
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
|
||||
void handle_ws_message(const std::string& dev_id, const std::string& payload);
|
||||
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
|
||||
void update_status_cache(const nlohmann::json& updates);
|
||||
nlohmann::json build_print_payload_locked() const;
|
||||
|
||||
@@ -210,8 +196,6 @@ private:
|
||||
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
|
||||
std::atomic<uint64_t> ws_last_emit_ms{0};
|
||||
std::thread ws_thread;
|
||||
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
|
||||
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
|
||||
|
||||
// Throttling configuration for WebSocket updates
|
||||
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
|
||||
|
||||
@@ -584,19 +584,6 @@ bool PythonInterpreter::initialize()
|
||||
}
|
||||
BOOST_LOG_TRIVIAL(debug) << "Program name set successfully";
|
||||
|
||||
// Keep bytecode out of the Python home. The shipped .pyc files are stale against their
|
||||
// sources, so a writable install would rewrite them inside the app (breaking a signed
|
||||
// macOS bundle's seal) and a read-only one would recompile them on every launch.
|
||||
const std::string pycache_prefix = data_dir() + "/python/pycache";
|
||||
#ifdef _WIN32
|
||||
status = PyConfig_SetString(&config, &config.pycache_prefix, boost::nowide::widen(pycache_prefix).c_str());
|
||||
#else
|
||||
status = PyConfig_SetBytesString(&config, &config.pycache_prefix, pycache_prefix.c_str());
|
||||
#endif
|
||||
if (PyStatus_Exception(status))
|
||||
BOOST_LOG_TRIVIAL(warning) << "Failed to set the Python bytecode cache to " << pycache_prefix << ": "
|
||||
<< (status.err_msg ? status.err_msg : "unknown error");
|
||||
|
||||
// Use pybind11's scoped_interpreter which properly initializes
|
||||
// pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig
|
||||
// does not set up thread state tracking that pybind11 requires.
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
#include "libslic3r/ClipperUtils.hpp"
|
||||
#include "libslic3r/ExPolygon.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/Polygon.hpp"
|
||||
#include "libslic3r/Polyline.hpp"
|
||||
#include <limits>
|
||||
#include <string>
|
||||
@@ -995,384 +994,3 @@ 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);
|
||||
}
|
||||
@@ -735,65 +735,6 @@ 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;
|
||||
|
||||
@@ -985,37 +985,6 @@ 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;
|
||||
@@ -1219,24 +1188,6 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
|
||||
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("The selected preset is its own base when its parent is not loaded", "[Preset]")
|
||||
{
|
||||
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||
add_inmemory_preset(printers, "Printer");
|
||||
add_inmemory_preset(printers, "User Printer", "Printer");
|
||||
add_inmemory_preset(printers, "Orphan Printer", "Uninstalled Vendor Printer");
|
||||
|
||||
printers.select_preset_by_name("User Printer", true);
|
||||
REQUIRE(printers.get_selected_preset_name() == "User Printer");
|
||||
CHECK(printers.get_selected_preset_base().name == "Printer");
|
||||
|
||||
printers.select_preset_by_name("Orphan Printer", true);
|
||||
REQUIRE(printers.get_selected_preset_name() == "Orphan Printer");
|
||||
REQUIRE(printers.get_preset_base(printers.get_selected_preset()) == nullptr);
|
||||
CHECK(printers.get_selected_preset_base().name == "Orphan Printer");
|
||||
}
|
||||
|
||||
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
|
||||
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_creality_cfs_match.cpp
|
||||
test_dev_mapping.cpp
|
||||
test_device_access.cpp
|
||||
test_moonraker_printer_agent.cpp
|
||||
test_moonraker_discovery.cpp
|
||||
test_network_discovery.cpp
|
||||
test_filament_bitmap_utils.cpp
|
||||
test_colour_picker_field.cpp
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
|
||||
// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
|
||||
// as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
|
||||
// ahead of it).
|
||||
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
|
||||
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
|
||||
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
@@ -18,15 +17,12 @@
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include <imgui/imgui.h>
|
||||
#include <imgui/imgui_internal.h>
|
||||
#include <wx/event.h>
|
||||
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
#include "slic3r/GUI/KeyChord.hpp"
|
||||
|
||||
using Catch::Matchers::WithinAbs;
|
||||
using Slic3r::GUI::ImGuiWrapper;
|
||||
using Slic3r::GUI::KeyChord;
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -45,45 +41,6 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
|
||||
return ImGui::GetIO().MouseWheel;
|
||||
}
|
||||
|
||||
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
|
||||
struct ImGuiFrames
|
||||
{
|
||||
ImGuiWrapper imgui;
|
||||
|
||||
ImGuiFrames()
|
||||
{
|
||||
imgui.set_display_size(800.f, 600.f);
|
||||
unsigned char* pixels = nullptr;
|
||||
int width = 0, height = 0;
|
||||
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||
}
|
||||
|
||||
template<class Draw> void frame(Draw draw)
|
||||
{
|
||||
ImGui::NewFrame();
|
||||
draw();
|
||||
ImGui::EndFrame();
|
||||
}
|
||||
|
||||
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
|
||||
void draw_input(bool focus)
|
||||
{
|
||||
ImGui::Begin("window");
|
||||
if (focus)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
ImGui::InputText("##text", text, sizeof(text));
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
char text[16] = "";
|
||||
};
|
||||
|
||||
void draw_modal()
|
||||
{
|
||||
if (ImGui::BeginPopupModal("modal"))
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
||||
@@ -102,62 +59,3 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
|
||||
}));
|
||||
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
||||
}
|
||||
|
||||
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
|
||||
{
|
||||
ImGuiFrames f;
|
||||
f.frame([&] { f.draw_input(false); });
|
||||
CHECK_FALSE(f.imgui.text_input_active());
|
||||
|
||||
f.frame([&] { f.draw_input(true); });
|
||||
f.frame([&] { f.draw_input(false); });
|
||||
CHECK(f.imgui.text_input_active());
|
||||
|
||||
f.frame([] {});
|
||||
f.frame([] {});
|
||||
CHECK_FALSE(f.imgui.text_input_active());
|
||||
}
|
||||
|
||||
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
|
||||
{
|
||||
ImGuiFrames f;
|
||||
CHECK_FALSE(f.imgui.modal_popup_open());
|
||||
|
||||
f.frame([] {
|
||||
ImGui::OpenPopup("modal");
|
||||
draw_modal();
|
||||
});
|
||||
CHECK(f.imgui.modal_popup_open());
|
||||
|
||||
// A frame opened by input, before anything is drawn in it.
|
||||
ImGui::NewFrame();
|
||||
CHECK(f.imgui.modal_popup_open());
|
||||
draw_modal();
|
||||
ImGui::EndFrame();
|
||||
CHECK(f.imgui.modal_popup_open());
|
||||
|
||||
// A frame that no longer draws it, as when its canvas is hidden.
|
||||
f.frame([] {});
|
||||
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
|
||||
CHECK_FALSE(f.imgui.modal_popup_open());
|
||||
}
|
||||
|
||||
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
|
||||
{
|
||||
ImGuiFrames f;
|
||||
const KeyChord letter{ 'A' };
|
||||
const KeyChord save{ 'S', wxMOD_CONTROL };
|
||||
CHECK_FALSE(f.imgui.owns_key(letter));
|
||||
CHECK_FALSE(f.imgui.owns_key(save));
|
||||
|
||||
f.frame([&] { f.draw_input(true); });
|
||||
f.frame([&] { f.draw_input(false); });
|
||||
CHECK(f.imgui.owns_key(letter));
|
||||
CHECK_FALSE(f.imgui.owns_key(save));
|
||||
|
||||
f.frame([] {
|
||||
ImGui::OpenPopup("modal");
|
||||
draw_modal();
|
||||
});
|
||||
CHECK(f.imgui.owns_key(save));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <string>
|
||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
class DiscoveryAgent : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||
using MoonrakerPrinterAgent::device_info;
|
||||
mutable unsigned fetch_count = 0;
|
||||
|
||||
protected:
|
||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||
{
|
||||
++fetch_count;
|
||||
info.dev_name = "Test printer";
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
|
||||
{
|
||||
DiscoveryAgent agent("");
|
||||
unsigned announcements = 0;
|
||||
nlohmann::json announcement;
|
||||
agent.set_on_ssdp_msg_fn([&](std::string payload) {
|
||||
++announcements;
|
||||
announcement = nlohmann::json::parse(payload);
|
||||
});
|
||||
|
||||
agent.start_discovery(true, false);
|
||||
REQUIRE(announcements == 0);
|
||||
REQUIRE(agent.fetch_count == 0);
|
||||
|
||||
agent.device_info.dev_id = "127.0.0.1:7125";
|
||||
agent.device_info.dev_ip = agent.device_info.dev_id;
|
||||
agent.device_info.base_url = "http://127.0.0.1:7125";
|
||||
agent.start_discovery(true, false);
|
||||
REQUIRE(announcements == 1);
|
||||
REQUIRE(agent.fetch_count == 1);
|
||||
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
|
||||
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
|
||||
REQUIRE(announcement.at("dev_name") == "Test printer");
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/buffer.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/beast/core/error.hpp>
|
||||
#include <boost/beast/core/flat_buffer.hpp>
|
||||
#include <boost/beast/http/message.hpp>
|
||||
#include <boost/beast/http/read.hpp>
|
||||
#include <boost/beast/http/status.hpp>
|
||||
#include <boost/beast/http/string_body.hpp>
|
||||
#include <boost/beast/http/write.hpp>
|
||||
#include <boost/beast/websocket/error.hpp>
|
||||
#include <boost/beast/websocket/rfc6455.hpp>
|
||||
#include <boost/beast/websocket/stream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "slic3r/Utils/IPrinterAgent.hpp"
|
||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
namespace beast = boost::beast;
|
||||
namespace http = beast::http;
|
||||
namespace websocket = beast::websocket;
|
||||
namespace net = boost::asio;
|
||||
using tcp = net::ip::tcp;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
class DiscoveryAgent : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||
using MoonrakerPrinterAgent::device_info;
|
||||
mutable unsigned fetch_count = 0;
|
||||
|
||||
protected:
|
||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||
{
|
||||
++fetch_count;
|
||||
info.dev_name = "Test printer";
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
class StreamAgent : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||
using MoonrakerPrinterAgent::start_status_stream;
|
||||
using MoonrakerPrinterAgent::stop_status_stream;
|
||||
using MoonrakerPrinterAgent::ws_timings;
|
||||
|
||||
protected:
|
||||
bool init_device_info(
|
||||
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
|
||||
{
|
||||
device_info = MoonrakerDeviceInfo{};
|
||||
device_info.dev_id = dev_id;
|
||||
device_info.base_url = "http://" + dev_ip + ":" + port;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||
{
|
||||
info.dev_name = "Test printer";
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
enum class Behavior {
|
||||
SilentBeforeHandshake, // Accepts the connection and never answers
|
||||
Silent, // Completes the upgrade, then never reads or sends
|
||||
AnswersPings, // Reports a status, then answers pings and sends nothing else
|
||||
AnswersPingsWithoutStatus, // Answers pings but never reports a status
|
||||
Chatters, // Reports a status, then answers pings and sends notifications that carry no status
|
||||
HangsUp, // Reports a status, then closes the websocket
|
||||
HangsUpWithoutAnswer, // Closes the websocket without sending anything
|
||||
IgnoresClose, // Reports Klippy disconnected, then never answers the close
|
||||
StallsObjectList, // Never answers the stream's object list request
|
||||
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
|
||||
};
|
||||
|
||||
class FakePrinter
|
||||
{
|
||||
public:
|
||||
explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
|
||||
{
|
||||
m_accept_thread = std::thread([this] { accept_loop(); });
|
||||
}
|
||||
|
||||
~FakePrinter()
|
||||
{
|
||||
release();
|
||||
m_stopping = true;
|
||||
// Wakes the blocking accept.
|
||||
tcp::socket wake{m_accept_ioc};
|
||||
beast::error_code ec;
|
||||
wake.connect(m_acceptor.local_endpoint(), ec);
|
||||
m_accept_thread.join();
|
||||
for (std::thread& connection : m_connections)
|
||||
connection.join();
|
||||
}
|
||||
|
||||
std::string host() const { return "127.0.0.1"; }
|
||||
std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
|
||||
std::string base_url() const { return "http://" + host() + ":" + port(); }
|
||||
|
||||
// A connection is ready once it reaches the state its behavior waits in.
|
||||
bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
|
||||
}
|
||||
|
||||
bool wait_for_http_requests(int count)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
|
||||
}
|
||||
|
||||
// Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
|
||||
bool wait_for_http_hang_ups(int count)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
|
||||
}
|
||||
|
||||
int close_frames()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_close_frames;
|
||||
}
|
||||
|
||||
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
|
||||
bool wait_for_close_frames(int count)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
|
||||
}
|
||||
|
||||
// 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;
|
||||
m_changed.notify_all();
|
||||
}
|
||||
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.wait_for_close_frames(1));
|
||||
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));
|
||||
}
|
||||
@@ -615,22 +615,8 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
|
||||
|
||||
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
||||
write_package("'new'");
|
||||
{
|
||||
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
|
||||
// interpreter caches it.
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
|
||||
REQUIRE(util);
|
||||
for (const char* file : {"__init__.py", "sub.py"}) {
|
||||
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
|
||||
REQUIRE(source);
|
||||
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
|
||||
REQUIRE(cached);
|
||||
boost::system::error_code ec;
|
||||
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
|
||||
}
|
||||
}
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(package_root / "__pycache__", ec);
|
||||
|
||||
paths.clear();
|
||||
modules.clear();
|
||||
|
||||
@@ -126,55 +126,6 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
|
||||
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
|
||||
{
|
||||
CHECK(KeyChord{ 'A' }.edits_text());
|
||||
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_SPACE }.edits_text());
|
||||
CHECK(KeyChord{ WXK_BACK }.edits_text());
|
||||
CHECK(KeyChord{ WXK_DELETE }.edits_text());
|
||||
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
|
||||
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_HOME }.edits_text());
|
||||
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
|
||||
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_INSERT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
|
||||
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_RETURN }.edits_text());
|
||||
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
|
||||
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
|
||||
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
|
||||
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
|
||||
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
||||
|
||||
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
|
||||
CHECK_FALSE(KeyChord{}.edits_text());
|
||||
#ifdef __APPLE__
|
||||
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
|
||||
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
|
||||
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
|
||||
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
||||
#else
|
||||
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
|
||||
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
|
||||
{
|
||||
#ifdef _WIN32
|
||||
@@ -235,21 +186,6 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
|
||||
{
|
||||
ShortcutRegistry registry;
|
||||
size_t checked = 0;
|
||||
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
|
||||
const ShortcutInfo& info = shortcut_info(Shortcut(i));
|
||||
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
|
||||
continue;
|
||||
CAPTURE(info.key);
|
||||
CHECK_FALSE(registry.binding(info.id).edits_text());
|
||||
++checked;
|
||||
}
|
||||
CHECK(checked > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
|
||||
{
|
||||
ShortcutRegistry registry;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ set(SLIC3R_APP_KEY "OrcaSlicer")
|
||||
if(NOT DEFINED BBL_INTERNAL_TESTING)
|
||||
set(BBL_INTERNAL_TESTING "0")
|
||||
endif()
|
||||
set(SoftFever_VERSION "2.5.0-dev")
|
||||
set(SoftFever_VERSION "2.5.0-alpha")
|
||||
string(REGEX MATCH "^([0-9]+)\\.([0-9]+)\\.([0-9]+)"
|
||||
SoftFever_VERSION_MATCH ${SoftFever_VERSION})
|
||||
set(ORCA_VERSION_MAJOR ${CMAKE_MATCH_1})
|
||||
|
||||
Reference in new issue
Block a user