diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index ff07d1efb7..1918c009db 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -19,6 +19,8 @@ on: - 'build_linux.sh' - 'build_win.bat' - 'scripts/test_build_win.ps1' + - 'scripts/check_exception_throw_info.ps1' + - 'scripts/test_check_exception_throw_info.ps1' - 'build_release_macos.sh' - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' @@ -41,6 +43,8 @@ on: - 'build_linux.sh' - 'build_win.bat' - 'scripts/test_build_win.ps1' + - 'scripts/check_exception_throw_info.ps1' + - 'scripts/test_check_exception_throw_info.ps1' - 'build_release_macos.sh' - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' @@ -84,6 +88,10 @@ jobs: shell: powershell run: .\scripts\test_build_win.ps1 + - name: Run the exception throw info check tests + shell: powershell + run: .\scripts\test_check_exception_throw_info.ps1 + build_linux: strategy: fail-fast: false diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 60456ad171..e9c4603bc8 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -65,15 +65,6 @@ jobs: shell: bash run: | leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}" - # clang-cl refuses a precompiled header from another cl.exe build and ccache - # does not hash that build, so each one gets its own cache. The build number - # is read from cl.exe itself; the toolset directory keeps its name across patches. - if [ "${{ runner.os }}" = Windows ]; then - vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe' - toolset=$(tr -d '\r\n' < "$("$vswhere" -latest -products '*' -find 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt' | tr -d '\r')") - cl=$("$vswhere" -latest -products '*' -find 'VC\Tools\MSVC\'"$toolset"'\**\cl.exe' | tr -d '\r' | head -1) - leg="$leg-vc$("$cl" 2>&1 | grep -o -E 'Version [0-9.]+' | cut -d' ' -f2)" - fi echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV" echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV" @@ -552,6 +543,13 @@ jobs: name: OrcaSlicer_profile_validator_Windows_${{ env.ver }} path: ${{ github.workspace }}/build/src/Release/OrcaSlicer_profile_validator.exe + - name: Check exception throw info Win + if: runner.os == 'Windows' && inputs.compiler == 'clang' + working-directory: ${{ github.workspace }} + # After the uploads, so a failure still leaves the build and its PDB to inspect, and before any deploy. + shell: pwsh + run: scripts\check_exception_throw_info.ps1 -BuildDir $env:BUILD_DIR + - name: Deploy Windows release portable if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED uses: ./.github/actions/deploy-nightly diff --git a/CMakeLists.txt b/CMakeLists.txt index 06ea0dbd6f..62798ebfc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -321,6 +321,14 @@ if (IS_CROSS_COMPILE) set(BUILD_TESTS OFF CACHE BOOL "" FORCE) endif () +# With a precompiled header, clang-cl can omit an exception type's copy constructor from its throw info, +# so copies of the exception share and double-free their data: https://github.com/llvm/llvm-project/issues/53486 +if (MSVC AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + message(STATUS "clang-cl: building without the precompiled header (llvm/llvm-project#53486)") + set_property(CACHE SLIC3R_PCH PROPERTY HELPSTRING "Use precompiled headers (ignored with clang-cl, llvm/llvm-project#53486)") + set(SLIC3R_PCH OFF) +endif () + # Print out the SLIC3R_* cache options get_cmake_property(_cache_vars CACHE_VARIABLES) list (SORT _cache_vars) diff --git a/build_linux.sh b/build_linux.sh index cf07d7e9df..b4c03e52ec 100755 --- a/build_linux.sh +++ b/build_linux.sh @@ -473,8 +473,8 @@ fi # cmake 4.x compatibility workaround export CMAKE_POLICY_VERSION_MINIMUM=3.5 -DISTRIBUTION=$(awk -F= '/^ID=/ {print $2}' /etc/os-release | tr -d '"') -DISTRIBUTION_LIKE=$(awk -F= '/^ID_LIKE=/ {print $2}' /etc/os-release | tr -d '"') +DISTRIBUTION=$(awk -F= '/^ID=/ {print $2}' /etc/os-release | tr -d "'\"") +DISTRIBUTION_LIKE=$(awk -F= '/^ID_LIKE=/ {print $2}' /etc/os-release | tr -d "'\"") # Check for direct distribution match to Ubuntu/Debian if [ "${DISTRIBUTION}" == "ubuntu" ] || [ "${DISTRIBUTION}" == "linuxmint" ] ; then DISTRIBUTION="debian" diff --git a/build_release_macos.sh b/build_release_macos.sh index cdc5dbbe3c..9eca2e7f40 100755 --- a/build_release_macos.sh +++ b/build_release_macos.sh @@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do u ) export BUILD_TARGET="universal" ;; - h ) echo "Usage: ./build_release_macos.sh [-d]" + h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]" echo " -d: Build deps only" echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)" echo " -s: Build slicer only" @@ -60,13 +60,33 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew" echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)" echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)" + echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds," + echo " e.g. -DDESTDIR= for the deps, -DCMAKE_PREFIX_PATH=/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh." + echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory." exit 0 ;; + : ) + echo "Option -${OPTARG} requires an argument, aborting." + exit 1 + ;; * ) + echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'." + exit 1 ;; esac done +# getopts stops at '--'; whatever follows is handed to cmake unchanged. +if [[ ${OPTIND} -le $# ]] ; then + separator_index=$((OPTIND-1)) + if [[ "${!separator_index}" != "--" ]] ; then + echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting." + exit 1 + fi +fi +shift $((OPTIND-1)) +EXTRA_CMAKE_ARGS=("$@") + # Set defaults if [ -z "$ARCH" ]; then @@ -211,7 +231,8 @@ function build_deps() { -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ "${CMAKE_CCACHE_ARGS[@]}" \ - ${CMAKE_POLICY_COMPAT} + ${CMAKE_POLICY_COMPAT} \ + "${EXTRA_CMAKE_ARGS[@]}" fi cmake --build . --config "$BUILD_CONFIG" --target deps ) @@ -314,7 +335,8 @@ function build_slicer() { -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ "${CMAKE_CCACHE_ARGS[@]}" \ - ${CMAKE_POLICY_COMPAT} + ${CMAKE_POLICY_COMPAT} \ + "${EXTRA_CMAKE_ARGS[@]}" fi cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET" ) diff --git a/build_win.bat b/build_win.bat index dc19df06bc..8af3581abf 100644 --- a/build_win.bat +++ b/build_win.bat @@ -53,7 +53,7 @@ call :add_section "Build configuration" call :add_arg config string "" config "release, debug, relwithdebinfo or minsizerel (default: release)" call :add_arg target_arch string "" arch "x64 or arm64 (default: the host architecture)" call :add_arg slicer_asan bool a asan "Build the slicer with ASAN enabled" -call :add_arg no_pch bool "" no-pch "Build without the precompiled header, implied by --cache" +call :add_arg no_pch bool "" no-pch "Build without the precompiled header, implied by -l and --cache" call :add_section "Toolchain" call :add_arg use_clang_cl bool l clang-cl "Use clang-cl as the compiler" @@ -388,6 +388,9 @@ if "%use_ninja%" == "ON" if "%use_msbuild%" == "ON" ( exit /b 1 ) +REM CMakeLists turns the precompiled header off for clang-cl (llvm/llvm-project#53486). +if "%use_clang_cl%" == "ON" set "no_pch=ON" + set "generator=!vs_gen_%vs_version%!" if "%use_ninja%" == "ON" ( set "generator=Ninja Multi-Config" @@ -543,7 +546,7 @@ REM real path rather than one glued onto the repository root. for %%p in ("!build_dir!") do set "build_full=%%~fp" echo Configuration: %build_type%, %arch% if not "%clang_exe%" == "" echo Compiler: %clang_exe% -if "%no_pch%" == "ON" echo Precompiled header: off +if "%build_slicer%" == "ON" if "%no_pch%" == "ON" echo Precompiled header: off if not "%cache_args%" == "" echo Compiler cache: %cache_exe% set "SIG_FLAG=" @@ -716,8 +719,8 @@ if "%build_slicer%" == "ON" ( set "slicer_args=!slicer_args! -DSLIC3R_ASAN=ON" ) - REM A later -DSLIC3R_PCH=ON in ORCA_SLICER_CMAKE_ARGS still wins, because - REM CMake takes the last definition on the command line. + REM Under cl, a later -DSLIC3R_PCH=ON in ORCA_SLICER_CMAKE_ARGS still wins, + REM because CMake takes the last definition on the command line. if "%no_pch%" == "ON" ( set "slicer_args=!slicer_args! -DSLIC3R_PCH=OFF" ) diff --git a/deps/OpenCV/OpenCV.cmake b/deps/OpenCV/OpenCV.cmake index 0290b3f47a..d465e6129d 100644 --- a/deps/OpenCV/OpenCV.cmake +++ b/deps/OpenCV/OpenCV.cmake @@ -26,6 +26,7 @@ orcaslicer_add_cmake_project(OpenCV URL https://github.com/opencv/opencv/archive/refs/tags/4.6.0.tar.gz URL_HASH SHA256=1ec1cba65f9f20fe5a41fda1586e01c70ea0c9a6d7b67c9e13edf0cfe2239277 PATCH_COMMAND git apply ${OpenCV_DIRECTORY_FLAG} --verbose --ignore-space-change --whitespace=fix ${CMAKE_CURRENT_LIST_DIR}/0001-vs.patch ${CMAKE_CURRENT_LIST_DIR}/0002-clang19-macos.patch + DEPENDS ${JPEG_PKG} CMAKE_ARGS -DBUILD_SHARED_LIBS=0 -DBUILD_PERE_TESTS=OFF @@ -34,7 +35,8 @@ orcaslicer_add_cmake_project(OpenCV -DBUILD_EXAMPLES=OFF -DBUILD_JASPER=OFF -DBUILD_JAVA=OFF - -DBUILD_JPEG=ON + # Share Orca's libjpeg to avoid conflicting codec symbols in the application. + -DBUILD_JPEG=OFF -DBUILD_APPS_LIST=version -DBUILD_opencv_apps=OFF -DBUILD_opencv_java=OFF @@ -67,7 +69,8 @@ orcaslicer_add_cmake_project(OpenCV -DWITH_OPENJPEG=OFF -DWITH_QUIRC=OFF -DWITH_VTK=OFF - -DWITH_JPEG=OFF + # Textured GLB/OBJ imports decode embedded and external JPEGs with cv::imdecode. + -DWITH_JPEG=ON -DWITH_WEBP=OFF -DWITH_TIFF=OFF -DBUILD_TIFF=OFF diff --git a/deps/wxWidgets/wxWidgets.cmake b/deps/wxWidgets/wxWidgets.cmake index e57e82f3e9..605fad2f0b 100644 --- a/deps/wxWidgets/wxWidgets.cmake +++ b/deps/wxWidgets/wxWidgets.cmake @@ -21,6 +21,14 @@ else () set(_wx_edge "-DwxUSE_WEBVIEW_EDGE=OFF") endif () +# With a precompiled header, clang-cl can omit an exception type's copy constructor from its throw info, +# so copies of the exception share and double-free their data: https://github.com/llvm/llvm-project/issues/53486 +if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(_wx_precomp OFF) +else () + set(_wx_precomp ON) +endif () + set(_wx_patch_command "") if (APPLE) set(_wx_patch_command @@ -39,7 +47,7 @@ orcaslicer_add_cmake_project( PATCH_COMMAND ${_wx_patch_command} DEPENDS ${PNG_PKG} ${ZLIB_PKG} ${EXPAT_PKG} ${JPEG_PKG} CMAKE_ARGS - -DwxBUILD_PRECOMP=ON + -DwxBUILD_PRECOMP=${_wx_precomp} ${_wx_toolkit} "-DCMAKE_DEBUG_POSTFIX:STRING=${_wx_debug_postfix}" -DwxBUILD_DEBUG_LEVEL=0 diff --git a/docs/HLSD/filament_id.md b/docs/HLSD/filament_id.md index 122dfb6fdb..555a5d6ad5 100644 --- a/docs/HLSD/filament_id.md +++ b/docs/HLSD/filament_id.md @@ -350,8 +350,11 @@ identity default and translates nothing. `NetworkAgent` forwards both to the liv comparison sites below reach them through `wxGetApp().getAgent()` and leave an id untranslated while no agent is live. Whole documents are Bambu's business alone: `BBLPrinterAgent::to_orca_payload` and `from_orca_payload` rewrite every string under -`tray_info_idx`, `filament_id` or `filamentId` at any depth; text that does not parse, or carries -none of those keys, comes back unchanged. The map is loaded once, lazily; a missing or malformed +`tray_info_idx`, `filament_id`, `filamentId`, `cate` or `fila_id` at any depth; text that does not +parse, or carries none of those keys, comes back unchanged. The last two are the H2C nozzle rack's: +`cate` in the auto nozzle mapping request, `fila_id` in the rack status. A new Bambu message that +carries a filament id under another key needs that key added to `filament_id_keys`, or the id +crosses the boundary untranslated. The map is loaded once, lazily; a missing or malformed file degrades to identity with a log line rather than failing. | Boundary | Where it translates | diff --git a/docs/HLSD/travel-optimization.md b/docs/HLSD/travel-optimization.md new file mode 100644 index 0000000000..119133cee2 --- /dev/null +++ b/docs/HLSD/travel-optimization.md @@ -0,0 +1,198 @@ +# Travel optimization — High Level Design + +## Purpose and scope + +Inside an island, the infill paths of each region are chained into one print +order by a greedy pass that joins the closest free path ends first. That pass +leaves its longest joins for last, and they often cross the whole island. Two +things shorten the travel between infill paths: + +- the fill generators join their lines into as few paths as they can, since + every separate path needs a travel to it; +- the G-code export refines the greedy chain with a local search that shortens + the straight-line travel between the paths. + +The refinement covers every chain that `chain_and_reorder_extrusion_entities()` +builds from a start point: the infill and ironing of `GCode::extrude_infill()` +and the support of `GCode::extrude_support()`, as well as the order of objects +and islands under `print_order` Default. It changes only the order and the +direction of the paths, not the paths or the walls. Neither part has a setting. + +## The chain + +`ObjectByExtruder::Island::Region::append()` flattens every sortable fill +collection into its paths, so the paths of all the fills of a region in one +island are chained together. A collection that must keep its order (`no_sort`: +monotonic, concentric, a forced fill order, the flow calibration) stays a single +entry that cannot be reversed. Paths that must keep their direction cannot be +reversed either: Grid's, since it alternates their direction between layers for +strength, and those of a fill with a forced order. A loop starts and ends at the +same point, so reversing it changes nothing. + +`extrude_infill()` chains each region from the current nozzle position with +`chain_and_reorder_extrusion_entities()`, and chains the paths inside a nested +sortable collection the same way; `extrude_support()` chains the support paths +of a layer so too, unless they keep their order. The overload without a start +point, used to chain collections while slicing, keeps the greedy chain. The +order the fill generators give their paths does not reach the G-code for a +sortable fill, which is chained again. + +## Connecting the lines + +Most patterns cut their lines at the infill boundary, and `Fill::connect_infill()` +joins pairs of line ends that are neighbors on a boundary contour by the arc of +wall between them, shortest arcs first, as long as neither end is joined yet and +the join closes no loop. An arc is joined when it is shorter than +`infill_anchor_max`, or than two anchors and a line width if that is longer: +two ends further apart each get an anchor toward the other, and where those +would meet, the join prints about as much wall as one path instead of two. + +Regular patterns leave many arcs of the same length, and the order among them +decides the pairing. In an arbitrary order the pairs along one wall can fall out +of step, such as lines 1–2 and 3–4, then 6–7 and 8–9, which leaves the end of +line 5 loose, and every loose end starts or ends a separate path. So the arcs +are also sorted in steps of half the line spacing and, within a step, along each +contour forwards and backwards, which pairs the ends of a wall in one phase. +Each order is replayed on the flags and a union-find of the lines, without +building any path, and the one that joins the most lines is used; a tie keeps +the shortest-first order. + +The replay includes the last pass, which joins any two open ends that are +neighbors on a contour. With multiline infill the main pass leaves the copies of +one line to that pass, so that each copy reaches a wall of its own. Two +neighboring groups cut at both walls can then be joined at either wall by arcs +of the same length: at one, their open ends stay next to their own copies, which +the last pass joins, so each line prints out and back; at the other, the copies +end up in separate paths printed apart. + +A line end left unpaired gets an anchor, a piece of the wall at most +`infill_anchor` long. It runs toward the nearest open end of another path, +looking up to 64 ends along the contour, since the next path usually starts +there; without one it takes the side with more free wall. With multiline infill +an anchor toward a copy of the same line stops short of the copy rather than +printing over it. A copy is a neighboring end less than the group width +(`multiline` spacings) across the line, at any wall angle, while the next group +lies a whole line spacing away. Meeting the wall at an angle θ, the copy covers +half a width over sin θ of the wall on each side of its end, so the anchor stops +that far and half its own width before it, or is left out; the end still counts +as anchored, so the last pass pairs the other ends as before. + +Last, `Fill::fill_surface_extrusion()` joins the sparse infill lines whose ends +are at most two line spacings apart with a straight piece. With the anchors off +(`infill_anchor_max` 0) nothing above runs and this is the only joining; with +them on, it takes what the steps above leave next to each other, such as two +anchors that stop just short of each other or a short piece cut off by a corner. +A gap that short leaves no room to print anything in between, and as a travel it +is the one hop a reordering can trade for a longer travel that retracts. +Multiline copies leave the most such ends: one spacing apart across the line, +their ends where it meets a wall at an angle θ are one spacing over sin θ apart +along it, so two spacings reach walls met at 30° and steeper. + +Ends that meet are joined first, then the gaps between the leading ends of their +lines, along each line's overall direction, then the rest, shortest first within +each, without closing a loop or crossing a line or another join. Taking the +leading ends first joins every pair of copies at the same side, which leaves +their open ends next to each other; by length alone, neighboring pairs were +joined at different walls and the travel between them crossed the island. Lines +that cannot reverse, such as Grid's, only join the end of one to the start of the +next, and fills that keep their order are left alone. + +## The search + +A chain of up to eight paths is found exactly, by dynamic programming over the +subsets of paths: a state is the paths visited so far, the last one and its +direction, about four thousand states for eight paths. The shared greedy +chaining, `chain_segments_greedy_constrained_reversals_()`, does the same for two +to four segments, which also orders up to four islands of a layer and the loops +of a perimeter collection exactly. Either way the first path is no longer +always the one nearest the start; with a free end, a path near the start can be +worth leaving for last. + +Objects (`print_order` Default) and the tour of islands across objects are +visited in the same order on every layer, so `chain_points_with_postprocessing()` +orders them as a closed tour rather than an open chain. Up to ten points the +shortest tour is found exactly by the same dynamic programming with the first +point fixed; with a start point the tour then begins at the point nearest it. +Larger sets take the greedy chain and alternate 2-opt, or-opt and crossing +removal; or-opt moves runs of up to three points next to one of the eight points +nearest an end of the run, found on the same grid as the chain's candidates. + +For longer chains, the search refines the greedy chain. On an island cut into many short pieces, +such as infill without anchors, that chain can sweep away from the nozzle and +come back for the rest with one long jump, which no local move below removes. +So a nearest-next chain, going from the nozzle to the nearest free path end each +time, is built too; when it is already shorter than the refined greedy chain, it +is refined and used instead. Elsewhere it costs one grid query per path. + +`improve_chain_by_local_search()` sees the chain as a sequence of segments with +an entry and an exit point each. Its cost is the straight distance from the +nozzle to the first entry plus the distance from every exit to the next entry; +the end of the chain is free. It applies three kinds of moves: + +- 2-opt reverses a run of segments, which flips each of them and replaces the + two links at the ends of the run. +- Or-opt moves a run of up to three segments between two others, flipped or + not. +- A run of any length that starts or ends next to the nozzle moves to the front. + Local search can settle on a chain that starts with a block of paths far from + the nozzle while the paths next to it sit in the middle, and moving that block + takes more than three segments at once. + +A move that would flip a segment that cannot be reversed is skipped, so such +segments are only reordered. + +A move is tried only if every link it creates joins an end point to one of the +32 end points nearest to it, which covers every move on a chain of up to 17 +segments, as most islands are; with 8 candidates the search missed most of the +gain on grid-like patterns such as Triangles. The candidates come from a uniform +grid of about two end points per cell, searched in rings of cells around the +query. `KDTreeIndirect` visits its subtrees in a fixed order rather than the +nearer one first, so a query for 32 points walks most of the tree, and on long +chains finding the candidates would cost more than the search itself. + +The candidates of an end point are tried nearest first, and only while they are +closer to it than the link the move would replace there. A 2-opt move that pays +off creates a link shorter than the one it replaces at one of its two ends, so +this misses no 2-opt move. An or-opt move is tried only while its new link is +shorter than what taking the run out saves, which skips a few or-opt moves that +would still pay off but cuts the search on long chains to a fraction. + +The search works through a queue of segments and applies the first move that +shortens the chain around the segment it examines. After a move, only the +segments next to a changed link are queued again. A new link can also make room +for a run elsewhere that no segment next to it would try, so when the queue +empties after any move, every segment is queued once more, and the search ends +when such a sweep finds no move. + +Every move shortens the chain by more than `SCALED_EPSILON`, so the result is +never longer than the greedy or the nearest-next chain and the search ends; a +cap of 50 moves per segment only bounds its time. The search has no randomness +and visits the segments in a fixed order, so the same input always gives the +same G-code. + +## Cost + +Finding the candidates of an end point visits a few cells, and the nearest-next +chain makes one such query per path, which only grows where the paths around the +nozzle are used up and the chain jumps. Examining a segment tries at most a few +hundred candidate links, usually only the nearest few, plus a scan of the chain +for the move to the front when it is the first segment, and a move rewrites only +the range of the chain it changes, so the search stays close to linear in the +number of segments. Most islands hold a few to a few dozen paths; the cost shows +on infill cut into thousands of pieces per island, such as dense infill with the +anchors off. The straight joins only look at the line segments near a candidate +join for crossings. + +## Limitations + +The search minimizes straight-line distance. It does not know which travels +leave the infill area and retract under `reduce_infill_retraction`, nor the +detours of `reduce_crossing_wall`, and a travel's time is not proportional to +its length: a short travel is bound by acceleration, so its time grows about +with the square root of its length. A chain that is shorter in total but trades +very short travels for longer ones, or for ones past `retraction_minimum_travel`, +can occasionally take a little longer to print. + +The end of the chain is free: it is not steered toward the next island or the +start of the next layer. The regions of an island are chained one after the +other, in region order, each from where the previous one ended. diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index 5c7127dbc6..639d9948fc 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -36839,7 +36839,9 @@ msgstr "" msgid "" "Reverse on even\n" "Did you know that Reverse on even feature can significantly improve the surface quality of your overhangs? However, it can cause wall inconsistencies so use carefully!" -msgstr "Реверс на чётных слояхСовет: настройка Реверс на чётных слоях может существенно улучшить качество нависающих поверхностей. Как и нарушить их однородность, так что используйте с осторожностью." +msgstr "" +"Реверс на чётных слоях\n" +"Совет: настройка Реверс на чётных слоях может существенно улучшить качество нависающих поверхностей. Как и нарушить их однородность, так что используйте с осторожностью." #: resources/data/hints.ini: [hint:Cut Tool] msgid "" diff --git a/resources/fonts/NotoSansKR-Bold.ttf b/resources/fonts/NotoSansKR-Bold.ttf deleted file mode 100644 index 6cf639eb7d..0000000000 Binary files a/resources/fonts/NotoSansKR-Bold.ttf and /dev/null differ diff --git a/resources/fonts/NotoSansKR-Regular.ttf b/resources/fonts/NotoSansKR-Regular.ttf deleted file mode 100644 index 1b14d32473..0000000000 Binary files a/resources/fonts/NotoSansKR-Regular.ttf and /dev/null differ diff --git a/scripts/check_exception_throw_info.ps1 b/scripts/check_exception_throw_info.ps1 new file mode 100644 index 0000000000..5fd76599b8 --- /dev/null +++ b/scripts/check_exception_throw_info.ps1 @@ -0,0 +1,126 @@ +<# +.SYNOPSIS + Fails when a Windows build has an exception class with broken throw info. + +.DESCRIPTION + Clang on the MSVC ABI can leave the copy constructor out of the CatchableType it emits for + an exception class that comes through a precompiled header + (https://github.com/llvm/llvm-project/issues/53486). The linker keeps one throw info per + class, so when it keeps such a record, a copy of that exception (std::exception_ptr, a catch + by value) shares its data with the original and the data is freed twice. + + A class is either trivially copyable or not, so a binary should never have both a CatchableType + without a copy constructor (_CT??_R0?AV@@@8) and one with it + (_CT??_R0?AV@@@8??0...). The script lists each PDB's public symbols with the + llvm-pdbutil next to the build's compiler and reports every class that has both. + + scripts\test_check_exception_throw_info.ps1 tests it. + +.PARAMETER BuildDir + The slicer build directory. Its CMakeCache.txt names the compiler, and its src\ + holds the PDBs to check, which must include OrcaSlicer.pdb. + +.PARAMETER Config + The configuration whose PDBs to check (default: Release). + +.PARAMETER Pdb + PDBs to check instead of the ones in \src\, as a list or comma-separated. + +.PARAMETER Dump + Saved `llvm-pdbutil dump --publics` output to check instead of PDBs. Needs no build. + +.EXAMPLE + scripts\check_exception_throw_info.ps1 -BuildDir build +#> +param( + [string] $BuildDir, + [string] $Config = 'Release', + [string[]] $Pdb, + [string[]] $Dump +) +$ErrorActionPreference = 'Stop' + +# powershell -File passes "a.pdb,b.pdb" as one string. A path that exists as given stays whole. +function Split-PathList([string[]] $values) { + @($values | Where-Object { $_ } | ForEach-Object { + if ((Test-Path -LiteralPath $_) -or $_ -notmatch ',') { $_ } else { $_ -split ',' | Where-Object { $_ } } + }) +} + +$Dump = Split-PathList $Dump +if (-not $Dump) { + if (-not $BuildDir) { + throw 'Name the slicer build directory with -BuildDir.' + } + $cache = Join-Path $BuildDir 'CMakeCache.txt' + if (-not (Test-Path $cache)) { + throw "No CMakeCache.txt in $BuildDir. Name the slicer build directory." + } + $entry = Select-String -Path $cache -Pattern '^CMAKE_CXX_COMPILER:\w+=(.+)$' | Select-Object -First 1 + if (-not $entry) { + throw "No CMAKE_CXX_COMPILER in $cache." + } + $compiler = $entry.Matches[0].Groups[1].Value + # A tree configured with a bare compiler name found it on PATH. + if (-not (Split-Path $compiler)) { + $found = Get-Command $compiler -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $found) { + throw "$compiler, the compiler in $cache, is not on PATH." + } + $compiler = $found.Source + } + $pdbutil = Join-Path (Split-Path $compiler) 'llvm-pdbutil.exe' + if (-not (Test-Path $pdbutil)) { + throw "No llvm-pdbutil.exe next to $compiler. This check needs a build made with Clang." + } + + $Pdb = Split-PathList $Pdb + if (-not $Pdb) { + # OrcaSlicer.dll holds the slicer itself, so a check without it proves nothing. + $pdbDir = Join-Path $BuildDir "src\$Config" + if (-not (Test-Path (Join-Path $pdbDir 'OrcaSlicer.pdb'))) { + throw "No OrcaSlicer.pdb in $pdbDir." + } + $Pdb = @(Get-ChildItem $pdbDir -Filter *.pdb | ForEach-Object FullName) + } +} + +$catchable = [regex]'`_CT\?\?_R0\?A[UV](.+?)@@@8([^`]*)`' +$tmp = [IO.Path]::GetTempFileName() +$failed = $false +$classes = 0 +try { + foreach ($file in $(if ($Dump) { $Dump } else { $Pdb })) { + $text = $file + if (-not $Dump) { + $run = Start-Process -FilePath $pdbutil -ArgumentList 'dump', '--publics', "`"$file`"" -NoNewWindow -Wait -PassThru -RedirectStandardOutput $tmp + if ($run.ExitCode -ne 0) { + throw "llvm-pdbutil failed on $file." + } + $text = $tmp + } + + $kinds = @{} + foreach ($match in $catchable.Matches([IO.File]::ReadAllText((Resolve-Path -LiteralPath $text).Path))) { + $class = $match.Groups[1].Value + if (-not $kinds.ContainsKey($class)) { $kinds[$class] = @{} } + $kinds[$class][$(if ($match.Groups[2].Value.StartsWith('??0')) { 'copy' } else { 'bitwise' })] = $true + } + + $broken = @($kinds.Keys | Where-Object { $kinds[$_].Count -eq 2 } | Sort-Object) + "$(Split-Path -Leaf $file): $($kinds.Count) exception classes, $($broken.Count) with broken throw info" + foreach ($class in $broken) { " $class" } + if ($broken.Count -gt 0) { $failed = $true } + $classes += $kinds.Count + } +} finally { + Remove-Item $tmp -ErrorAction SilentlyContinue +} + +# The slicer's PDBs always contain exception throw info, so finding none means the dump format or the PDBs changed. +if ($classes -eq 0) { + 'No exception classes found. Check that llvm-pdbutil still prints public symbols in backticks.' + $failed = $true +} + +exit [int]$failed diff --git a/scripts/test_build_win.ps1 b/scripts/test_build_win.ps1 index 5f36fb7ac9..0c2a26c952 100644 --- a/scripts/test_build_win.ps1 +++ b/scripts/test_build_win.ps1 @@ -301,6 +301,10 @@ $cases = @( Contains = @('Precompiled header: off') } @{ Name = 'the precompiled header is on unless asked'; Args = @('-s') NotContains = @('SLIC3R_PCH') } + @{ Name = '-l turns the precompiled header off'; Args = @('-s', '-l') + Contains = @('-DSLIC3R_PCH=OFF', 'Precompiled header: off') } + @{ Name = 'a deps-only build says nothing about the precompiled header'; Args = @('-d', '-l') + NotContains = @('Precompiled header') } @{ Name = '--no-pch works without a cache'; Args = @('-s', '--no-pch') NotContains = @('COMPILER_LAUNCHER') } @{ Name = 'the slicer build runs gettext'; Args = @('-s') diff --git a/scripts/test_check_exception_throw_info.ps1 b/scripts/test_check_exception_throw_info.ps1 new file mode 100644 index 0000000000..0c2ef2edfb --- /dev/null +++ b/scripts/test_check_exception_throw_info.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS + Tests scripts/check_exception_throw_info.ps1 on canned llvm-pdbutil dumps. + +.DESCRIPTION + Each case runs the check in its own powershell -File process, as CI does, and asserts on the + exit code and output. Dumps are written to a temporary folder, so no build is needed. + +.EXAMPLE + powershell -File scripts/test_check_exception_throw_info.ps1 +#> +$ErrorActionPreference = 'Stop' + +$check = Join-Path $PSScriptRoot 'check_exception_throw_info.ps1' +$work = Join-Path ([IO.Path]::GetTempPath()) 'check_exception_throw_info_tests' +Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $work | Out-Null + +function Write-Dump([string] $path, [string[]] $symbols) { + New-Item -ItemType Directory -Force -Path (Split-Path $path) | Out-Null + $i = 0 + $lines = @(' Public Symbols', '============================================================') + foreach ($symbol in $symbols) { + $i++ + $lines += " $i | S_PUB32 [size = 48] ``$symbol``", ' flags = none, addr = 0007:0000' + } + Set-Content -Path $path -Encoding ascii -Value $lines +} + +$bitwise = '_CT??_R0?AVfuture_error@std@@@840' +$copy = '_CT??_R0?AVfuture_error@std@@@8??0future_error@std@@QEAA@AEBV01@@Z40' +$trivial = '_CT??_R0?AUtag@@@81' +Write-Dump "$work\broken.txt" @($bitwise, $copy, $trivial) +Write-Dump "$work\clean.txt" @($copy, $trivial) +Write-Dump "$work\empty.txt" @() +Write-Dump "$work\a dir, with a comma\clean.txt" @($copy) + +# A build directory whose compiler has an llvm-pdbutil beside it but no OrcaSlicer.pdb. +# Nothing runs the stand-in; the check stops before it gets there. +$build = "$work\build" +New-Item -ItemType Directory -Force -Path "$build\bin", "$build\src\Release" | Out-Null +Copy-Item "$env:SystemRoot\System32\where.exe" "$build\bin\llvm-pdbutil.exe" +Set-Content -Path "$build\CMakeCache.txt" -Encoding ascii -Value "CMAKE_CXX_COMPILER:FILEPATH=$build\bin\clang-cl.exe" +Set-Content -Path "$build\src\Release\orca-slicer.pdb" -Value '' +$bare = "$work\bare" +New-Item -ItemType Directory -Force -Path $bare | Out-Null +Set-Content -Path "$bare\CMakeCache.txt" -Encoding ascii -Value 'CMAKE_CXX_COMPILER:STRING=no-such-clang-cl.exe' + +$cases = @( + @{ Name = 'a class with and without a copy constructor fails'; Args = @('-Dump', "$work\broken.txt") + Exit = 1; Contains = @('1 with broken throw info', 'future_error@std') } + @{ Name = 'copy constructors and trivially copyable classes pass'; Args = @('-Dump', "$work\clean.txt") + Exit = 0; Contains = @('2 exception classes, 0 with broken throw info') } + @{ Name = 'no exception classes at all fails'; Args = @('-Dump', "$work\empty.txt") + Exit = 1; Contains = @('No exception classes found') } + @{ Name = 'a comma-separated list checks each dump'; Args = @('-Dump', "$work\broken.txt,$work\clean.txt") + Exit = 1; Contains = @('broken.txt: 2 exception classes', 'clean.txt: 2 exception classes') } + @{ Name = 'a path with a comma stays whole'; Args = @('-Dump', "$work\a dir, with a comma\clean.txt") + Exit = 0; Contains = @('clean.txt: 1 exception classes') } + @{ Name = 'a build without OrcaSlicer.pdb fails'; Args = @('-BuildDir', $build) + Exit = 1; Contains = @('No OrcaSlicer.pdb in') } + @{ Name = 'a compiler that is not on PATH is named'; Args = @('-BuildDir', $bare) + Exit = 1; Contains = @('no-such-clang-cl.exe, the compiler in') } + @{ Name = 'a missing build directory is named'; Args = @('-BuildDir', "$work\nowhere") + Exit = 1; Contains = @('No CMakeCache.txt in') } + @{ Name = 'no build directory and no dump fails'; Args = @() + Exit = 1; Contains = @('Name the slicer build directory') } +) + +$failed = 0 +foreach ($case in $cases) { + $ErrorActionPreference = 'Continue' + $output = (& powershell -NoProfile -File $check @($case.Args) 2>&1 | Out-String) + $code = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + $problems = @() + if ($code -ne $case.Exit) { $problems += "exit $code, expected $($case.Exit)" } + foreach ($text in $case.Contains) { + if (-not $output.Contains($text)) { $problems += "no '$text' in the output" } + } + if ($problems) { + $failed++ + " FAIL $($case.Name)" + $problems | ForEach-Object { " $_" } + $output.TrimEnd() -split "`r?`n" | ForEach-Object { " | $_" } + } else { + " ok $($case.Name)" + } +} + +Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue +"$($cases.Count - $failed) passed, $failed failed" +exit [int]($failed -gt 0) diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 265efb2eea..4acea143cc 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -666,6 +666,9 @@ endif () encoding_check(libslic3r) target_compile_definitions(libslic3r PUBLIC -DUSE_TBB -DTBB_USE_CAPTURED_EXCEPTION=0) +# CGAL changes Kd_tree's member layout when TBB is enabled. Match libslic3r_cgal +# in every consumer so inline constructors/destructors cannot mix the two layouts. +target_compile_definitions(libslic3r PUBLIC CGAL_LINKED_WITH_TBB) if (USE_SLIC3R_CONSOLE_LOG) target_compile_definitions(libslic3r PRIVATE $<$:SLIC3R_CONSOLE_LOG>) endif() diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 9d76262ec6..9f70498499 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -377,7 +377,8 @@ public: virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0; virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector& restore_index, int stride) = 0; virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector& restore_index, int start, int len, bool skip_error = false) = 0; - virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride) = 0; + // keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit". + virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride, bool keep_nil = false) = 0; virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector& dest_index, int stride) = 0; virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0; // Resize the vector of values, copy the newly added values from opt_default if provided. @@ -580,7 +581,7 @@ public: //set a item related with extruder variants when loading user config, only set the different value of some extruder //rhs: item from user config //diff_index: which index in this vector need to be set - virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride) override + virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride, bool keep_nil) override { if (rhs->type() == this->type()) { // Assign the first value of the rhs vector. @@ -592,7 +593,7 @@ public: for (size_t i = 0; i < diff_index.size(); i++) { if (diff_index[i] != -1) { for (size_t j = 0; j < stride; j++) { - if (!other->is_nil(diff_index[i] * stride)) + if (keep_nil || !other->is_nil(diff_index[i] * stride)) this->values[i * stride + j] = other->values[diff_index[i] * stride + j]; } } @@ -841,8 +842,12 @@ public: { UNUSED(append); std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + double value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + return true; } bool operator==(const ConfigOption &rhs) const override @@ -1033,8 +1038,12 @@ public: { UNUSED(append); std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + int value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + return true; } ConfigOptionInt& operator=(const ConfigOption *opt) @@ -1252,8 +1261,12 @@ public: UNUSED(append); // don't try to parse the trailing % since it's optional std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + double value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + return true; } private: @@ -1365,10 +1378,14 @@ public: bool deserialize(const std::string &str, bool append = false) override { UNUSED(append); - this->percent = str.find_first_of("%") != std::string::npos; std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + double value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + this->percent = str.find_first_of("%") != std::string::npos; + return true; } private: @@ -1538,8 +1555,12 @@ public: { UNUSED(append); char dummy; - return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 || - sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2; + Vec2d value; + if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 && + sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2) + return false; + this->value = value; + return true; } private: @@ -1658,8 +1679,12 @@ public: { UNUSED(append); char dummy; - return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 || - sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3; + Vec3d value; + if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 && + sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3) + return false; + this->value = value; + return true; } private: diff --git a/src/libslic3r/EdgeGrid.cpp b/src/libslic3r/EdgeGrid.cpp index c6e003505a..35d3608dd8 100644 --- a/src/libslic3r/EdgeGrid.cpp +++ b/src/libslic3r/EdgeGrid.cpp @@ -616,7 +616,7 @@ struct PropagateDanielssonSingleStep { if ((signs[addr] & 2) == 0) { float *v = &L[addr << 1]; float l = v[0] * v[0] + v[1] * v[1]; - float *v2s = v + (addr_delta << 1); + float *v2s = v + 2 * addr_delta; float v2[2] = { v2s[0] + INCX * resolution, v2s[1] + INCY * resolution diff --git a/src/libslic3r/Fill/FillBase.cpp b/src/libslic3r/Fill/FillBase.cpp index a852f2c42b..63ebff49c2 100644 --- a/src/libslic3r/Fill/FillBase.cpp +++ b/src/libslic3r/Fill/FillBase.cpp @@ -11,6 +11,10 @@ #include #include #include +#include +#include +#include +#include "../AABBTreeLines.hpp" #include "../ClipperUtils.hpp" #include "../EdgeGrid.hpp" #include "../Geometry.hpp" @@ -206,6 +210,9 @@ void Fill::fill_surface_extrusion(const Surface* surface, const FillParams& para thick_polylines.clear(); } else { + // Orca: sparse infill lines whose ends are next to each other print as one path, without a travel between them. + if (params.extrusion_role == erInternalInfill && ! eec->no_sort) + join_close_ends(polylines, scale_(2. * this->spacing), params.can_reverse); extrusion_entities_append_paths( eec->entities, std::move(polylines), params.extrusion_role, @@ -1644,6 +1651,8 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vector SCALED_EPSILON ? std::max(anchor_length_max, 2. * (anchor_length + line_half_width)) : anchor_length_max; #if 0 // Connection from end of one infill line to the start of another infill line. @@ -1720,6 +1729,10 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vector open_end(graph.map_infill_end_point_to_boundary.size(), 1); + auto end_index = [&graph](const ContourIntersectionPoint *cp) { return size_t(cp - graph.map_infill_end_point_to_boundary.data()); }; + struct Arc { ContourIntersectionPoint *intersection; double arc_length; @@ -1731,6 +1744,81 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vector &order) { + std::vector consumed(graph.map_infill_end_point_to_boundary.size()); + for (size_t i = 0; i < consumed.size(); ++ i) + consumed[i] = graph.map_infill_end_point_to_boundary[i].consumed; + std::vector parent(infill_ordered.size()); + std::iota(parent.begin(), parent.end(), 0); + auto find = [&parent](size_t i) { + while (parent[i] != i) + i = parent[i] = parent[parent[i]]; + return i; + }; + size_t joins = 0; + for (const Arc &arc : order) { + const size_t end1 = end_index(arc.intersection), end2 = end_index(arc.intersection->next_on_contour); + if (consumed[end1] || consumed[end2] || (params.multiline > 1 && arc.arc_length < scale_(spacing) * params.multiline)) + continue; + const size_t polyline1 = find(end1 / 2), polyline2 = find(end2 / 2); + if (polyline1 == polyline2) + continue; + if (arc.arc_length < join_length) { + parent[polyline2] = polyline1; + ++ joins; + } else if (anchor_length <= SCALED_EPSILON) + continue; + consumed[end1] = consumed[end2] = 1; + } + // The last pass joins the open ends next to each other, also those of a multiline group. + for (size_t end = 0; end < consumed.size(); ++ end) { + const ContourIntersectionPoint &cp = graph.map_infill_end_point_to_boundary[end]; + if (consumed[end] || cp.contour_idx == boundary_idx_unconnected) + continue; + const double length = graph.boundary_params[cp.contour_idx].back(); + auto open = [&](const ContourIntersectionPoint *other, bool trimmed) { + return other != &cp && ! consumed[end_index(other)] && ! trimmed; + }; + const double lprev = open(cp.prev_on_contour, cp.prev_trimmed || cp.prev_on_contour->next_trimmed) ? + path_length_along_contour_ccw(cp.prev_on_contour, &cp, length) : std::numeric_limits::max(); + const double lnext = open(cp.next_on_contour, cp.next_trimmed || cp.next_on_contour->prev_trimmed) ? + path_length_along_contour_ccw(&cp, cp.next_on_contour, length) : std::numeric_limits::max(); + consumed[end] = anchor_length > SCALED_EPSILON; + for (const double l : { std::min(lprev, lnext), std::max(lprev, lnext) }) { + if (l > join_length) + break; + const size_t other = end_index(l == lprev ? cp.prev_on_contour : cp.next_on_contour); + const size_t polyline1 = find(end / 2), polyline2 = find(other / 2); + if (polyline1 == polyline2) + continue; + parent[polyline2] = polyline1; + consumed[end] = consumed[other] = 1; + ++ joins; + break; + } + } + return joins; + }; + // Equal arcs taken along the contour pair a wall in one phase; keep the order that joins the most lines. + size_t best_joins = count_joins(arches); + const double bucket = 0.5 * scale_(spacing); + for (const bool backwards : { false, true }) { + std::vector along_contour = arches; + std::sort(along_contour.begin(), along_contour.end(), [bucket, backwards](const Arc &l, const Arc &r) { + const long long bucket_l = std::llround(l.arc_length / bucket), bucket_r = std::llround(r.arc_length / bucket); + if (bucket_l != bucket_r) + return bucket_l < bucket_r; + if (l.intersection->contour_idx != r.intersection->contour_idx) + return l.intersection->contour_idx < r.intersection->contour_idx; + return backwards ? l.intersection->param > r.intersection->param : l.intersection->param < r.intersection->param; + }); + if (const size_t joins = count_joins(along_contour); joins > best_joins) { + best_joins = joins; + arches = std::move(along_contour); + } + } } //FIXME improve the Traveling Salesman problem with 2-opt and 3-opt local optimization. @@ -1752,7 +1840,7 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vectorpoint_idx] == polyline1.points.front() || contour[cp1->point_idx] == polyline1.points.back()); if (contour[cp1->point_idx] == polyline1.points.front()) @@ -1761,6 +1849,7 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vectorpoint_idx] == polyline2.points.back()) polyline2.reverse(); take(polyline1, polyline2, contour, cp1, cp2, false); + open_end[end_index(cp1)] = open_end[end_index(cp2)] = 0; // Mark the second polygon as merged with the first one. if (polyline_idx2 < polyline_idx1) { polyline2 = std::move(polyline1); @@ -1778,6 +1867,23 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vectorprev_on_contour : cp->next_on_contour; + if (other == nullptr || other == &from) + break; + distance += backwards ? closed_contour_distance_cw(cp->param, other->param, length) : closed_contour_distance_ccw(cp->param, other->param, length); + if (open_end[end_index(other)] && get_and_update_merged_with(end_index(other) / 2) != polyline_idx) + return distance; + cp = other; + } + return std::numeric_limits::max(); + }; + // Connect the remaining open infill lines to the perimeter lines if possible. for (ContourIntersectionPoint &contour_point : graph.map_infill_end_point_to_boundary) if (! contour_point.consumed && contour_point.contour_idx != boundary_idx_unconnected) { @@ -1796,7 +1902,7 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vector::max() || l > anchor_length_max) + if (l == std::numeric_limits::max() || l > join_length) break; // Take the complete contour. bool reversed = l == lprev; @@ -1815,6 +1921,7 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vectorpoint_idx] == polyline2.points.back()) polyline2.reverse(); take(polyline, polyline2, contour, &contour_point, cp2, reversed); + open_end[end_index(&contour_point)] = open_end[end_index(cp2)] = 0; if (polyline_idx < polyline_idx2) { // Mark the second polyline as merged with the first one. merged_with[polyline_idx2] = polyline_idx; @@ -1829,18 +1936,35 @@ void Fill::connect_infill(Polylines &&infill_ordered, const std::vector SCALED_EPSILON) { - // Which to take? One could optimize for: - // 1) Shortest path - // 2) Hook length - // ... - // Let's take the longer now, as this improves the chance of another hook to be placed on the other side of this contour point. - double l = std::max(contour_point.contour_not_taken_length_prev, contour_point.contour_not_taken_length_next); - if (l > SCALED_EPSILON) { - if (contour_point.contour_not_taken_length_prev > contour_point.contour_not_taken_length_next) - take_limited(polyline, contour, contour_params, &contour_point, contour_point.prev_on_contour, true, anchor_length, line_half_width); - else - take_limited(polyline, contour, contour_params, &contour_point, contour_point.next_on_contour, false, anchor_length, line_half_width); + // Hook toward the nearest open end of another polyline, where the next path likely starts. + const double free_prev = contour_point.contour_not_taken_length_prev; + const double free_next = contour_point.contour_not_taken_length_next; + const double to_prev = distance_to_open_end(contour_point, polyline_idx, true); + const double to_next = distance_to_open_end(contour_point, polyline_idx, false); + bool backwards = free_prev > free_next; + if (to_prev < to_next && free_prev > SCALED_EPSILON) + backwards = true; + else if (to_next < to_prev && free_next > SCALED_EPSILON) + backwards = false; + // Orca: with multiline, a hook toward a copy stops short of it; the copy takes half a width over the sine of its wall angle. + double length = anchor_length; + if (const ContourIntersectionPoint *other = backwards ? contour_point.prev_on_contour : contour_point.next_on_contour; + params.multiline > 1 && other != &contour_point) { + const Point &end = contour[contour_point.point_idx]; + Vec2d dir = Vec2d::Zero(); + for (size_t i = 1; i < polyline.size() && dir.squaredNorm() < sqr(2. * scale_(spacing) * params.multiline); ++ i) + dir = (end - polyline.points[polyline.points.front() == end ? i : polyline.size() - 1 - i]).cast(); + const Vec2d to_copy = (contour[other->point_idx] - end).cast(); + const double across = dir.squaredNorm() > 0. ? std::abs(cross2(dir.normalized(), to_copy)) : 0.; + if (across > SCALED_EPSILON && across < scale_(spacing) * params.multiline) { + const double gap = backwards ? path_length_along_contour_ccw(other, &contour_point, contour_params.back()) : + path_length_along_contour_ccw(&contour_point, other, contour_params.back()); + length = std::min(length, gap - line_half_width * (1. + to_copy.norm() / across)); + } } + if ((backwards ? free_prev : free_next) > SCALED_EPSILON) + take_limited(polyline, contour, contour_params, &contour_point, backwards ? contour_point.prev_on_contour : contour_point.next_on_contour, + backwards, std::max(0., length), line_half_width); } } @@ -2798,4 +2922,123 @@ void multiline_fill(Polylines& polylines, const FillParams& params, float spacin polylines = std::move(all_polylines); } +void join_close_ends(Polylines &polylines, double max_gap, bool can_reverse) +{ + const size_t n = polylines.size(); + if (n < 2 || max_gap <= 0.) + return; + static constexpr size_t npos = size_t(-1); + // End 2 * i is the first point of polyline i, 2 * i + 1 its last. + auto end_point = [&polylines](size_t e) -> const Point & { return (e & 1) ? polylines[e >> 1].points.back() : polylines[e >> 1].points.front(); }; + // Ends in cells of max_gap, so the ends within max_gap of one are in its cell or the eight around it. + auto cell_of = [max_gap](const Point &p) { return Vec2i64(int64_t(std::floor(p.x() / max_gap)), int64_t(std::floor(p.y() / max_gap))); }; + auto key = [](const Vec2i64 &c) { return (uint64_t(c.x()) << 32) ^ uint64_t(uint32_t(c.y())); }; + std::unordered_map> grid; + for (size_t e = 0; e < 2 * n; ++ e) + if (polylines[e >> 1].size() >= 2) + grid[key(cell_of(end_point(e)))].push_back(e); + + // The end of a polyline that comes last along its overall direction, taken along the dominant axis. + std::vector backwards(n, 0); + for (size_t i = 0; i < n; ++ i) + if (polylines[i].size() >= 2) { + const Vec2d dir = (polylines[i].points.back() - polylines[i].points.front()).cast(); + backwards[i] = std::abs(dir.x()) >= std::abs(dir.y()) ? dir.x() < 0. : dir.y() < 0.; + } + auto trailing = [&backwards](size_t e) { return bool(e & 1) != bool(backwards[e >> 1]); }; + // Ends that meet first, then shortest gaps between leading ends, then the others: the copies of neighboring multiline + // groups are all joined at the same side, which leaves their open ends next to each other rather than on alternating walls. + std::vector> gaps; + for (const auto &[cell_key, ends] : grid) { + const Vec2i64 c = cell_of(end_point(ends.front())); + for (int64_t dx = -1; dx <= 1; ++ dx) + for (int64_t dy = -1; dy <= 1; ++ dy) + if (auto it = grid.find(key(c + Vec2i64(dx, dy))); it != grid.end()) + for (size_t a : ends) + for (size_t b : it->second) + if (a < b && (a >> 1) != (b >> 1) && (can_reverse || (a & 1) != (b & 1))) + if (const double d = (end_point(a) - end_point(b)).cast().norm(); d <= max_gap) + gaps.emplace_back(d > SCALED_EPSILON, d > SCALED_EPSILON && (trailing(a) || trailing(b)), d, a, b); + } + if (gaps.empty()) + return; + std::sort(gaps.begin(), gaps.end()); + + std::vector partner(2 * n, npos), parent(n); + std::iota(parent.begin(), parent.end(), 0); + auto find = [&parent](size_t i) { + while (parent[i] != i) + i = parent[i] = parent[parent[i]]; + return i; + }; + // A join may not cross a line or another join; touching them at its own ends is fine, as where a line ends on another. + // Only the segments through a cell next to a candidate join can cross one. + std::unordered_set near_gaps; + for (const auto &[apart, trail, d, a, b] : gaps) + for (size_t e : { a, b }) + for (int64_t dx = -1; dx <= 1; ++ dx) + for (int64_t dy = -1; dy <= 1; ++ dy) + near_gaps.insert(key(cell_of(end_point(e)) + Vec2i64(dx, dy))); + Lines near_lines; + for (const Polyline &pl : polylines) + for (size_t i = 1; i < pl.size(); ++ i) { + const Vec2i64 c0 = cell_of(pl.points[i - 1]), c1 = cell_of(pl.points[i]); + const Vec2i64 lo = c0.cwiseMin(c1), hi = c0.cwiseMax(c1); + bool near = (hi - lo).maxCoeff() > 3; + for (int64_t x = lo.x(); x <= hi.x() && ! near; ++ x) + for (int64_t y = lo.y(); y <= hi.y() && ! near; ++ y) + near = near_gaps.count(key(Vec2i64(x, y))) > 0; + if (near) + near_lines.emplace_back(pl.points[i - 1], pl.points[i]); + } + const AABBTreeLines::LinesDistancer lines(std::move(near_lines)); + auto crosses = [&](size_t a, size_t b) { + const Vec2d p = end_point(a).cast(), q = end_point(b).cast(); + const double length = (q - p).norm(); + if (length <= 2. * SCALED_EPSILON) + return false; + const Vec2d dir = (q - p) / length; + const Line inner(Point((p + SCALED_EPSILON * dir).cast()), Point((q - SCALED_EPSILON * dir).cast())); + if (! lines.get_lines().empty() && ! lines.intersections_with_line(inner).empty()) + return true; + const Vec2i64 c = cell_of(end_point(a)); + for (int64_t dx = -3; dx <= 3; ++ dx) + for (int64_t dy = -3; dy <= 3; ++ dy) + if (auto it = grid.find(key(c + Vec2i64(dx, dy))); it != grid.end()) + for (size_t e : it->second) + if (partner[e] != npos && Geometry::segments_intersect(inner.a, inner.b, end_point(e), end_point(partner[e]))) + return true; + return false; + }; + for (const auto &[apart, trail, d, a, b] : gaps) + if (partner[a] == npos && partner[b] == npos && find(a >> 1) != find(b >> 1) && ! crosses(a, b)) { + partner[a] = b; + partner[b] = a; + parent[find(a >> 1)] = find(b >> 1); + } + + // Walk each group from a polyline with a free end, entering the next polyline at the end joined to the last one. + Polylines out; + std::vector done(n, 0); + auto walk = [&](size_t first_end) { + Polyline joined; + for (size_t e = first_end; e != npos;) { + Polyline &pl = polylines[e >> 1]; + done[e >> 1] = 1; + if (e & 1) + pl.reverse(); + joined.points.insert(joined.points.end(), pl.points.begin() + (! joined.empty() && joined.points.back() == pl.points.front()), pl.points.end()); + e = partner[e ^ 1]; + } + out.emplace_back(std::move(joined)); + }; + for (size_t i = 0; i < n; ++ i) + if (! done[i] && partner[2 * i] == npos) + walk(2 * i); + for (size_t i = 0; i < n; ++ i) + if (! done[i] && partner[2 * i + 1] == npos) + walk(2 * i + 1); + polylines = std::move(out); +} + } // namespace Slic3r diff --git a/src/libslic3r/Fill/FillBase.hpp b/src/libslic3r/Fill/FillBase.hpp index d1f07af77c..f60fde6d1d 100644 --- a/src/libslic3r/Fill/FillBase.hpp +++ b/src/libslic3r/Fill/FillBase.hpp @@ -258,8 +258,11 @@ public: static coord_t _adjust_solid_spacing(const coord_t width, const coord_t distance); }; - //Fill Multiline + //Fill Multiline void multiline_fill(Polylines& polylines, const FillParams& params, float spacing); + // Joins polylines whose ends are at most max_gap apart, shortest gaps first and without closing a loop, so each such + // group prints as one path. Without can_reverse only the end of one polyline joins the start of another. + void join_close_ends(Polylines &polylines, double max_gap, bool can_reverse); } // namespace Slic3r #endif // slic3r_FillBase_hpp_ diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 1500483f6e..88fef3bb35 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6660,10 +6660,6 @@ LayerResult GCode::process_layer( case CalibMode::Calib_Input_shaping_freq: { if (m_layer_index == 1){ gcode += writer().set_input_shaping('A', print.calib_params().start, 0.f, print.calib_params().shaper_type); - if (m_writer.get_gcode_flavor() == gcfKlipper) { - // Disable minimum cruise ratio to ensure consistent motion for calibration - gcode += "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=0\n"; - } } else { if (print.calib_params().freqStartX == print.calib_params().freqStartY && print.calib_params().freqEndX == print.calib_params().freqEndY) { gcode += writer().set_input_shaping('A', 0.f, this->interpolate_value_across_layers(print.calib_params().freqStartX, print.calib_params().freqEndX), ""); @@ -6676,10 +6672,6 @@ LayerResult GCode::process_layer( } case CalibMode::Calib_Input_shaping_damp: { if (m_layer_index == 1){ - if (m_writer.get_gcode_flavor() == gcfKlipper) { - // Disable minimum cruise ratio to ensure consistent motion for calibration - gcode += "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=0\n"; - } gcode += writer().set_input_shaping('X', 0.f, print.calib_params().freqStartX, print.calib_params().shaper_type); gcode += writer().set_input_shaping('Y', 0.f, print.calib_params().freqStartY, print.calib_params().shaper_type); } else { diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 6346651448..3512df6ab8 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -378,6 +379,11 @@ void GCodeProcessor::TimeMachine::reset() travel_acceleration = 0.0f; max_travel_acceleration = 0.0f; extrude_factor_override_percentage = 1.0f; + klipper = false; + minimum_cruise_ratio = 0.5f; + requested_accel_to_decel = -1.0f; + klipper_junction_flush = 1.0f; + klipper_queue_priming = true; time = 0.0f; stop_times = std::vector(); curr.reset(); @@ -493,16 +499,87 @@ GCodeProcessor::TimeMachine::AdditionalBuffer GCodeProcessor::TimeMachine::merge return merged; } +// Klipper toolhead.py LookAheadQueue.flush: the pseudo-acceleration pass +// reserves cruising distance across a sequence, not separately in each segment. +size_t GCodeProcessor::TimeMachine::plan_klipper(bool lazy) +{ + klipper_junction_flush = requested_accel_to_decel >= 0.0f ? 0.250f : 0.150f; + klipper_queue_priming = false; + size_t flush_count = blocks.size(); + bool update_flush_count = lazy; + float next_start_v2 = 0.0f; + float next_mcr_start_v2 = 0.0f; + float peak_cruise_v2 = 0.0f; + size_t pending = 0; + for (size_t i = blocks.size(); i-- > 0;) { + TimeBlock& block = blocks[i]; + const float delta_v2 = 2.0f * block.distance * block.acceleration; + const float reachable_start_v2 = next_start_v2 + delta_v2; + const float start_v2 = std::min(sqr(block.max_entry_speed), reachable_start_v2); + const float reachable_mcr_v2 = next_mcr_start_v2 + block.mcr_delta_v2; + const float mcr_start_v2 = std::min(block.max_mcr_entry_speed_sqr, reachable_mcr_v2); + float cruise_v2 = -1.0f; + ++pending; + if (mcr_start_v2 < reachable_mcr_v2) { + if (mcr_start_v2 + block.mcr_delta_v2 > next_mcr_start_v2 || pending > 1) { + // Stop at this block, not past it: the blocks above still have a cruise speed + // that depends on moves not yet seen, so only [0, i) may be committed. + if (update_flush_count && peak_cruise_v2 > 0.0f) { + flush_count = i; + update_flush_count = false; + } + peak_cruise_v2 = std::min(sqr(block.feedrate_profile.cruise), + 0.5f * (mcr_start_v2 + reachable_mcr_v2)); + } + cruise_v2 = std::min({0.5f * (start_v2 + reachable_start_v2), + sqr(block.feedrate_profile.cruise), peak_cruise_v2}); + pending = 0; + } + // Until the forward pass, these fields hold v^2, not speeds; -1 marks + // an unassigned cruise speed. A lazy return leaves them in this state. + block.feedrate_profile.entry = start_v2; + block.feedrate_profile.exit = next_start_v2; + block.trapezoid.cruise_feedrate = cruise_v2; + block.peak_cruise_v2 = peak_cruise_v2; + next_start_v2 = start_v2; + next_mcr_start_v2 = mcr_start_v2; + } + if (update_flush_count) + return 0; + // A run of blocks that cannot accelerate is resolved here, once the peak cruise speed of the + // accelerating block that precedes it is known. Each one takes a running minimum with its own + // entry speed, seeded from that peak rather than from the setter's own clamped cruise speed. + float prev_cruise_v2 = 0.0f; + for (size_t i = 0; i < flush_count; ++i) { + TimeBlock& block = blocks[i]; + float cruise_v2 = block.trapezoid.cruise_feedrate; + if (cruise_v2 < 0.0f) { + cruise_v2 = std::min(prev_cruise_v2, block.feedrate_profile.entry); + prev_cruise_v2 = cruise_v2; + } else + prev_cruise_v2 = block.peak_cruise_v2; + const float start_v2 = std::min(block.feedrate_profile.entry, cruise_v2); + const float end_v2 = std::min(block.feedrate_profile.exit, cruise_v2); + // A zero acceleration would put an infinity in the ramp distances; the Marlin helpers guard + // their divides the same way and fall back to a pure cruise. + const float inv_two_accel = block.acceleration > 0.0f ? 0.5f / block.acceleration : 0.0f; + block.feedrate_profile.entry = std::sqrt(start_v2); + block.feedrate_profile.exit = std::sqrt(end_v2); + block.trapezoid.cruise_feedrate = std::sqrt(cruise_v2); + block.trapezoid.accelerate_until = (cruise_v2 - start_v2) * inv_two_accel; + block.trapezoid.decelerate_after = block.distance - (cruise_v2 - end_v2) * inv_two_accel; + } + return flush_count; +} + void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, PrintEstimatedStatistics::ETimeMode mode, size_t keep_last_n_blocks, float additional_time, EMoveType target_move_type, bool is_final) { if (!enabled) return; - // Orca: on the finalization pass, drain extra time that is still buffered (e.g. a - // trailing filament change) even with fewer than two blocks queued -- no later pass - // exists to attribute it on. Every other pass keeps the original >= 2 requirement, - // and an empty buffer keeps today's early-return unchanged (no behavior change). + // Drain buffered delays on the final pass, even with no motion left. + // A trailing filament change has no later move to receive its time. const bool drain_final = is_final && !m_additional_time_buffer.empty(); - if (blocks.size() < 2 && !drain_final) { + if ((klipper ? blocks.empty() : blocks.size() < 2) && !drain_final) { // Not enough blocks to attribute the extra time to yet; buffer it so it is // applied on a later pass instead of being dropped. if (additional_time > 0.0f) @@ -510,7 +587,7 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P return; } - assert(keep_last_n_blocks <= blocks.size()); + assert(klipper || keep_last_n_blocks <= blocks.size()); // Merge any previously buffered extra time with this call's extra time. Each // entry is applied, in order, to the first block matching its target move type @@ -520,23 +597,36 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P additional_buffer.emplace_back(target_move_type, additional_time); additional_buffer = merge_adjacent_additional_time_blocks(additional_buffer); - // reverse_pass - for (int i = static_cast(blocks.size()) - 1; i > 0; --i) { - planner_reverse_pass_kernel(blocks[i - 1], blocks[i]); + size_t n_blocks_process; + if (klipper) { + n_blocks_process = plan_klipper(keep_last_n_blocks != 0); + keep_last_n_blocks = blocks.size() - n_blocks_process; + } else { + for (int i = static_cast(blocks.size()) - 1; i > 0; --i) + planner_reverse_pass_kernel(blocks[i - 1], blocks[i]); + for (size_t i = 0; i + 1 < blocks.size(); ++i) + planner_forward_pass_kernel(blocks[i], blocks[i + 1]); + recalculate_trapezoids(blocks); + n_blocks_process = blocks.size() - keep_last_n_blocks; } - - // forward_pass - for (size_t i = 0; i + 1 < blocks.size(); ++i) { - planner_forward_pass_kernel(blocks[i], blocks[i + 1]); - } - - recalculate_trapezoids(blocks); - - const size_t n_blocks_process = blocks.size() - keep_last_n_blocks; size_t additional_buffer_idx = 0; for (size_t i = 0; i < n_blocks_process; ++i) { const TimeBlock& block = blocks[i]; - float block_time = block.time(); + double motion_time; + if (klipper && block.trapezoid.cruise_feedrate > 0.0f) { + // Integrate using the planned speeds. Reconstructing the exit speed + // from rounded deceleration distance loses precision near a stop. + const double cruise = block.trapezoid.cruise_feedrate; + motion_time = block.distance / cruise; + if (block.acceleration > 0.0f) { + const double accel_delta = cruise - block.feedrate_profile.entry; + const double decel_delta = cruise - block.feedrate_profile.exit; + motion_time += (sqr(accel_delta) + sqr(decel_delta)) / (2.0 * block.acceleration * cruise); + } + } else { + motion_time = block.time(); + } + double block_time = motion_time; if (additional_buffer_idx < additional_buffer.size()) { const EMoveType buf_move_type = additional_buffer[additional_buffer_idx].first; if (buf_move_type == EMoveType::Noop || buf_move_type == block.move_type) { @@ -545,13 +635,13 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P } } - time += double(block_time); + time += block_time; // Orca: accumulate per-SkipType time spent inside SKIPPABLE regions, folded into this single // calculate_time write site. Fires fleet-wide (the shipping time_lapse_gcode template stamps // blocks stTimelapse), but writes only skippable_part_time, which has no g-code-emitting // reader until the pre-heat injector consumes it, so output is byte-identical. if (block.skippable_type != SkipType::stNone) - result.skippable_part_time[block.skippable_type] += block.time(); + result.skippable_part_time[block.skippable_type] += motion_time; result.moves[block.move_id].time[static_cast(mode)] = block_time; gcode_time.cache += block_time; //BBS @@ -689,14 +779,12 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P if (keep_last_n_blocks) { blocks.erase(blocks.begin(), blocks.begin() + n_blocks_process); - // Ensure that the new first block's entry speed will be preserved to prevent discontinuity - // between the erased blocks' exit speed and the new first block's entry speed. - // Otherwise, the first block's entry speed could be recalculated on the next pass without - // considering that there are no more blocks before this first block. This could lead - // to discontinuity between the exit speed (of already processed blocks) and the entry - // speed of the first block. - TimeBlock &first_block = blocks.front(); - first_block.max_entry_speed = first_block.feedrate_profile.entry; + // Preserve the boundary speed in the Marlin planner. Klipper only flushes + // moves whose cruise speed is already independent of future moves. + if (!klipper) { + TimeBlock& first_block = blocks.front(); + first_block.max_entry_speed = first_block.feedrate_profile.entry; + } } else { blocks.clear(); } @@ -3872,9 +3960,9 @@ void GCodeProcessor::finalize(bool post_process) update_slice_warnings(); } -float GCodeProcessor::get_time(PrintEstimatedStatistics::ETimeMode mode) const +double GCodeProcessor::get_time(PrintEstimatedStatistics::ETimeMode mode) const { - return (mode < PrintEstimatedStatistics::ETimeMode::Count) ? float(m_time_processor.machines[static_cast(mode)].time) : 0.0f; + return (mode < PrintEstimatedStatistics::ETimeMode::Count) ? m_time_processor.machines[static_cast(mode)].time : 0.0; } float GCodeProcessor::get_prepare_time(PrintEstimatedStatistics::ETimeMode mode) const @@ -5031,6 +5119,14 @@ void GCodeProcessor::process_G1(const GCodeReader::GCodeLine& line, const std::o if (line.has_e()) g1_axes[E] = (double)line.e(); std::optional g1_feedrate = std::nullopt; if (line.has_f()) g1_feedrate = (double)line.f(); + if (m_flavor == gcfKlipper) { + // Preserve decimal coordinates for short segments without parsing twice. + for (unsigned char axis = X; axis <= E; ++axis) + if (line.has(static_cast(axis))) + g1_axes[axis] = line.precise_value(static_cast(axis)); + if (line.has_f()) + g1_feedrate = line.precise_value(F); + } process_G1(g1_axes, g1_feedrate); } @@ -5228,7 +5324,7 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes //BBS: limite the cruise according to centripetal acceleration //Only need to handle when both prev and curr segment has movement in x-y plane - if ((prev.exit_direction(0) != 0.0f || prev.exit_direction(1) != 0.0f) && + if (m_flavor != gcfKlipper && (prev.exit_direction(0) != 0.0f || prev.exit_direction(1) != 0.0f) && (curr.enter_direction(0) != 0.0f || curr.enter_direction(1) != 0.0f)) { Vec3f v1 = prev.exit_direction; v1(2, 0) = 0.0f; @@ -5252,6 +5348,15 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes } } + if (m_flavor == gcfKlipper && !is_extrusion_only_move(delta_pos)) { + const float max_velocity = get_axis_max_feedrate(static_cast(i), X, m_machine_config_idx); + if (max_velocity > 0.0f) + curr.feedrate = std::min(curr.feedrate, max_velocity); + } + // Klipper applies E limits to retractions and moves without XY motion, + // not to positive extrusion along a printing path. + const bool limit_extruder = m_flavor != gcfKlipper || delta_pos[E] < 0.0 || + (delta_pos[X] == 0.0 && delta_pos[Y] == 0.0); // calculates block cruise feedrate float min_feedrate_factor = 1.0f; for (unsigned char a = X; a <= E; ++a) { @@ -5260,7 +5365,8 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes curr.axis_feedrate[a] *= machine.extrude_factor_override_percentage; curr.abs_axis_feedrate[a] = std::abs(curr.axis_feedrate[a]); - if (curr.abs_axis_feedrate[a] != 0.0f) { + if (curr.abs_axis_feedrate[a] != 0.0f && (a != E || limit_extruder) && + (m_flavor != gcfKlipper || a == Z || a == E)) { float axis_max_feedrate = get_axis_max_feedrate(static_cast(i), static_cast(a), m_machine_config_idx); if (axis_max_feedrate != 0.0f) min_feedrate_factor = std::min(min_feedrate_factor, axis_max_feedrate / curr.abs_axis_feedrate[a]); } @@ -5277,20 +5383,42 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes } // calculates block acceleration - float acceleration = - (type == EMoveType::Travel) ? get_travel_acceleration(static_cast(i)) : - (is_extrusion_only_move(delta_pos) ? - get_retract_acceleration(static_cast(i)) : - get_acceleration(static_cast(i))); + float acceleration; + if (m_flavor == gcfKlipper && is_extrusion_only_move(delta_pos)) { + // Klipper's max_extrude_only_accel. machine_max_acceleration_e is optional and some + // shipped profiles leave it at 0, so keep the non-zero retract fallback. + acceleration = get_axis_max_acceleration(static_cast(i), E, m_machine_config_idx); + if (acceleration <= 0.0f) + acceleration = get_retract_acceleration(static_cast(i)); + } else + acceleration = + (m_flavor == gcfKlipper) ? get_acceleration(static_cast(i)) : + (type == EMoveType::Travel) ? get_travel_acceleration(static_cast(i)) : + (is_extrusion_only_move(delta_pos) ? + get_retract_acceleration(static_cast(i)) : + get_acceleration(static_cast(i))); //BBS for (unsigned char a = X; a <= E; ++a) { + if ((a == E && !limit_extruder) || (m_flavor == gcfKlipper && (a == X || a == Y))) + continue; float axis_max_acceleration = get_axis_max_acceleration(static_cast(i), static_cast(a), m_machine_config_idx); + // An unset (0) limit means "no limit", as in the feedrate loop above. Only the Klipper + // planner divides by the block acceleration, so leave the Marlin path as it was. + if (m_flavor == gcfKlipper && axis_max_acceleration <= 0.0f) + continue; if (acceleration * std::abs(delta_pos[a]) * inv_distance > axis_max_acceleration) acceleration = axis_max_acceleration / (std::abs(delta_pos[a]) * inv_distance); } block.acceleration = acceleration; + if (m_flavor == gcfKlipper) { + block.junction_deviation = get_junction_deviation(static_cast(i), acceleration); + const float cruise_acceleration = machine.requested_accel_to_decel >= 0.0f ? + std::min(machine.acceleration, machine.requested_accel_to_decel) : + machine.acceleration * (1.0f - machine.minimum_cruise_ratio); + block.mcr_delta_v2 = 2.0f * distance * std::min(acceleration, cruise_acceleration); + } static const float PREVIOUS_FEEDRATE_THRESHOLD = 0.0001f; const bool has_prev_move = !blocks.empty() && prev.feedrate > PREVIOUS_FEEDRATE_THRESHOLD; @@ -5401,6 +5529,12 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes block.feedrate_profile.entry = std::min(vmax_junction, v_allowable); block.max_entry_speed = vmax_junction; + if (m_flavor == gcfKlipper && has_prev_move) { + const TimeBlock& previous = blocks.back(); + block.max_entry_speed = std::min(block.max_entry_speed, + std::sqrt(sqr(previous.max_entry_speed) + 2.0f * previous.distance * previous.acceleration)); + block.max_mcr_entry_speed_sqr = std::min(sqr(block.max_entry_speed), previous.max_mcr_entry_speed_sqr + previous.mcr_delta_v2); + } block.flags.nominal_length = (block.feedrate_profile.cruise <= v_allowable); block.flags.recalculate = true; block.safe_feedrate = curr.safe_feedrate; @@ -5412,9 +5546,12 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes prev = curr; blocks.push_back(block); + if (m_flavor == gcfKlipper && blocks.size() > 1) + machine.klipper_junction_flush -= distance / curr.feedrate; } - if (m_time_processor.machines[0].blocks.size() > TimeProcessor::Planner::refresh_threshold) + if (m_flavor == gcfKlipper ? m_time_processor.machines[0].klipper_junction_flush <= 0.0f : + m_time_processor.machines[0].blocks.size() > TimeProcessor::Planner::refresh_threshold) calculate_time(m_result, TimeProcessor::Planner::queue_size); const Vec3f plate_offset = {(float) m_x_offset, (float) m_y_offset, 0.0f}; @@ -6341,6 +6478,8 @@ void GCodeProcessor::process_M108(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_M109(const GCodeReader::GCodeLine& line) { + if (m_flavor == gcfKlipper) + simulate_st_synchronize(); int filament_id = get_filament_id(); float new_temp; if (line.has_value('R', new_temp)) { @@ -6403,6 +6542,8 @@ void GCodeProcessor::process_M140(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_M190(const GCodeReader::GCodeLine& line) { + if (m_flavor == gcfKlipper) + simulate_st_synchronize(); float new_temp; if (line.has_value('S', new_temp)) m_highest_bed_temp = m_highest_bed_temp < (int)new_temp ? (int)new_temp : m_highest_bed_temp; @@ -6480,6 +6621,21 @@ void GCodeProcessor::process_M203(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_M204(const GCodeReader::GCodeLine& line) { float value; + if (m_flavor == gcfKlipper) { + if (!line.has_value('S', value)) { + float travel; + if (!line.has_value('P', value) || !line.has_value('T', travel)) + return; + value = std::min(value, travel); + } + // Klipper replaces its single live max_accel for print and travel; the profile-clamping + // setters would diverge from the firmware when custom G-code raises the limit. + if (value > 0.0f) { + for (auto& machine : m_time_processor.machines) + machine.acceleration = machine.travel_acceleration = value; + } + return; + } for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { if (static_cast(i) == PrintEstimatedStatistics::ETimeMode::Normal || m_time_processor.machine_envelope_processing_enabled) { @@ -6541,50 +6697,50 @@ void GCodeProcessor::process_M205(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_SET_VELOCITY_LIMIT(const GCodeReader::GCodeLine& line) { - // handle SQUARE_CORNER_VELOCITY - static const std::regex square_corner_velocity_pattern("\\sSQUARE_CORNER_VELOCITY\\s*=\\s*([0-9]*\\.*[0-9]*)"); - std::smatch matches; - if (std::regex_search(line.raw(), matches, square_corner_velocity_pattern) && matches.size() == 2) { - float _jerk = 0; - try - { - _jerk = std::stof(matches[1]); + static const std::regex parameter_pattern( + R"(\s(VELOCITY|ACCEL|ACCEL_TO_DECEL|SQUARE_CORNER_VELOCITY|MINIMUM_CRUISE_RATIO)\s*=\s*([+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)(?=\s|;|$))", + std::regex::icase); + const std::string command = line.raw().substr(0, line.raw().find(';')); + std::vector> parameters; + for (std::sregex_iterator it(command.begin(), command.end(), parameter_pattern), end; it != end; ++it) { + float value; + try { + value = std::stof((*it)[2].str()); + } catch (const std::exception&) { + continue; } - catch (...){} - for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { - set_option_value(m_time_processor.machine_limits.machine_max_jerk_x, i, _jerk); - set_option_value(m_time_processor.machine_limits.machine_max_jerk_y, i, _jerk); + if (std::isfinite(value)) + parameters.emplace_back(boost::to_upper_copy((*it)[1].str()), value); + } + // Klipper 0.13 converts ACCEL_TO_DECEL only when the line has no MINIMUM_CRUISE_RATIO, and later + // releases ignore it, so a line carrying both (as Orca writes) is planned with the cruise ratio. + const bool has_cruise_ratio = std::any_of(parameters.begin(), parameters.end(), + [](const auto& parameter) { return parameter.first == "MINIMUM_CRUISE_RATIO" && parameter.second >= 0.0f && parameter.second < 1.0f; }); + for (const auto& [name, value] : parameters) { + for (size_t i = 0; i < m_time_processor.machines.size(); ++i) { + TimeMachine& machine = m_time_processor.machines[i]; + // Klipper replaces its single live max_accel for print and travel; the profile-clamping + // setters would diverge from the firmware when custom G-code raises the limit. + if (name == "ACCEL" && value > 0.0f) + machine.acceleration = machine.travel_acceleration = value; + else if (name == "MINIMUM_CRUISE_RATIO" && value >= 0.0f && value < 1.0f) { + if (machine.klipper_queue_priming && machine.requested_accel_to_decel >= 0.0f) + machine.klipper_junction_flush -= 1.0f; + machine.minimum_cruise_ratio = value; + machine.requested_accel_to_decel = -1.0f; + } else if (name == "ACCEL_TO_DECEL" && value > 0.0f && !has_cruise_ratio) { + if (machine.klipper_queue_priming && machine.requested_accel_to_decel < 0.0f) + machine.klipper_junction_flush += 1.0f; + machine.requested_accel_to_decel = value; + } else if (name == "SQUARE_CORNER_VELOCITY" && value >= 0.0f) { + set_option_value(m_time_processor.machine_limits.machine_max_jerk_x, i, value); + set_option_value(m_time_processor.machine_limits.machine_max_jerk_y, i, value); + } else if (name == "VELOCITY" && value > 0.0f) { + set_option_value(m_time_processor.machine_limits.machine_max_speed_x, i, value); + set_option_value(m_time_processor.machine_limits.machine_max_speed_y, i, value); + } } } - - static const std::regex accel_pattern("\\sACCEL\\s*=\\s*([0-9]*\\.*[0-9]*)"); - if (std::regex_search(line.raw(), matches, accel_pattern) && matches.size() == 2) { - float _accl = 0; - try - { - _accl = std::stof(matches[1]); - } - catch (...) {} - for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { - set_acceleration(static_cast(i), _accl); - set_travel_acceleration(static_cast(i), _accl); - } - } - - static const std::regex velocity_pattern("\\sVELOCITY\\s*=\\s*([0-9]*\\.*[0-9]*)"); - if (std::regex_search(line.raw(), matches, velocity_pattern) && matches.size() == 2) { - float _speed = 0; - try - { - _speed = std::stof(matches[1]); - } - catch (...) {} - for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { - set_option_value(m_time_processor.machine_limits.machine_max_speed_x, i, _speed); - set_option_value(m_time_processor.machine_limits.machine_max_speed_y, i, _speed); - } - } - } void GCodeProcessor::process_M221(const GCodeReader::GCodeLine& line) @@ -6621,14 +6777,15 @@ void GCodeProcessor::process_M400(const GCodeReader::GCodeLine& line) { float value_s = 0.0; float value_p = 0.0; - if (line.has_value('S', value_s) || line.has_value('P', value_p)) { - value_s += value_p * 0.001; - // Skip post-print end-gcode dwells so they don't inflate the M73 estimate (see - // m_skip_end_gcode_delays). Only omits dwell time — no state is updated here. - if (m_skip_end_gcode_delays) - return; - simulate_st_synchronize(value_s); - } + const bool has_delay = line.has_value('S', value_s) || line.has_value('P', value_p); + if (m_flavor != gcfKlipper && !has_delay) + return; + value_s += value_p * 0.001; + // Skip post-print end-gcode dwells so they don't inflate the M73 estimate (see + // m_skip_end_gcode_delays). Only omits dwell time — no state is updated here. + if (m_skip_end_gcode_delays && has_delay) + return; + simulate_st_synchronize(value_s); } void GCodeProcessor::process_M401(const GCodeReader::GCodeLine& line) @@ -7460,12 +7617,12 @@ float GCodeProcessor::get_junction_deviation(PrintEstimatedStatistics::ETimeMode { const size_t id = static_cast(mode); - // Klipper has no classic jerk: jd = scv^2 * (sqrt(2) - 1) / max_accel - // (toolhead.py::_calc_junction_deviation). Passing the block acceleration back in makes it cancel - // in calc_vmax_junction_deviation(), leaving the identity v == scv at a 90 degree corner. + // Klipper derives junction deviation from the live toolhead acceleration, + // before a move is constrained by Z or extruder limits. if (m_flavor == gcfKlipper) { // machine_max_jerk_x holds the square corner velocity; process_SET_VELOCITY_LIMIT() writes it. const float scv = get_option_value(m_time_processor.machine_limits.machine_max_jerk_x, id); + acceleration = m_time_processor.machines[id].acceleration; if (scv <= 0.0f || acceleration <= 0.0f) return 0.0f; return sqr(scv) * (std::sqrt(2.0f) - 1.0f) / acceleration; @@ -7497,18 +7654,45 @@ float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const const TimeMachine::State& curr, bool has_prev_move, PrintEstimatedStatistics::ETimeMode mode) const { + if (m_flavor == gcfKlipper) { + if (!has_prev_move || curr.enter_direction.isZero() || prev.exit_direction.isZero()) + return 0.0f; + const TimeBlock& previous = m_time_processor.machines[static_cast(mode)].blocks.back(); + double limit = std::min(sqr(double(block.feedrate_profile.cruise)), sqr(double(prev.feedrate))); + const double extrude_ratio_change = std::abs(curr.axis_feedrate[E] / curr.feedrate - + prev.axis_feedrate[E] / prev.feedrate); + // Klipper's instantaneous_corner_velocity defaults to 1 mm/s. + // It is independent of machine_max_jerk_e; custom firmware values + // cannot be inferred from the slicer's jerk setting. + if (extrude_ratio_change > 0.0f) + limit = std::min(limit, sqr(1.0 / extrude_ratio_change)); + // Recover directions from the double-precision axis velocities: float + // unit vectors lose the angle between nearly collinear short segments. + const Vec3d prev_direction = Vec3d(prev.axis_feedrate[X], prev.axis_feedrate[Y], prev.axis_feedrate[Z]).normalized(); + const Vec3d curr_direction = Vec3d(curr.axis_feedrate[X], curr.axis_feedrate[Y], curr.axis_feedrate[Z]).normalized(); + const double cosine = std::clamp(-prev_direction.dot(curr_direction), -1.0, 1.0); + const double sin_half = std::sqrt(0.5 * (1.0 - cosine)); + const double cos_half = std::sqrt(0.5 * (1.0 + cosine)); + if (sin_half < 1.0 && cos_half > 0.0) { + const double radius = sin_half / (1.0 - sin_half); + const double half_tan = 0.5 * sin_half / cos_half; + limit = std::min({limit, + radius * block.junction_deviation * block.acceleration, + radius * previous.junction_deviation * previous.acceleration, + half_tan * block.distance * block.acceleration, + half_tan * previous.distance * previous.acceleration}); + } + return std::sqrt(limit); + } const float junction_deviation = get_junction_deviation(mode, block.acceleration); if (junction_deviation <= 0.0f) return -1.0f; // classic jerk machine, the caller keeps its own computation if (!has_prev_move) return 0.0f; // starts from rest, the planner raises this on the reverse pass - // -1 for a straight continuation, +1 for a full reversal. Half angle identity, no acos()/sin(). - // Both vectors are unit length over XYZE, so this really is a cosine: scaling by 1 / distance - // instead, as PrusaSlicer does, leaves an E term that makes extruding corners look straighter - // than they are. Marlin normalizes over XYZE for any extruding move (planner.cpp, esteps > 0) - // and Klipper keeps E out of the cosine entirely (toolhead.py::Move.calc_junction); both agree - // that the corner is planned by its geometry, and normalizing matches them to within 1e-5. + // Unit vectors over XYZE keep this a cosine. Scaling by 1 / XYZ distance + // leaves an E term that makes extruding corners appear straighter. + // Marlin normalizes over XYZE; the Klipper branch above uses XYZ only. float junction_cos_theta = (-prev.jd_unit_vec).dot(curr.jd_unit_vec); if (junction_cos_theta > 0.999999f) return 0.0f; // the path doubles back, the machine has to stop @@ -7524,8 +7708,8 @@ float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const float vmax_junction_sqr = (junction_acceleration * junction_deviation * sin_theta_d2) / (1.0f - sin_theta_d2); // Marlin's JD_HANDLE_SMALL_SEGMENTS: a short move through a shallow corner is treated as an arc and - // capped by the centripetal acceleration it needs. Klipper has no equivalent. - if (m_flavor != gcfKlipper && block.distance < 1.0f && junction_cos_theta < -0.7071067812f) { + // capped by the centripetal acceleration it needs. + if (block.distance < 1.0f && junction_cos_theta < -0.7071067812f) { // Fast acos(-t), max. error +-0.033rad. MinMax polynomial by W. Randolph Franklin: // https://wrf.ecse.rpi.edu/Research/Short_Notes/arcsin/onlyelem.html const float neg = junction_cos_theta < 0.0f ? -1.0f : 1.0f; @@ -7700,6 +7884,7 @@ void GCodeProcessor::calculate_time(GCodeProcessorResult& result, size_t keep_la std::vector actual_speed_moves; for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { TimeMachine& machine = m_time_processor.machines[i]; + machine.klipper = m_flavor == gcfKlipper; machine.calculate_time(m_result, static_cast(i), keep_last_n_blocks, additional_time, target_move_type, is_final); if (static_cast(i) == PrintEstimatedStatistics::ETimeMode::Normal) actual_speed_moves = std::move(machine.actual_speed_moves); @@ -7778,6 +7963,14 @@ void GCodeProcessor::calculate_time(GCodeProcessorResult& result, size_t keep_la void GCodeProcessor::simulate_st_synchronize(float additional_time, EMoveType target_move_type) { calculate_time(m_result, 0, additional_time, target_move_type); + if (m_flavor == gcfKlipper) { + // ToolHead overrides the normal look-ahead window with BUFFER_TIME_HIGH + // at startup and after synchronization: 2 s on legacy, 1 s on modern Klipper. + for (TimeMachine& machine : m_time_processor.machines) { + machine.klipper_junction_flush = machine.requested_accel_to_decel >= 0.0f ? 2.0f : 1.0f; + machine.klipper_queue_priming = true; + } + } } void GCodeProcessor::update_estimated_times_stats() @@ -7812,7 +8005,7 @@ double GCodeProcessor::extract_absolute_position_on_axis(Axis axis, const GCodeR is_relative |= (m_e_local_positioning_type == EPositioningType::Relative); const double lengthsScaleFactor = (m_units == EUnits::Inches) ? double(INCHES_TO_MM) : 1.0; - double ret = line.value(Slic3r::Axis(axis)) * lengthsScaleFactor; + double ret = line.precise_value(Slic3r::Axis(axis)) * lengthsScaleFactor; // if (axis == E && m_use_volumetric_e) // ret /= area_filament_cross_section; return is_relative ? m_start_position[axis] + ret : m_origin[axis] + ret; diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 72fb84bad0..b1fc911104 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -91,12 +91,12 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; struct Mode { - float time; + double time; float prepare_time; std::vector>> custom_gcode_times; void reset() { - time = 0.0f; + time = 0.0; prepare_time = 0.0f; custom_gcode_times.clear(); custom_gcode_times.shrink_to_fit(); @@ -703,6 +703,12 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; float acceleration{ 0.0f }; // mm/s^2 float max_entry_speed{ 0.0f }; // mm/s float safe_feedrate{ 0.0f }; // mm/s + float junction_deviation{ 0.0f }; + float max_mcr_entry_speed_sqr{ 0.0f }; + float mcr_delta_v2{ 0.0f }; + // Peak cruise speed squared in effect when this block was planned. Only set on a block + // that can accelerate; the delayed blocks after it are seeded from its value. + float peak_cruise_v2{ 0.0f }; Flags flags; FeedrateProfile feedrate_profile; Trapezoid trapezoid; @@ -782,6 +788,11 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; // hard limit for the travel acceleration, to which the firmware will clamp. float max_travel_acceleration; // mm/s^2 float extrude_factor_override_percentage; + bool klipper{ false }; + float minimum_cruise_ratio{ 0.5f }; + float requested_accel_to_decel{ -1.0f }; + float klipper_junction_flush{ 1.0f }; + bool klipper_queue_priming{ true }; // We accumulate total print time in doubles to reduce the loss of precision // while adding big floating numbers with small float numbers. double time; // s @@ -818,6 +829,8 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; // Merge adjacent buffer entries that target the same move type. static AdditionalBuffer merge_adjacent_additional_time_blocks(const AdditionalBuffer& buffer); + size_t plan_klipper(bool lazy); + // additional_time is attributed to the first block matching target_move_type // (EMoveType::Noop matches any block, i.e. the first processed block). void calculate_time(GCodeProcessorResult& result, PrintEstimatedStatistics::ETimeMode mode, size_t keep_last_n_blocks = 0, float additional_time = 0.0f, EMoveType target_move_type = EMoveType::Noop, bool is_final = false); @@ -1383,7 +1396,7 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; void process_buffer(const std::string& buffer); void finalize(bool post_process); - float get_time(PrintEstimatedStatistics::ETimeMode mode) const; + double get_time(PrintEstimatedStatistics::ETimeMode mode) const; float get_prepare_time(PrintEstimatedStatistics::ETimeMode mode) const; std::string get_time_dhm(PrintEstimatedStatistics::ETimeMode mode) const; std::vector>> get_custom_gcode_times(PrintEstimatedStatistics::ETimeMode mode, bool include_remaining) const; diff --git a/src/libslic3r/GCode/OrderingStrategies.cpp b/src/libslic3r/GCode/OrderingStrategies.cpp index 31b80e2697..4ac7ce6cb6 100644 --- a/src/libslic3r/GCode/OrderingStrategies.cpp +++ b/src/libslic3r/GCode/OrderingStrategies.cpp @@ -9,6 +9,7 @@ #include "libslic3r/libslic3r.h" #include +#include #include #include #include @@ -226,6 +227,41 @@ bool tsp_remove_crossings(std::vector& path, const Points& centers) return improved; } +std::vector tsp_exact_cycle(const Points& centers) +{ + const size_t n = centers.size(); + std::vector path(n); + std::iota(path.begin(), path.end(), 0); + if (n <= 3) return path; + // State mask * m + j: the points 1..m of mask visited after point 0, the last one j + 1. + const size_t m = n - 1; + assert(m <= 16); + std::vector cost((size_t(1) << m) * m, std::numeric_limits::max()); + std::vector from(cost.size(), size_t(-1)); + auto dist = [¢ers](size_t a, size_t b) { return (centers[a].cast() - centers[b].cast()).norm(); }; + for (size_t j = 0; j < m; ++j) + cost[(size_t(1) << j) * m + j] = dist(0, j + 1); + for (size_t mask = 1; mask < (size_t(1) << m); ++mask) + for (size_t j = 0; j < m; ++j) + if (const double c = cost[mask * m + j]; c < std::numeric_limits::max()) + for (size_t k = 0; k < m; ++k) + if (!(mask >> k & 1)) { + const size_t next = (mask | size_t(1) << k) * m + k; + if (const double nc = c + dist(j + 1, k + 1); nc < cost[next]) { + cost[next] = nc; + from[next] = mask * m + j; + } + } + const size_t full = (size_t(1) << m) - 1; + size_t best = full * m; + for (size_t j = 1; j < m; ++j) + if (cost[full * m + j] + dist(j + 1, 0) < cost[best] + dist(best % m + 1, 0)) + best = full * m + j; + for (size_t s = best, k = n; k-- > 1; s = from[s]) + path[k] = s % m + 1; + return path; +} + void tsp_rotate_minimize_closing(std::vector& path, const Points& centers) { size_t pn = path.size(); diff --git a/src/libslic3r/GCode/OrderingStrategies.hpp b/src/libslic3r/GCode/OrderingStrategies.hpp index a42a338a52..95a6e77bcf 100644 --- a/src/libslic3r/GCode/OrderingStrategies.hpp +++ b/src/libslic3r/GCode/OrderingStrategies.hpp @@ -29,6 +29,9 @@ bool tsp_2opt_improve(std::vector& path, const Points& centers, int max_ // Returns true if any crossing was removed. bool tsp_remove_crossings(std::vector& path, const Points& centers); +// The shortest cycle through the points, starting at point 0, by dynamic programming over subsets; for a few points only. +std::vector tsp_exact_cycle(const Points& centers); + // Rotate the cycle so the closing edge (last -> first) is minimized. void tsp_rotate_minimize_closing(std::vector& path, const Points& centers); diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index b2ddc75a34..e081717ecf 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -26,6 +26,7 @@ #include "ClipperUtils.hpp" #include "libslic3r/Line.hpp" #include "LocalesUtils.hpp" +#include "libslic3r/GCodeWriter.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Polygon.hpp" #include "libslic3r/Point.hpp" @@ -1528,8 +1529,10 @@ private: gcode << "M204 P" << acceleration; } else if (m_gcode_flavor == gcfKlipper && m_accel_to_decel_enable) { - gcode << "SET_VELOCITY_LIMIT ACCEL_TO_DECEL=" << acceleration * m_accel_to_decel_factor / 100; - gcode << "\nM204 S" << acceleration; + // One line, so Klipper derives the limit from this acceleration rather than the previous one. + std::string limits = "SET_VELOCITY_LIMIT ACCEL=" + std::to_string(acceleration); + GCodeWriter::append_klipper_accel_to_decel(limits, acceleration, m_accel_to_decel_factor); + gcode << limits; } else { // M204: Set default acceleration diff --git a/src/libslic3r/GCodeReader.cpp b/src/libslic3r/GCodeReader.cpp index eff557d817..1b545f86b5 100644 --- a/src/libslic3r/GCodeReader.cpp +++ b/src/libslic3r/GCodeReader.cpp @@ -95,7 +95,7 @@ const char* GCodeReader::parse_line_internal(const char *ptr, const char *end, G if (pend != c && is_end_of_word(*pend)) { // The axis value has been parsed correctly. if (axis != UNKNOWN_AXIS) - gline.m_axis[int(axis)] = float(v); + gline.m_axis[int(axis)] = v; gline.m_mask |= 1 << int(axis); c = pend; } else diff --git a/src/libslic3r/GCodeReader.hpp b/src/libslic3r/GCodeReader.hpp index e65dff193c..97699a99e1 100644 --- a/src/libslic3r/GCodeReader.hpp +++ b/src/libslic3r/GCodeReader.hpp @@ -35,7 +35,8 @@ public: std::string_view axis_pos(char axis) const; void clear() { m_raw.clear(); } bool has(Axis axis) const { return (m_mask & (1 << int(axis))) != 0; } - float value(Axis axis) const { return m_axis[axis]; } + float value(Axis axis) const { return float(m_axis[axis]); } + double precise_value(Axis axis) const { return m_axis[axis]; } bool has(char axis) const; bool has_value(char axis, float &value) const; // Parse value of an axis from raw string starting at axis_pos. @@ -72,15 +73,15 @@ public: bool has_p() const { return this->has(P); } bool has_unknown_axis() const { return this->has(UNKNOWN_AXIS); } - float x() const { return m_axis[X]; } - float y() const { return m_axis[Y]; } - float z() const { return m_axis[Z]; } - float e() const { return m_axis[E]; } - float f() const { return m_axis[F]; } + float x() const { return float(m_axis[X]); } + float y() const { return float(m_axis[Y]); } + float z() const { return float(m_axis[Z]); } + float e() const { return float(m_axis[E]); } + float f() const { return float(m_axis[F]); } // BBS: add I J P axis - float i() const { return m_axis[I]; } - float j() const { return m_axis[J]; } - float p() const { return m_axis[P]; } + float i() const { return float(m_axis[I]); } + float j() const { return float(m_axis[J]); } + float p() const { return float(m_axis[P]); } static bool cmd_is(const std::string &gcode_line, const char *cmd_test) { const char *cmd = GCodeReader::skip_whitespaces(gcode_line.c_str()); @@ -100,7 +101,7 @@ public: } private: std::string m_raw; - float m_axis[NUM_AXES]; + double m_axis[NUM_AXES]; uint32_t m_mask; friend class GCodeReader; }; diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index 62382f8214..805e6e2f52 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -453,12 +453,11 @@ std::string GCodeWriter::set_acceleration_internal(Acceleration type, unsigned i gcode += separate_travel ? "M204 T" : "M204 P"; gcode += value; } else if (FLAVOR_IS(gcfKlipper)) { - gcode.reserve(96); + gcode.reserve(128); gcode += "SET_VELOCITY_LIMIT ACCEL="; gcode += value; if (this->config.accel_to_decel_enable) { - gcode += " ACCEL_TO_DECEL="; - gcode += float_to_string_decimal_point(acceleration * this->config.accel_to_decel_factor / 100); + append_klipper_accel_to_decel(gcode, acceleration, this->config.accel_to_decel_factor.value); if (GCodeWriter::full_gcode_comment) gcode += " ; adjust ACCEL_TO_DECEL"; } @@ -473,6 +472,14 @@ std::string GCodeWriter::set_acceleration_internal(Acceleration type, unsigned i return gcode; } +void GCodeWriter::append_klipper_accel_to_decel(std::string &gcode, unsigned int acceleration, double accel_to_decel_factor) +{ + gcode += " ACCEL_TO_DECEL="; + gcode += float_to_string_decimal_point(acceleration * accel_to_decel_factor / 100); + gcode += " MINIMUM_CRUISE_RATIO="; + gcode += float_to_string_decimal_point(1. - accel_to_decel_factor / 100); +} + std::string GCodeWriter::set_jerk_xy(double jerk) { if (jerk < 0.01 || is_approx(jerk, m_last_jerk)) @@ -548,15 +555,13 @@ std::string GCodeWriter::set_accel_and_jerk(unsigned int acceleration, double je return std::string(); std::string gcode; - gcode.reserve(96); + gcode.reserve(128); gcode += "SET_VELOCITY_LIMIT"; if (set_acceleration) { gcode += " ACCEL="; gcode += std::to_string(acceleration); - if (this->config.accel_to_decel_enable) { - gcode += " ACCEL_TO_DECEL="; - gcode += float_to_string_decimal_point(acceleration * this->config.accel_to_decel_factor / 100); - } + if (this->config.accel_to_decel_enable) + append_klipper_accel_to_decel(gcode, acceleration, this->config.accel_to_decel_factor.value); m_last_acceleration = acceleration; } if (set_jerk) { diff --git a/src/libslic3r/GCodeWriter.hpp b/src/libslic3r/GCodeWriter.hpp index 3c90a4e9e1..fcbd68b282 100644 --- a/src/libslic3r/GCodeWriter.hpp +++ b/src/libslic3r/GCodeWriter.hpp @@ -71,6 +71,10 @@ public: std::string set_jerk_xy(double jerk); // Orca: set acceleration and jerk in one command for Klipper std::string set_accel_and_jerk(unsigned int acceleration, double jerk); + // Orca: Klipper's accel_to_decel limit for `acceleration`, sent as the legacy ACCEL_TO_DECEL and as the + // equivalent MINIMUM_CRUISE_RATIO = 1 - ACCEL_TO_DECEL / ACCEL that replaced it. Klipper before 0.13 and + // vendor forks read only the first, current Klipper only the second, and 0.13 prefers the second. + static void append_klipper_accel_to_decel(std::string &gcode, unsigned int acceleration, double accel_to_decel_factor); std::string set_junction_deviation(double junction_deviation); std::string set_pressure_advance(double pa, int tool = -1) const; std::string set_input_shaping(char axis, float damp, float freq, std::string type) const; diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index edd93390b4..81b78ec092 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config) { ConfigOption *opt_src = config.option(option); ConfigOption *opt_dst = temp_config.option(option, true); - if (opt_dst->is_scalar() || !(opt_dst->nullable())) + if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option)) opt_dst->set(opt_src); else { ConfigOptionVectorBase* opt_vec_src = static_cast(opt_src); @@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset) { ConfigOption *opt_src = preset.config.option(option); ConfigOption *opt_dst = temp_config.option(option, true); - if (opt_dst->is_scalar() || !(opt_dst->nullable())) + if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option)) opt_dst->set(opt_src); else { ConfigOptionVectorBase* opt_vec_src = static_cast(opt_src); @@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map 100%), silently // resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe. if (m_type == Preset::TYPE_FILAMENT) { - for (const std::string& opt_key : filament_extruder_override_keys) { - if (key_values.count(opt_key)) - continue; // already carried by the diff + for (const std::string& opt_key : preset.config.keys()) { + if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key)) + continue; // not an override, or already carried by the diff + const ConfigOptionDef *def = print_config_def.get(opt_key); + if (def == nullptr || !def->default_value || def->default_value->is_nil()) + continue; // the option's default is nil too, so an absent key cannot come back as a value const auto* opt_vec = dynamic_cast(preset.config.option(opt_key)); if (opt_vec == nullptr) continue; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 48d92da201..212d8d8cb5 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -119,12 +119,18 @@ const std::vector filament_extruder_override_keys = { }; // Some filament override parameters are generated from filament_extruder_override_keys, -// while filament_retract_length_nc is defined separately. Keep the generator list -// unchanged and use this helper for behavior checks that need the full override set. +// while filament_retract_length_nc and the ironing overrides are defined separately. +// Keep the generator list unchanged and use this helper for behavior checks that need +// the full override set. A nil in one of these keys means the override is disabled, so +// child presets store and load it verbatim instead of treating nil as "inherit". bool is_filament_extruder_override_key(const std::string &opt_key) { return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() || - opt_key == "filament_retract_length_nc"; + opt_key == "filament_retract_length_nc" || + opt_key == "filament_ironing_flow" || + opt_key == "filament_ironing_spacing" || + opt_key == "filament_ironing_inset" || + opt_key == "filament_ironing_speed"; } size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id) @@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig& opt_src->set(opt_target); } else - opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride); + // An override's nil means "disabled" and is the child's value; every other nil column inherits. + opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt)); } } } @@ -13493,12 +13500,30 @@ static std::map s_CustomGcodeSpecificP // Machine G-code {"file_start_gcode", {}}, {"machine_start_gcode", {}}, - {"machine_end_gcode", {"layer_num", "layer_z", "max_layer_z", "filament_extruder_id"}}, + {"machine_end_gcode", {"layer_num", "layer_z", "max_layer_z", "filament_extruder_id", + "current_filament_id", "current_extruder_id", "current_nozzle_id", + "nozzle_diameter_at_nozzle_id", "nozzle_volume_types"}}, {"before_layer_change_gcode", {"layer_num", "layer_z", "max_layer_z"}}, - {"layer_change_gcode", {"layer_num", "layer_z", "max_layer_z"}}, - {"timelapse_gcode", {"layer_num", "layer_z", "max_layer_z"}}, - {"change_filament_gcode", {"layer_num", "layer_z", "max_layer_z", "next_extruder", "previous_extruder", "fan_speed", + {"layer_change_gcode", {"layer_num", "layer_z", "max_layer_z", "most_used_physical_extruder_id", + "current_filament_id", "current_nozzle_id", "curr_y_acceleration_limit", + "curr_accumulated_mass", "curr_layer_mass"}}, + {"timelapse_gcode", {"layer_num", "layer_z", "max_layer_z", "most_used_physical_extruder_id", + "curr_physical_extruder_id", "timelapse_pos_x", "timelapse_pos_y", + "has_timelapse_safe_pos", "timelapse_inline_photo", + "farthest_point_timelapse_enabled", "clear_to_x0"}}, + {"wrapping_detection_gcode", {"layer_num", "layer_z", "max_layer_z", "most_used_physical_extruder_id", + "curr_physical_extruder_id"}}, + {"change_filament_gcode", {"layer_num", "layer_z", "max_layer_z", "next_extruder", "previous_extruder", + "current_hotend", "next_hotend", "current_nozzle_id", "next_nozzle_id", + "current_filament_id", "next_filament_id", "old_extruder_variant", "new_extruder_variant", + "nozzle_diameter_at_nozzle_id", "nozzle_volume_types", "filament_retract_length_nc", + "new_extruder_retracted_length", "outer_wall_volumetric_speed", "fan_speed", "first_flush_volume", "flush_length_1", "flush_length_2", "flush_length_3", "flush_length_4", + "flush_length", "flush_volumetric_speeds", "flush_temperatures", "filament_cooling_before_tower", + "wipe_avoid_perimeter", "wipe_avoid_pos_x", "is_prime_tower_interface", + "filament_tower_interface_purge_volume", "filament_tower_interface_print_temp", + "wipe_tower_center_pos_x", "wipe_tower_center_pos_y", "wipe_tower_center_pos_valid", + "temperature", "nozzle_temperature", "first_layer_temperature", "nozzle_temperature_initial_layer", "new_filament_e_feedrate", "new_filament_temp", "new_retract_length", "new_retract_length_toolchange", "old_filament_e_feedrate", "old_filament_temp", "old_retract_length", "old_retract_length_toolchange", "relative_e_axis", "second_flush_volume", "toolchange_count", "toolchange_z", @@ -13511,8 +13536,18 @@ static std::map s_CustomGcodeSpecificP {"machine_pause_gcode", {}}, {"template_custom_gcode", {}}, // Filament G-code - {"filament_start_gcode", {"filament_extruder_id"}}, - {"filament_end_gcode", {"layer_num", "layer_z", "max_layer_z", "filament_extruder_id"}}, + {"filament_start_gcode", {"layer_num", "layer_z", "max_layer_z", "filament_extruder_id", + "current_filament_id", "current_extruder_id", "current_nozzle_id", + "nozzle_diameter_at_nozzle_id", "nozzle_volume_types", + "retraction_distance_when_cut", "long_retraction_when_cut", + "temperature", "nozzle_temperature", "first_layer_temperature", + "nozzle_temperature_initial_layer"}}, + {"filament_end_gcode", {"layer_num", "layer_z", "max_layer_z", "filament_extruder_id", + "current_filament_id", "current_extruder_id", "current_nozzle_id", + "nozzle_diameter_at_nozzle_id", "nozzle_volume_types", + "retraction_distance_when_cut", "long_retraction_when_cut", + "temperature", "nozzle_temperature", "first_layer_temperature", + "nozzle_temperature_initial_layer"}}, }; const std::map& custom_gcode_specific_placeholders() @@ -13541,11 +13576,44 @@ CustomGcodeSpecificConfigDef::CustomGcodeSpecificConfigDef() def->label = L("Filament extruder ID"); def->tooltip = L("The current extruder ID. The same as current_extruder."); + new_def("current_filament_id", coInt, L("Current filament ID"), L("Index of the currently active filament. The index is zero-based (first filament has index 0).")); + new_def("current_extruder_id", coInt, L("Current extruder ID"), L("Index of the currently active physical extruder. The index is zero-based (first extruder has index 0).")); + new_def("current_nozzle_id", coInt, L("Current nozzle ID"), L("Index of the currently active nozzle. The index is zero-based (first nozzle has index 0).")); + new_def("nozzle_diameter_at_nozzle_id", coFloats, L("Nozzle diameters"), L("Nozzle diameters indexed by nozzle ID.")); + new_def("nozzle_volume_types", coStrings, L("Nozzle volume types"), L("Nozzle volume variants indexed by nozzle ID.")); + new_def("retraction_distance_when_cut", coFloat, L("Retraction distance when cut"), L("Retraction distance used when cutting filament.")); + new_def("long_retraction_when_cut", coBool, L("Long retraction when cut"), L("Whether long retraction is used when cutting filament.")); + +// layer_change_gcode + new_def("most_used_physical_extruder_id", coInt, L("Most-used physical extruder ID"), L("Index of the physical extruder used most often on the current layer. The index is zero-based (first extruder has index 0).")); + new_def("curr_y_acceleration_limit", coFloat, L("Current Y acceleration limit"), L("Current Y-axis acceleration limit after the bed-mass adjustment.")); + new_def("curr_accumulated_mass", coFloat, L("Current accumulated mass"), L("Accumulated printed mass at the current layer.")); + new_def("curr_layer_mass", coFloat, L("Current layer mass"), L("Printed mass of the previous layer.")); + +// timelapse_gcode + new_def("curr_physical_extruder_id", coInt, L("Current physical extruder ID"), L("Index of the physical extruder active for the current layer. The index is zero-based (first extruder has index 0).")); + new_def("timelapse_pos_x", coInt, L("Timelapse position X"), L("Selected X position for timelapse capture.")); + new_def("timelapse_pos_y", coInt, L("Timelapse position Y"), L("Selected Y position for timelapse capture.")); + new_def("has_timelapse_safe_pos", coBool, L("Has timelapse safe position"), L("Whether a safe timelapse position was found.")); + new_def("timelapse_inline_photo", coBool, L("Timelapse inline photo"), L("Whether the timelapse photo is captured at the inline position.")); + new_def("farthest_point_timelapse_enabled", coBool, L("Farthest-point timelapse enabled"), L("Whether farthest-point timelapse positioning is active.")); + new_def("clear_to_x0", coBool, L("Clear to X0"), L("Whether the timelapse move is clear to X0.")); + // change_filament_gcode new_def("previous_extruder", coInt, L("Previous extruder"), L("Index of the extruder that is being unloaded. The index is zero based (first extruder has index 0).")); new_def("next_extruder", coInt, L("Next extruder"), L("Index of the extruder that is being loaded. The index is zero based (first extruder has index 0).")); + new_def("current_hotend", coInt, L("Current hotend"), L("Zero-based extruder index of the outgoing filament. On Bambu printers it is -1, or the nozzle ID when a filament moves between nozzles across layers.")); + new_def("next_hotend", coInt, L("Next hotend"), L("Zero-based extruder index of the incoming filament. On Bambu printers it is -1, or the nozzle ID when a filament moves between nozzles across layers.")); + new_def("next_nozzle_id", coInt, L("Next nozzle ID"), L("Index of the nozzle associated with the incoming filament. The index is zero-based (first nozzle has index 0).")); + new_def("next_filament_id", coInt, L("Next filament ID"), L("Index of the incoming filament. The index is zero-based (first filament has index 0).")); + new_def("old_extruder_variant", coString, L("Old extruder variant"), L("Nozzle-volume variant of the outgoing extruder.")); + new_def("new_extruder_variant", coString, L("New extruder variant"), L("Nozzle-volume variant of the incoming extruder.")); + new_def("filament_retract_length_nc", coFloat, L("Filament nozzle-change retract length"), L("Nozzle-change retraction length of the outgoing filament.")); + new_def("new_extruder_retracted_length", coFloat, L("New extruder retracted length"), L("Currently parked retraction length of the incoming extruder.")); + new_def("outer_wall_volumetric_speed", coFloat, L("Outer wall volumetric speed"), L("Volumetric speed used for the outer wall.")); new_def("relative_e_axis", coBool, L("Relative e-axis"), L("Indicates if relative positioning is being used.")); new_def("toolchange_count", coInt, L("Toolchange count"), L("The number of toolchanges throughout the print.")); + new_def("toolchange_z", coFloat, L("Toolchange Z"), L("The Z position during the toolchange.")); new_def("fan_speed", coNone, "", ""); //Option is no longer used and is zeroed by placeholder parser for compatability new_def("old_retract_length", coFloat, L("Old retract length"), L("The retraction length of the previous filament.")); new_def("new_retract_length", coFloat, L("New retract length"), L("The retraction length of the new filament.")); @@ -13570,6 +13638,22 @@ CustomGcodeSpecificConfigDef::CustomGcodeSpecificConfigDef() new_def("flush_length_2", coFloat, L("Flush Length 2"), L("The second flush length.")); new_def("flush_length_3", coFloat, L("Flush Length 3"), L("The third flush length.")); new_def("flush_length_4", coFloat, L("Flush Length 4"), L("The fourth flush length.")); + new_def("flush_length", coFloat, L("Flush length"), L("Total flush length.")); + new_def("flush_volumetric_speeds", coFloats, L("Flush volumetric speeds"), L("Flush volumetric speeds for each filament.")); + new_def("flush_temperatures", coInts, L("Flush temperatures"), L("Flush temperatures for each filament.")); + new_def("filament_cooling_before_tower", coFloats, L("Filament cooling before tower"), L("Temperature drop before entering filament tower")); + new_def("wipe_avoid_perimeter", coBool, L("Avoid wipe perimeter"), L("Whether travel should avoid the wipe perimeter.")); + new_def("wipe_avoid_pos_x", coFloat, L("Wipe avoidance X"), L("X position used to avoid the wipe area.")); + new_def("is_prime_tower_interface", coBool, L("Prime tower interface"), L("Whether the current purge is for a prime-tower interface.")); + new_def("filament_tower_interface_purge_volume", coFloat, L("Interface layer purge length"), L("Purge length for prime tower interface layer (where different materials meet).")); + new_def("filament_tower_interface_print_temp", coInt, L("Filament tower interface print temperature"), L("Print temperature used for the filament tower interface.")); + new_def("wipe_tower_center_pos_x", coFloat, L("Wipe tower center X"), L("X coordinate of the nozzle's wipe-tower wait position, 2 mm outside the tower edge.")); + new_def("wipe_tower_center_pos_y", coFloat, L("Wipe tower center Y"), L("Y coordinate of the nozzle's wipe-tower wait position.")); + new_def("wipe_tower_center_pos_valid", coBool, L("Wipe tower center position valid"), L("Whether the print has a wipe tower.")); + new_def("temperature", coInts, L("Temperature"), L("Nozzle temperatures indexed by filament.")); + new_def("nozzle_temperature", coInts, L("Nozzle temperature"), L("Nozzle temperatures indexed by filament.")); + new_def("first_layer_temperature", coInts, L("First layer temperature"), L("First-layer nozzle temperatures indexed by filament.")); + new_def("nozzle_temperature_initial_layer", coInts, L("Nozzle temperature initial layer"), L("First-layer nozzle temperatures indexed by filament.")); // change_extrusion_role_gcode // The whole tooltip is one literal so that the GUI can look it up in the catalog. diff --git a/src/libslic3r/ShortestPath.cpp b/src/libslic3r/ShortestPath.cpp index bbea1a6900..31e02f35bc 100644 --- a/src/libslic3r/ShortestPath.cpp +++ b/src/libslic3r/ShortestPath.cpp @@ -26,8 +26,10 @@ #include "Print.hpp" #include "GCode/OrderingStrategies.hpp" +#include #include #include +#include #include "MultiMaterialSegmentation.hpp" namespace Slic3r { @@ -96,6 +98,46 @@ std::vector> chain_segments_closest_point(std::vector> chain_segments_exact(const std::vector &end_points, const std::vector &could_reverse, const Vec2d *start) +{ + const size_t n = could_reverse.size(); + assert(n >= 1 && n <= 12 && end_points.size() == 2 * n); + // State (mask * n + j) * 2 + o: the segments of mask visited, the last one j, flipped when o. + const size_t num_states = (size_t(1) << n) * n * 2; + std::vector cost(num_states, std::numeric_limits::max()); + std::vector from(num_states, size_t(-1)); + auto entry = [&end_points](size_t j, size_t o) -> const Vec2d & { return end_points[2 * j + o]; }; + auto exit = [&end_points](size_t j, size_t o) -> const Vec2d & { return end_points[2 * j + 1 - o]; }; + for (size_t j = 0; j < n; ++ j) + for (size_t o = 0; o <= size_t(could_reverse[j] != 0); ++ o) + cost[((size_t(1) << j) * n + j) * 2 + o] = start == nullptr ? 0. : (entry(j, o) - *start).norm(); + for (size_t mask = 1; mask < (size_t(1) << n); ++ mask) + for (size_t s = mask * n * 2; s < (mask + 1) * n * 2; ++ s) + if (cost[s] < std::numeric_limits::max()) + for (size_t k = 0; k < n; ++ k) + if (! (mask >> k & 1)) + for (size_t o = 0; o <= size_t(could_reverse[k] != 0); ++ o) { + const size_t next = (((mask | size_t(1) << k) * n) + k) * 2 + o; + const double c = cost[s] + (entry(k, o) - exit(s / 2 % n, s & 1)).norm(); + if (c < cost[next]) { + cost[next] = c; + from[next] = s; + } + } + const size_t full = (size_t(1) << n) - 1; + size_t best = full * n * 2; + for (size_t s = best; s < num_states; ++ s) + if (cost[s] < cost[best]) + best = s; + std::vector> out(n); + for (size_t s = best, k = n; k -- > 0; s = from[s]) + out[k] = { s / 2 % n, (s & 1) != 0 }; + return out; +} + // Chain perimeters (always closed) and thin fills (closed or open) using a greedy algorithm. // Solving a Traveling Salesman Problem (TSP) with the modification, that the sites are not always points, but points and segments. // Solving using a greedy algorithm, where a shortest edge is added to the solution if it does not produce a bifurcation or a cycle. @@ -104,6 +146,7 @@ std::vector> chain_segments_closest_point(std::vector std::vector> chain_segments_greedy_constrained_reversals_(SegmentEndPointFunc end_point_func, CouldReverseFunc could_reverse_func, size_t num_segments, const PointType *start_near) { @@ -118,6 +161,19 @@ std::vector> chain_segments_greedy_constrained_reversals out.emplace_back(0, could_reverse_func(0) && start_near != nullptr && (end_point_func(0, false) - *start_near).template cast().squaredNorm() < (end_point_func(0, true) - *start_near).template cast().squaredNorm()); } + else if (num_segments <= 4) + { + // Orca: so few segments are cheap to chain exactly. The first one is then not always the one nearest start_near. + std::vector end_points(2 * num_segments); + std::vector could_reverse(num_segments); + for (size_t i = 0; i < num_segments; ++ i) { + end_points[2 * i] = end_point_func(i, true).template cast(); + end_points[2 * i + 1] = end_point_func(i, false).template cast(); + could_reverse[i] = could_reverse_func(i); + } + const Vec2d start = start_near != nullptr ? Vec2d(start_near->template cast()) : Vec2d::Zero(); + out = chain_segments_exact(end_points, could_reverse, start_near != nullptr ? &start : nullptr); + } else { // End points of segments for the KD tree closest point search. @@ -1039,12 +1095,370 @@ std::vector> chain_segments_greedy2(SegmentEndPointFunc return chain_segments_greedy_constrained_reversals2_(end_point_func, could_reverse_func, num_segments, start_near); } +// The K points nearest to a query, nearest first, ties by index, from a uniform grid of about two points per cell. +template +class NearestPoints +{ +public: + static constexpr size_t npos = size_t(-1); + + explicit NearestPoints(const std::vector &points) : m_points(points) + { + BoundingBoxf bbox; + for (const Vec2d &p : points) + bbox.merge(p); + const Vec2d size = bbox.size(); + const double count = double(std::max(points.size(), 1)); + m_cell = std::max(std::sqrt(2. * size.x() * size.y() / count), std::max(size.x(), size.y()) / count); + if (m_cell <= 0.) + m_cell = 1.; + m_origin = bbox.min; + m_cols = int(size.x() / m_cell) + 1; + m_rows = int(size.y() / m_cell) + 1; + m_first.assign(size_t(m_cols) * m_rows + 1, 0); + for (const Vec2d &p : points) + ++ m_first[cell(p) + 1]; + for (size_t c = 1; c < m_first.size(); ++ c) + m_first[c] += m_first[c - 1]; + m_index.resize(points.size()); + std::vector next(m_first.begin(), m_first.end() - 1); + for (size_t i = 0; i < points.size(); ++ i) + m_index[next[cell(points[i])] ++] = i; + } + + // Indices of the nearest points that filter accepts, npos past the last one. + template + std::array query(const Vec2d &q, Filter filter) const + { + std::array, K> best; + size_t found = 0; + const int cx = int(std::clamp(std::floor((q.x() - m_origin.x()) / m_cell), 0., double(m_cols - 1))); + const int cy = int(std::clamp(std::floor((q.y() - m_origin.y()) / m_cell), 0., double(m_rows - 1))); + auto visit = [&](int x, int y) { + if (x < 0 || y < 0 || x >= m_cols || y >= m_rows) + return; + const size_t c = size_t(y) * m_cols + x; + for (size_t k = m_first[c]; k < m_first[c + 1]; ++ k) { + const size_t i = m_index[k]; + if (! filter(i)) + continue; + const std::pair candidate((m_points[i] - q).squaredNorm(), i); + if (found == K && ! (candidate < best[K - 1])) + continue; + auto it = std::upper_bound(best.begin(), best.begin() + found, candidate); + std::move_backward(it, best.begin() + std::min(found, K - 1), best.begin() + std::min(found + 1, K)); + *it = candidate; + found = std::min(found + 1, K); + } + }; + // Points beyond ring r are at least r cells away. + for (int r = 0; r <= std::max(m_cols, m_rows); ++ r) { + if (r == 0) + visit(cx, cy); + for (int d = -r; d <= r && r > 0; ++ d) { + visit(cx + d, cy - r); + visit(cx + d, cy + r); + if (d != -r && d != r) { + visit(cx - r, cy + d); + visit(cx + r, cy + d); + } + } + if (found == K && best[K - 1].first <= sqr(r * m_cell)) + break; + } + std::array out; + out.fill(npos); + for (size_t k = 0; k < found; ++ k) + out[k] = best[k].second; + return out; + } + +private: + size_t cell(const Vec2d &p) const + { + const int x = std::clamp(int((p.x() - m_origin.x()) / m_cell), 0, m_cols - 1); + const int y = std::clamp(int((p.y() - m_origin.y()) / m_cell), 0, m_rows - 1); + return size_t(y) * m_cols + x; + } + + const std::vector &m_points; + Vec2d m_origin; + double m_cell; + int m_cols; + int m_rows; + std::vector m_first; + std::vector m_index; +}; + +// Shortens the travel of a chain with 2-opt moves (reversing a run of segments) and or-opt moves (moving a run of up to +// three segments elsewhere, or a longer one to the front, flipped or not). end_points holds the first and the last point +// of each segment. The chain starts at start_near when given, and its end is free. A move that would flip a segment that +// cannot reverse is skipped. Moves only create links between nearest neighbor end points, and after a move only the +// segments next to a changed link are examined again, until a sweep of all segments finds no move. This keeps the search +// close to linear. +static void improve_chain_by_local_search(std::vector> &chain, const std::vector &end_points, + const std::vector &could_reverse, const Vec2d *start_near) +{ + const size_t n = chain.size(); + if (n < 2) + return; + assert(end_points.size() == 2 * n && could_reverse.size() == n); + + std::vector order(n), pos(n), fixed_before(n + 1, 0); + std::vector flipped(n); + for (size_t k = 0; k < n; ++ k) { + order[k] = chain[k].first; + flipped[order[k]] = chain[k].second; + } + // Updates positions lo..hi after a move, which keeps the number of segments that cannot reverse past hi. + auto refresh = [&](size_t lo, size_t hi) { + for (size_t k = lo; k <= hi; ++ k) { + pos[order[k]] = k; + fixed_before[k + 1] = fixed_before[k] + ! could_reverse[order[k]]; + } + }; + refresh(0, n - 1); + auto entry_idx = [&](size_t k) { return 2 * order[k] + flipped[order[k]]; }; + auto exit_idx = [&](size_t k) { return 2 * order[k] + 1 - flipped[order[k]]; }; + auto entry = [&](size_t k) -> const Vec2d& { return end_points[entry_idx(k)]; }; + auto exit = [&](size_t k) -> const Vec2d& { return end_points[exit_idx(k)]; }; + auto is_exit = [&](size_t e) { return char(e & 1) != flipped[e >> 1]; }; + auto reversible = [&](size_t i, size_t j) { return fixed_before[j + 1] == fixed_before[i]; }; + auto dist = [](const Vec2d *a, const Vec2d *b) { return a == nullptr || b == nullptr ? 0. : (*a - *b).norm(); }; + // Travel end points around position k, null where the chain is open. + auto prev_exit = [&](size_t k) -> const Vec2d* { return k > 0 ? &exit(k - 1) : start_near; }; + auto next_entry = [&](size_t k) -> const Vec2d* { return k + 1 < n ? &entry(k + 1) : nullptr; }; + + // The 32 nearest end points are the candidates, which covers every move on short chains; 8 missed much of the gain. + static constexpr size_t K = 32; + static constexpr size_t npos = NearestPoints::npos; + const NearestPoints nearest(end_points); + std::vector> neighbors(end_points.size()); + for (size_t e = 0; e < end_points.size(); ++ e) + neighbors[e] = nearest.query(end_points[e], [e](size_t idx) { return (idx >> 1) != (e >> 1); }); + std::array start_neighbors; + start_neighbors.fill(npos); + if (start_near != nullptr) + start_neighbors = nearest.query(*start_near, [](size_t) { return true; }); + + // Segments to examine, by id, each queued once. + std::deque queue; + std::vector queued(n, 0); + auto enqueue = [&](size_t k) { + if (k < n && ! queued[order[k]]) { + queued[order[k]] = 1; + queue.push_back(order[k]); + } + }; + + const double min_gain = SCALED_EPSILON; + // Reverses positions i..j if that shortens the chain. + auto try_2opt = [&](size_t i, size_t j) { + if (! reversible(i, j)) + return false; + const Vec2d *a = prev_exit(i), *b = next_entry(j); + if (dist(a, &exit(j)) + dist(&entry(i), b) > dist(a, &entry(i)) + dist(&exit(j), b) - min_gain) + return false; + std::reverse(order.begin() + i, order.begin() + j + 1); + for (size_t k = i; k <= j; ++ k) + flipped[order[k]] ^= 1; + refresh(i, j); + for (size_t k : { i - 1, i, j, j + 1 }) + enqueue(k); + return true; + }; + // Moves positions i..last after position gap (size_t(-1) for the front) if that shortens the chain. + auto try_oropt = [&](size_t i, size_t last, size_t gap) { + if (gap + 1 == i || (gap >= i && gap <= last)) + return false; + const Vec2d *a = gap == size_t(-1) ? start_near : &exit(gap); + const Vec2d *b = gap + 1 < n ? &entry(gap + 1) : nullptr; + const double removal_gain = dist(prev_exit(i), &entry(i)) + dist(&exit(last), next_entry(last)) - dist(prev_exit(i), next_entry(last)); + const double base = dist(a, b); + const double cost_kept = dist(a, &entry(i)) + dist(&exit(last), b) - base; + const double cost_flipped = reversible(i, last) ? dist(a, &exit(last)) + dist(&entry(i), b) - base : std::numeric_limits::max(); + if (std::min(cost_kept, cost_flipped) > removal_gain - min_gain) + return false; + const size_t len = last - i + 1; + // The run lands on first..first+len-1; lo..hi spans every position that changed. + const bool forward = gap > last && gap != size_t(-1); + const size_t first = forward ? gap + 1 - len : gap + 1; + const size_t lo = forward ? i : first; + const size_t hi = forward ? gap : last; + if (forward) + std::rotate(order.begin() + i, order.begin() + last + 1, order.begin() + gap + 1); + else + std::rotate(order.begin() + first, order.begin() + i, order.begin() + last + 1); + if (cost_flipped < cost_kept) { + std::reverse(order.begin() + first, order.begin() + first + len); + for (size_t k = first; k < first + len; ++ k) + flipped[order[k]] ^= 1; + } + refresh(lo, hi); + // The links around the run's new place, and the link closing its old place. + for (size_t k : { first - 1, first, first + len - 1, first + len, forward ? i - 1 : last, forward ? i : last + 1 }) + enqueue(k); + return true; + }; + // The gap before position k of the chain with positions i..last taken out. + auto gap_before = [](size_t k, size_t i, size_t last) { return k == last + 1 ? i - 1 : k - 1; }; + + // Applies the first move found that breaks a link next to position p. + // Candidates are tried nearest first, while closer than the link the move would replace at that end point. + auto closer = [&](const Vec2d &at, size_t q, double limit) { return q != npos && (end_points[q] - at).norm() < limit; }; + auto improve_at = [&](size_t p) { + // 2-opt breaking the link into p, as the first or the last link of the reversed run. + if (const Vec2d *a = prev_exit(p); a != nullptr) { + const double old_link = dist(a, &entry(p)); + for (size_t q : p > 0 ? neighbors[exit_idx(p - 1)] : start_neighbors) { + if (! closer(*a, q, old_link)) + break; + if (is_exit(q) && pos[q >> 1] >= p && try_2opt(p, pos[q >> 1])) + return true; + } + if (p > 0) + for (size_t q : neighbors[entry_idx(p)]) { + if (! closer(entry(p), q, old_link)) + break; + if (! is_exit(q) && pos[q >> 1] < p && try_2opt(pos[q >> 1], p - 1)) + return true; + } + } + // 2-opt breaking the link out of p. + if (p + 1 < n) { + const double old_link = dist(&exit(p), &entry(p + 1)); + for (size_t q : neighbors[exit_idx(p)]) { + if (! closer(exit(p), q, old_link)) + break; + if (is_exit(q) && pos[q >> 1] > p && try_2opt(p + 1, pos[q >> 1])) + return true; + } + for (size_t q : neighbors[entry_idx(p + 1)]) { + if (! closer(entry(p + 1), q, old_link)) + break; + if (! is_exit(q) && pos[q >> 1] <= p && try_2opt(pos[q >> 1], p)) + return true; + } + } + // A run of any length that starts or ends next to the start point moves to the front. + if (p == 0) + for (size_t q : start_neighbors) { + if (q == npos) + break; + const size_t m = pos[q >> 1]; + if (m == 0) + continue; + // Runs m..k enter next to the start, runs k..m leave there and go flipped. + const bool exit_near = is_exit(q); + for (size_t k = exit_near ? 1 : m; k < (exit_near ? m + 1 : n); ++ k) + if (try_oropt(exit_near ? k : m, exit_near ? m : k, size_t(-1))) + return true; + } + // Or-opt of the runs starting at p: next to an exit a run goes after that segment, next to an entry before it. + for (size_t last = p; last < std::min(p + 3, n); ++ last) { + if (try_oropt(p, last, size_t(-1))) + return true; + const double removal_gain = dist(prev_exit(p), &entry(p)) + dist(&exit(last), next_entry(last)) - dist(prev_exit(p), next_entry(last)); + for (size_t e : { entry_idx(p), exit_idx(last) }) + for (size_t q : neighbors[e]) { + if (! closer(end_points[e], q, removal_gain)) + break; + const size_t m = pos[q >> 1]; + if ((m < p || m > last) && try_oropt(p, last, is_exit(q) ? m : gap_before(m, p, last))) + return true; + } + } + return false; + }; + + // A new link can make room for a run that sits elsewhere, hence the sweeps until one finds no move. Every move shortens + // the chain, so the search ends; the cap only bounds its time. + for (size_t k = 0; k < n; ++ k) + enqueue(k); + bool moved_since_sweep = false; + for (size_t moves = 0; moves < 50 * n;) { + if (queue.empty()) { + if (! moved_since_sweep) + break; + moved_since_sweep = false; + for (size_t k = 0; k < n; ++ k) + enqueue(k); + } + const size_t segment = queue.front(); + queue.pop_front(); + queued[segment] = 0; + if (improve_at(pos[segment])) { + ++ moves; + moved_since_sweep = true; + enqueue(pos[segment]); + } + } + + for (size_t k = 0; k < n; ++ k) + chain[k] = { order[k], flipped[order[k]] != 0 }; +} + +// Chain that always goes on to the nearest free end point, from start. +static std::vector> chain_nearest_next(const std::vector &end_points, const std::vector &could_reverse, const Vec2d &start) +{ + const NearestPoints<1> nearest(end_points); + std::vector taken(could_reverse.size(), 0); + std::vector> out; + out.reserve(could_reverse.size()); + for (Vec2d at = start; out.size() < could_reverse.size();) { + const size_t e = nearest.query(at, [&](size_t idx) { return ! taken[idx >> 1] && ((idx & 1) == 0 || could_reverse[idx >> 1]); })[0]; + taken[e >> 1] = 1; + out.emplace_back(e >> 1, (e & 1) != 0); + at = end_points[e ^ 1]; + } + return out; +} + +static double chain_travel(const std::vector> &chain, const std::vector &end_points, const Vec2d &start) +{ + double travel = 0.; + Vec2d at = start; + for (const std::pair &segment : chain) { + travel += (end_points[2 * segment.first + segment.second] - at).norm(); + at = end_points[2 * segment.first + 1 - segment.second]; + } + return travel; +} + template -static std::vector> chain_extrusion_entities_impl(const std::vector &entities, const Point *start_near) +static std::vector> chain_extrusion_entities_impl(const std::vector &entities, const Point *start_near, bool improve = false) { auto segment_end_point = [&entities](size_t idx, bool first_point) -> Point { return first_point ? entities[idx]->first_point() : entities[idx]->last_point(); }; auto could_reverse = [&entities](size_t idx) { const ExtrusionEntity *ee = entities[idx]; return ee->is_loop() || ee->can_reverse(); }; std::vector> out = chain_segments_greedy_constrained_reversals(segment_end_point, could_reverse, entities.size(), start_near); + if (improve && out.size() > 1) { + std::vector end_points; + std::vector reversible; + end_points.reserve(2 * entities.size()); + reversible.reserve(entities.size()); + for (size_t idx = 0; idx < entities.size(); ++ idx) { + end_points.emplace_back(segment_end_point(idx, true).template cast()); + end_points.emplace_back(segment_end_point(idx, false).template cast()); + reversible.emplace_back(could_reverse(idx)); + } + Vec2d start = Vec2d::Zero(); + if (start_near != nullptr) + start = start_near->cast(); + if (entities.size() <= 8) { + // Few paths are chained exactly; the local search below can stop short of the shortest chain. + out = chain_segments_exact(end_points, reversible, start_near != nullptr ? &start : nullptr); + } else { + improve_chain_by_local_search(out, end_points, reversible, start_near != nullptr ? &start : nullptr); + if (start_near != nullptr) { + // The greedy chain can settle into a sweep that local moves cannot undo, which a nearest-next chain may avoid. + std::vector> nearest_next = chain_nearest_next(end_points, reversible, start); + if (chain_travel(nearest_next, end_points, start) < chain_travel(out, end_points, start) - SCALED_EPSILON) { + improve_chain_by_local_search(nearest_next, end_points, reversible, &start); + out = std::move(nearest_next); + } + } + } + } for (std::pair &segment : out) { const ExtrusionEntity *ee = entities[segment.first]; if (ee->is_loop()) @@ -1086,7 +1500,8 @@ void reorder_extrusion_entities(std::vector &entities, const s void chain_and_reorder_extrusion_entities(std::vector &entities, const Point &start_near) { - chain_and_reorder_extrusion_entities(entities, &start_near); + remove_entities_without_endpoints(entities); + reorder_extrusion_entities(entities, chain_extrusion_entities_impl(entities, &start_near, true)); } void chain_and_reorder_extrusion_entities(std::vector &entities, const Point *start_near) @@ -1101,7 +1516,7 @@ void chain_and_reorder_extrusion_entities(std::vector &e remove_entities_without_endpoints(entities); std::vector out; out.reserve(entities.size()); - for (const auto &[idx, reverse] : chain_extrusion_entities_impl(entities, &start_near)) { + for (const auto &[idx, reverse] : chain_extrusion_entities_impl(entities, &start_near, true)) { if (reverse) { ExtrusionEntity *clone = reversed_clones.emplace_back(entities[idx]->clone()).get(); clone->reverse(); @@ -1159,16 +1574,74 @@ std::vector chain_points(const Points &points, const Point *start_near) return out; } +// Moves runs of up to three points of the cycle next to one of the 8 points nearest to an end of the run, reversed or not, +// while that shortens the cycle. path[0] stays first. Returns true if any move was made. +static bool improve_cycle_by_or_opt(std::vector &path, const Points &points) +{ + const size_t pn = path.size(); + if (pn < 5) + return false; + std::vector coords(points.size()); + for (size_t i = 0; i < points.size(); ++ i) + coords[i] = points[i].cast(); + const NearestPoints<8> grid(coords); + std::vector pos(points.size()); + auto refresh = [&]() { for (size_t k = 0; k < pn; ++ k) pos[path[k]] = k; }; + refresh(); + auto dist = [&](size_t a, size_t b) { return (coords[path[a % pn]] - coords[path[b % pn]]).norm(); }; + bool improved = false; + for (bool moved = true; moved;) { + moved = false; + for (size_t len = 1; len <= 3; ++ len) + for (size_t i = 1; i + len <= pn; ++ i) { + // The run path[i..last] goes between path[k] and path[k + 1]. + const size_t last = i + len - 1; + const double removal = dist(i - 1, i) + dist(last, last + 1) - dist(i - 1, last + 1); + bool applied = removal <= SCALED_EPSILON; + for (size_t end : { path[i], path[last] }) + for (size_t q : grid.query(coords[end], [end](size_t idx) { return idx != end; })) + for (size_t k : { q == NearestPoints<8>::npos ? pn : (pos[q] + pn - 1) % pn, q == NearestPoints<8>::npos ? pn : pos[q] }) { + if (applied || k == pn || (k + 1 >= i && k <= last)) + continue; + const double base = dist(k, k + 1); + const double kept = dist(k, i) + dist(last, k + 1) - base; + const double reversed = dist(k, last) + dist(i, k + 1) - base; + if (std::min(kept, reversed) > removal - SCALED_EPSILON) + continue; + std::vector run(path.begin() + i, path.begin() + last + 1); + if (reversed < kept) + std::reverse(run.begin(), run.end()); + path.erase(path.begin() + i, path.begin() + last + 1); + path.insert(path.begin() + (k < i ? k + 1 : k + 1 - len), run.begin(), run.end()); + refresh(); + applied = moved = improved = true; + } + } + } + return improved; +} + std::vector chain_points_with_postprocessing(const Points &points, const Point *start_near) { - std::vector path = chain_points(points, start_near); - // Alternate 2-opt and crossing removal until convergence. - // 2-opt can create new crossings, and crossing removal can create new - // opportunities for 2-opt improvement. Break early if neither improves. - for (int iter = 0; iter < 3; ++iter) { - bool improved = tsp_2opt_improve(path, points); - improved |= tsp_remove_crossings(path, points); - if (!improved) break; + std::vector path; + if (points.size() <= 10) { + // Orca: few points are toured exactly, from the point nearest start_near toward its nearer neighbor. + path = tsp_exact_cycle(points); + if (start_near != nullptr && path.size() > 2) { + auto dist = [&points](size_t a, const Point &b) { return (points[a] - b).cast().squaredNorm(); }; + std::rotate(path.begin(), std::min_element(path.begin(), path.end(), [&](size_t a, size_t b) { return dist(a, *start_near) < dist(b, *start_near); }), path.end()); + if (dist(path.back(), points[path.front()]) < dist(path[1], points[path.front()])) + std::reverse(path.begin() + 1, path.end()); + } + } else { + path = chain_points(points, start_near); + // Alternate 2-opt, or-opt and crossing removal: each can open moves for the others. Break early if none improves. + for (int iter = 0; iter < 3; ++iter) { + bool improved = tsp_2opt_improve(path, points); + improved |= improve_cycle_by_or_opt(path, points); + improved |= tsp_remove_crossings(path, points); + if (!improved) break; + } } if (start_near == nullptr) tsp_rotate_minimize_closing(path, points); diff --git a/src/libslic3r/ShortestPath.hpp b/src/libslic3r/ShortestPath.hpp index 53ec00f9e4..34127fdb9a 100644 --- a/src/libslic3r/ShortestPath.hpp +++ b/src/libslic3r/ShortestPath.hpp @@ -23,9 +23,12 @@ std::vector chain_expolygons(const ExPolygons &input_exploy); std::vector> chain_extrusion_entities(std::vector &entities, const Point *start_near = nullptr); void reorder_extrusion_entities(std::vector &entities, const std::vector> &chain); +// From start_near, the greedy chain is refined by 2-opt and or-opt moves, which shortens the travel at some extra cost. void chain_and_reorder_extrusion_entities(std::vector &entities, const Point &start_near); +// The greedy chain only. void chain_and_reorder_extrusion_entities(std::vector &entities, const Point *start_near = nullptr); -// Each entity the chain reverses is replaced by a reversed clone that reversed_clones owns, so the originals stay unchanged. +// Refined as above. Each entity the chain reverses is replaced by a reversed clone that reversed_clones owns, so the +// originals stay unchanged. void chain_and_reorder_extrusion_entities(std::vector &entities, const Point &start_near, std::vector> &reversed_clones); diff --git a/src/slic3r/GUI/DailyTips.cpp b/src/slic3r/GUI/DailyTips.cpp index 8bace894e1..dc97117efc 100644 --- a/src/slic3r/GUI/DailyTips.cpp +++ b/src/slic3r/GUI/DailyTips.cpp @@ -161,7 +161,7 @@ void DailyTipsDataRenderer::render_img(const ImVec2& start_pos, const ImVec2& si bool has_cjk(const std::string &text) { - for (size_t i = 0; i < text.size() - 2; ++i) { + for (size_t i = 0; i + 2 < text.size(); ++i) { unsigned char c1 = text[i]; unsigned char c2 = text[i + 1]; unsigned char c3 = text[i + 2]; diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index f6adfc0c38..5d467d44c7 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -2158,6 +2158,7 @@ wxMenu* MenuFactory::multi_selection_menu() menu->AppendSeparator(); append_menu_items_convert_unit(menu); + append_menu_item_reload_from_disk(menu); append_menu_item_replace_all_with_stl(menu); //BBS append_menu_item_change_filament(menu); @@ -2173,6 +2174,7 @@ wxMenu* MenuFactory::multi_selection_menu() //append_menu_item_simplify(menu); append_menu_item_delete(menu); append_menu_items_convert_unit(menu); + append_menu_item_reload_from_disk(menu); append_menu_item_replace_all_with_stl(menu); append_menu_item_change_filament(menu); wxMenu* split_menu = new wxMenu(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp b/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp index db125ae1d5..ae39eade25 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp @@ -1089,8 +1089,8 @@ EmbossStyles GLGizmoEmboss::create_default_styles() styles.push_back(WxFontUtils::create_emboss_style(wx_font, _u8L("First font"))); } else { // On current OS is not installed any correct TTF font - // use font packed with Slic3r - std::string font_path = Slic3r::resources_dir() + "/fonts/NotoSans-Regular.ttf"; + // use font packed with OrcaSlicer + std::string font_path = Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; styles.push_back(EmbossStyle{_u8L("Default font"), font_path, EmbossStyle::Type::file_path}); } return styles; diff --git a/src/slic3r/GUI/HMS.cpp b/src/slic3r/GUI/HMS.cpp index 2234d1afb4..6bd847f32f 100644 --- a/src/slic3r/GUI/HMS.cpp +++ b/src/slic3r/GUI/HMS.cpp @@ -33,7 +33,7 @@ static const char* HMS_LOCAL_IMG_PATH = "hms/local_image"; // the local HMS info // Orca: dev-id-type set trimmed to the devices Orca ships local HMS images for -static std::unordered_set package_dev_id_types {"094", "239", "093", "22E"}; +static std::unordered_set package_dev_id_types {"094", "239", "093", "22E", "20P", "26A", "31B"}; // Orca: HMS should be disabled when stealth mode is on or networking is not installed static bool should_disable_hms() diff --git a/src/slic3r/GUI/Jobs/PrintJob.cpp b/src/slic3r/GUI/Jobs/PrintJob.cpp index b6b420969f..c903e3b139 100644 --- a/src/slic3r/GUI/Jobs/PrintJob.cpp +++ b/src/slic3r/GUI/Jobs/PrintJob.cpp @@ -716,8 +716,6 @@ void PrintJob::process(Ctl &ctl) } else { // wait for printer mqtt ready the same job id - wxGetApp().plater()->record_slice_preset("print"); - BOOST_LOG_TRIVIAL(error) << "print_job: send ok."; wxCommandEvent* evt = new wxCommandEvent(m_print_job_completed_id); if (!m_completed_evt_data.empty()) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 058eae58ba..3b224725f6 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -17866,6 +17866,9 @@ void Plater::calib_input_shaping_freq(const Calib_Params& params) filament_config->set_key_value("slow_down_for_layer_cooling", new ConfigOptionBools{false}); print_config->set_key_value("layer_height", new ConfigOptionFloat(0.2)); set_config_values(print_config, "enable_overhang_speed", false); + // A 100% factor sends Klipper MINIMUM_CRUISE_RATIO=0 with every acceleration change, for the whole test. + print_config->set_key_value("accel_to_decel_enable", new ConfigOptionBool(true)); + print_config->set_key_value("accel_to_decel_factor", new ConfigOptionPercent(100)); print_config->set_key_value("timelapse_type", new ConfigOptionEnum(tlTraditional)); print_config->set_key_value("wall_loops", new ConfigOptionInt(1)); print_config->set_key_value("top_shell_layers", new ConfigOptionInt(0)); @@ -17930,6 +17933,9 @@ void Plater::calib_input_shaping_damp(const Calib_Params& params) filament_config->set_key_value("slow_down_min_speed", new ConfigOptionFloats { 0.0 }); filament_config->set_key_value("slow_down_for_layer_cooling", new ConfigOptionBools{false}); set_config_values(print_config, "enable_overhang_speed", false); + // A 100% factor sends Klipper MINIMUM_CRUISE_RATIO=0 with every acceleration change, for the whole test. + print_config->set_key_value("accel_to_decel_enable", new ConfigOptionBool(true)); + print_config->set_key_value("accel_to_decel_factor", new ConfigOptionPercent(100)); print_config->set_key_value("timelapse_type", new ConfigOptionEnum(tlTraditional)); print_config->set_key_value("wall_loops", new ConfigOptionInt(1)); print_config->set_key_value("top_shell_layers", new ConfigOptionInt(0)); @@ -18671,7 +18677,7 @@ void ProjectDropDialog::on_dpi_changed(const wxRect& suggested_rect) //BBS: remove GCodeViewer as seperate APP logic bool Plater::load_files(const wxArrayString& filenames) { - const std::regex pattern_drop(".*[.](stp|step|stl|oltp|obj|amf|3mf|svg|zip|drc)", std::regex::icase); + const std::regex pattern_drop(".*[.](stp|step|stl|oltp|obj|amf|3mf|svg|zip|drc|glb|gltf|fbx)", std::regex::icase); const std::regex pattern_gcode_drop(".*[.](gcode|g)", std::regex::icase); std::vector normal_paths; @@ -20790,76 +20796,6 @@ void Plater::reslice() p->preview->reload_print(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": finished, started slicing for plate %1%") % p->partplate_list.get_curr_plate_index(); - - record_slice_preset("slicing"); -} - -void Plater::record_slice_preset(std::string action) -{ - // record slice preset - try - { - json j; - auto printer_preset = wxGetApp().preset_bundle->printers.get_edited_preset_with_vendor_profile().preset; - if (printer_preset.is_system) { - j["printer_preset_name"] = printer_preset.name; - } - else { - j["printer_preset_name"] = printer_preset.config.opt_string("inherits"); - } - const t_config_enum_values* keys_map = print_config_def.get("curr_bed_type")->enum_keys_map; - if (keys_map) { - for (auto item : *keys_map) { - if (item.second == wxGetApp().preset_bundle->project_config.opt_enum("curr_bed_type")) { - j["curr_bed_type"] = item.first; - break; - } - } - } - auto filament_presets = wxGetApp().preset_bundle->filament_presets; - for (int i = 0; i < filament_presets.size(); ++i) { - auto filament_preset = wxGetApp().preset_bundle->filaments.find_preset(filament_presets[i]); - if (filament_preset->is_system) { - j["filament_preset_" + std::to_string(i)] = filament_preset->name; - } - else { - j["filament_preset_" + std::to_string(i)] = filament_preset->config.opt_string("inherits"); - } - } - - Preset& print_preset = wxGetApp().preset_bundle->prints.get_edited_preset(); - if (print_preset.is_system) { - j["process_preset"] = print_preset.name; - } - else { - j["process_preset"] = print_preset.config.opt_string("inherits"); - } - j["support_type"] = ConfigOptionEnum::get_enum_names().at(print_preset.config.opt_enum("support_type")); - j["sparse_infill_pattern"] = ConfigOptionEnum::get_enum_names().at(print_preset.config.opt_enum("sparse_infill_pattern")); - j["sparse_infill_density"] = print_preset.config.opt("sparse_infill_density")->value; - - j["brim_type"] = ConfigOptionEnum::get_enum_names().at(print_preset.config.opt_enum("brim_type")); - j["user_mode"] = wxGetApp().get_mode_str(); - - if (p->background_process.fff_print()) { - const DynamicPrintConfig& full_config = p->background_process.fff_print()->full_print_config(); - json values = json::array(); - if (full_config.has("different_settings_to_system")) { - std::vector different_values = full_config.option("different_settings_to_system")->values; - for (auto& item : different_values) { - values.push_back(item); - } - } - j["different_settings_to_system"] = values; - } - - j["record_event"] = action; - NetworkAgent* agent = wxGetApp().getAgent(); - } - catch (...) - { - return; - } } //BBS: add project slicing related logic diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 564addb0c7..a879226b42 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -597,7 +597,6 @@ public: bool has_toolpaths_to_export() const; void export_toolpaths_to_obj() const; void reslice(); - void record_slice_preset(std::string action); void reslice_SLA_supports(const ModelObject &object, bool postpone_error_messages = false); void reslice_SLA_hollowing(const ModelObject &object, bool postpone_error_messages = false); void reslice_SLA_until_step(SLAPrintObjectStep step, const ModelObject &object, bool postpone_error_messages = false); diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 3f5bf126a2..26441baf37 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -5738,8 +5738,9 @@ void SelectMachineDialog::reset_and_sync_ams_list() } // Orca: collapse the per-nozzle material items into the single panel when the printer has one -// extruder or a filament switch (both feed a single logical mapping surface); otherwise keep the -// left/right split. Early-returns unless an item is actually in the wrong panel. +// extruder or a filament switch (both feed a single logical mapping surface), or when the plate is +// sliced for one nozzle (it has no left/right filament map); otherwise keep the left/right split. +// Early-returns unless an item is actually in the wrong panel. void SelectMachineDialog::update_material_item_pos(MachineObject* obj_) { if (!obj_) { @@ -5748,7 +5749,10 @@ void SelectMachineDialog::update_material_item_pos(MachineObject* obj_) const bool is_single_head = obj_->GetExtderSystem()->GetTotalExtderCount() < 2; const bool has_switcher = obj_->GetFilaSwitch()->IsInstalled(); - const bool use_single_panel = is_single_head || has_switcher; + // reset_and_sync_ams_list() fills m_filaments_map only for a multi-nozzle plate + const auto& full_config = wxGetApp().preset_bundle->full_config(); + const bool single_nozzle_plate = full_config.option("nozzle_diameter")->values.size() < 2; + const bool use_single_panel = is_single_head || has_switcher || single_nozzle_plate; bool to_change_pos = false; for (const auto& iter : m_materialList) { diff --git a/src/slic3r/Utils/BBLPrinterAgent.cpp b/src/slic3r/Utils/BBLPrinterAgent.cpp index 7fde77a075..30fd075fc4 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.cpp +++ b/src/slic3r/Utils/BBLPrinterAgent.cpp @@ -7,6 +7,8 @@ #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/DeviceCore/DevManager.h" +#include +#include #include #include #include @@ -16,6 +18,7 @@ #include #include #include +#include #include #include using json = nlohmann::json; @@ -64,12 +67,16 @@ const BambuFilamentIdMap& bambu_filament_id_map() return map; } -// Rewrites every string under "tray_info_idx", "filament_id" or "filamentId", at any depth, in place. +// Keys whose string value is a filament id. "cate" and "fila_id" are the H2C nozzle rack's: the +// auto nozzle mapping request and the rack status in the push message. +constexpr std::array filament_id_keys{"tray_info_idx", "filament_id", "filamentId", "cate", "fila_id"}; + +// Rewrites every string under a filament id key, at any depth, in place. void rewrite_filament_ids(json& j, const std::unordered_map& map) { if (j.is_object()) { for (auto& [key, value] : j.items()) { - if (value.is_string() && (key == "tray_info_idx" || key == "filament_id" || key == "filamentId")) { + if (value.is_string() && std::find(filament_id_keys.begin(), filament_id_keys.end(), key) != filament_id_keys.end()) { auto it = map.find(value.get_ref()); if (it != map.end()) value = it->second; @@ -84,8 +91,8 @@ void rewrite_filament_ids(json& j, const std::unordered_map& map) { - if (map.empty() || (text.find("tray_info_idx") == std::string::npos && text.find("filament_id") == std::string::npos && - text.find("filamentId") == std::string::npos)) + if (map.empty() || std::none_of(filament_id_keys.begin(), filament_id_keys.end(), + [&text](std::string_view key) { return text.find(key) != std::string::npos; })) return text; // nothing to map, skip the parse (moved, not copied) try { json j = json::parse(text); diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index 8494eb5cfd..025f0ba918 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -1415,16 +1415,8 @@ void MoonrakerPrinterAgent::announce_printhost_device() } } - // Try to fetch actual device name from Moonraker - // Priority: 1) Moonraker hostname, 2) Preset model name, 3) Generic fallback - std::string dev_name; - MoonrakerDeviceInfo info; - std::string fetch_error; - if (fetch_device_info(device_info.base_url, device_info.api_key, info, fetch_error) && !info.dev_name.empty()) { - dev_name = info.dev_name; - } else { - dev_name = device_info.model_name.empty() ? "Moonraker Printer" : device_info.model_name; - } + // Never asks the printer, since discovery runs on the GUI thread and an unreachable printer would freeze it + const std::string dev_name = device_info.model_name.empty() ? "Moonraker Printer" : device_info.model_name; const std::string model_id = device_info.model_id; diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index f5f7e30d1d..f759d15a2b 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -1917,13 +1917,20 @@ RefreshResult OrcaCloudServiceAgent::refresh_from_storage(const std::string& rea bool OrcaCloudServiceAgent::refresh_if_expiring(std::chrono::seconds skew, const std::string& reason) { - bool needs_refresh = false; - { + const auto needs_refresh = [&] { std::lock_guard lock(session_mutex); - needs_refresh = should_refresh_locked(skew); - } + return should_refresh_locked(skew); + }; + if (!needs_refresh()) + return true; - if (!needs_refresh) + // A refresh may already be running on another thread, e.g. the one start() launches. Wait for + // it and check again instead of spending the refresh token it is rotating. Bounded: the + // refresh POST times out after 30 s. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60); + while (refresh_running.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (!needs_refresh()) return true; // First attempt. refresh_from_storage blocks on the cross-process lock and reads the @@ -1935,6 +1942,8 @@ bool OrcaCloudServiceAgent::refresh_if_expiring(std::chrono::seconds skew, const // One retry, only for a transient network failure of the refresh POST (lost response, // timeout, 429/5xx -> Transient). The retry re-acquires the lock and re-reads the store. std::this_thread::sleep_for(std::chrono::milliseconds(750)); + if (!needs_refresh()) + return true; return refresh_from_storage(reason + "_retry", false) == RefreshResult::Success; } diff --git a/src/slic3r/plugin/CloudPluginService.cpp b/src/slic3r/plugin/CloudPluginService.cpp index 22e0b57f81..221ec7787c 100644 --- a/src/slic3r/plugin/CloudPluginService.cpp +++ b/src/slic3r/plugin/CloudPluginService.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -49,16 +50,22 @@ bool CloudPluginService::fetch_manifests_into_descriptors(std::vectorensure_token_fresh("fetch_plugin_manifests"); + std::vector mine_descriptors; + auto mine = std::async(std::launch::async, [agent = m_orca_agent, &mine_descriptors] { + return agent->fetch_mine_manifests_into_descriptors(mine_descriptors); + }); + int ret = m_orca_agent->fetch_subscribed_manifests_into_descriptors(descriptors, not_found, unauthorized); + const int mine_ret = mine.get(); if (ret != 0) { BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to get subscribed plugins."; return false; } - std::vector mine_descriptors; - - ret = m_orca_agent->fetch_mine_manifests_into_descriptors(mine_descriptors); + ret = mine_ret; if (ret != 0) { BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to get owned plugins."; diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index e913763891..7464a8a41f 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name) return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; } +// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole +// operation, so the raising frame bounds everything the operation does. Every other mapped event +// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly +// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2 +// are raised in iglob(), which returns its lazy generator before any directory is listed. +static const std::unordered_set operation_root_events{ + "urllib.Request", "http.client.connect", "os.walk", "os.fwalk", + "glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob", + "subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp", + "shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree", + "shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive", + "shutil.unpack_archive", +}; + // True for the categories whose targets are filesystem paths, as opposed to a network // address or a process command line -- the deny-path and allowed-root checks only make sense // against a path. @@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category) } } +// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open". +static const char* category_name(AuditEventCategory category) +{ + switch (category) { + case AuditEventCategory::FsRead: return "FsRead"; + case AuditEventCategory::FsReadWrite: return "FsReadWrite"; + case AuditEventCategory::FsCreate: return "FsCreate"; + case AuditEventCategory::FsDelete: return "FsDelete"; + case AuditEventCategory::Http: return "Http"; + case AuditEventCategory::Socket: return "Socket"; + case AuditEventCategory::ProcessCreate: return "ProcessCreate"; + case AuditEventCategory::ProcessReplace: return "ProcessReplace"; + case AuditEventCategory::Threading: return "Threading"; + case AuditEventCategory::None: break; + } + return "None"; +} + // --------------------------------------------------------------------------- // Path safety // --------------------------------------------------------------------------- @@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +thread_local std::vector PluginAuditManager::m_root_frames; +thread_local int PluginAuditManager::m_releasing_frames = 0; thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local AuditViolation PluginAuditManager::m_last_violation; ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, const std::string& capability_name) - : m_previous_id(PluginAuditManager::instance().current_plugin()) + : m_plugin_key(plugin_key) + , m_capability(capability_name) + , m_previous_id(PluginAuditManager::instance().current_plugin()) , m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) + , m_previous_root_frames(std::move(PluginAuditManager::m_root_frames)) { PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::m_scoped_allowed_roots.clear(); + PluginAuditManager::m_root_frames.clear(); } ScopedPluginAuditContext::~ScopedPluginAuditContext() { - PluginAuditManager::instance().set_current_plugin(m_previous_id); - PluginAuditManager::instance().set_current_capability(m_previous_capability); + PluginAuditManager& mgr = PluginAuditManager::instance(); + + // Let this scope's root frames go while it is still the current context, and as its own + // plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer + // must never be judged as the outer plugin, or bypass the audit when there is none. A + // finalizer may record a new root frame, so repeat until none is held. + if (!PluginAuditManager::m_root_frames.empty()) { + assert(PyGILState_Check()); + mgr.set_current_plugin(m_plugin_key); + mgr.set_current_capability(m_capability); + while (!PluginAuditManager::m_root_frames.empty()) { + std::vector frames = std::move(PluginAuditManager::m_root_frames); + PluginAuditManager::m_root_frames.clear(); + mgr.release_frames(frames); + } + } + + mgr.set_current_plugin(m_previous_id); + mgr.set_current_capability(m_previous_capability); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); + PluginAuditManager::m_root_frames = std::move(m_previous_root_frames); } // --------------------------------------------------------------------------- @@ -296,16 +353,18 @@ void PluginAuditManager::add_denied_filename(const std::string& filename) std::vector PluginAuditManager::default_denied_filenames() { // AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key - // on its mode, and is a non-static member we cannot call without an instance. Denying all + // on its mode, and is a non-static member we cannot call without an instance. Denying all // four names is cheaper and more robust than replicating that; the two unused names cost one - // string comparison each. Single-sourced here so install_hook() and the tests seed from the - // same list and cannot drift apart. + // string comparison each. Keep the plugin install-state sidecar here too: it controls which + // packages load on startup and must remain host-managed. Single-sourced so install_hook() and + // the tests seed from the same list and cannot drift apart. return { SLIC3R_APP_KEY ".conf", GCODEVIEWER_APP_KEY ".conf", SLIC3R_APP_KEY ".ini", GCODEVIEWER_APP_KEY ".ini", secret_constants::USER_SECRET_FILENAME, + ".install_state.json", }; } @@ -575,34 +634,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const return true; } -bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key, - const std::vector& call_site_ids) const -{ - if (plugin_key.empty() || call_site_ids.empty()) - return false; - - std::lock_guard lock(m_mutex); - auto it = m_approved_call_sites.find(plugin_key); - if (it == m_approved_call_sites.end()) - return false; - - for (const auto& id : call_site_ids) - if (it->second.count(id)) - return true; - return false; -} - -void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key, - const std::vector& call_site_ids) -{ - if (plugin_key.empty() || call_site_ids.empty()) - return; - - std::lock_guard lock(m_mutex); - auto& approved = m_approved_call_sites[plugin_key]; - approved.insert(call_site_ids.begin(), call_site_ids.end()); -} - // --------------------------------------------------------------------------- // The C-level audit hook // --------------------------------------------------------------------------- @@ -663,40 +694,88 @@ std::string python_str(PyObject* object) return result; } -std::vector call_site_identities(const std::string& plugin_root) -{ - std::vector ids; - if (plugin_root.empty()) - return ids; +// The interpreter's stdlib folders, and the site-packages folders inside them that hold +// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only +// touched while the GIL is held. +static std::vector s_stdlib_dirs; +static std::vector s_site_package_dirs; +static std::unordered_map s_stdlib_file_verdicts; // co_filename -> is stdlib +void collect_stdlib_dirs() +{ + s_stdlib_dirs.clear(); + s_site_package_dirs.clear(); + s_stdlib_file_verdicts.clear(); + + PyObject* sysconfig = PyImport_ImportModule("sysconfig"); + PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr; + if (paths && PyDict_Check(paths)) { + const auto collect = [paths](const char* key, std::vector& out) { + const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed + if (dir.empty()) + return; + boost::system::error_code ec; + const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec); + out.push_back(ec ? boost::filesystem::path(dir) : resolved); + }; + collect("stdlib", s_stdlib_dirs); + collect("platstdlib", s_stdlib_dirs); + collect("purelib", s_site_package_dirs); + collect("platlib", s_site_package_dirs); + } + Py_XDECREF(paths); + Py_XDECREF(sysconfig); + if (PyErr_Occurred()) { + PyErr_Clear(); + BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig"; + } +} + +bool is_stdlib_frame(PyObject* frame) +{ + PyCodeObject* code = PyFrame_GetCode(reinterpret_cast(frame)); // new reference + const std::string filename = python_unicode(code->co_filename); + Py_DECREF(code); + return PluginAuditManager::is_stdlib_file(filename); +} + +// The frames on this thread's Python stack, innermost first. The pointers stay valid while the +// caller runs, because a running activation keeps its frame object alive. +std::vector stack_frames() +{ + std::vector frames; PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference Py_XINCREF(frame); // normalize to an owned reference for the loop below - while (frame) { - PyCodeObject* code = PyFrame_GetCode(frame); // new reference - const std::string filename = python_unicode(reinterpret_cast(code->co_filename)); - const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root); - - std::string id; - if (!is_plugin_frame && !filename.empty()) { - const std::string funcname = python_unicode(reinterpret_cast(code->co_name)); - id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno); - } - Py_DECREF(code); - + frames.push_back(reinterpret_cast(frame)); PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack Py_DECREF(frame); frame = back; - - if (is_plugin_frame) - break; - if (!id.empty()) - ids.push_back(std::move(id)); } + return frames; +} - Py_XDECREF(frame); // only holds a reference here if the loop exited via break +// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended +// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so +// it stays held until the scope ends. +void take_returned_frames(std::vector& held, std::vector& dropped) +{ + if (held.empty()) + return; - return ids; + const std::vector stack = stack_frames(); + const auto running = [&stack](PyObject* frame) { + if (std::find(stack.begin(), stack.end(), frame) != stack.end()) + return true; + // NULL once the generator has finished or been deleted: its frame then owns its own data. + PyObject* generator = PyFrame_GetGenerator(reinterpret_cast(frame)); // new reference + const bool suspended = generator != nullptr; + Py_XDECREF(generator); + return suspended; + }; + const auto returned = std::stable_partition(held.begin(), held.end(), running); + dropped.insert(dropped.end(), returned, held.end()); + held.erase(returned, held.end()); } static const std::unordered_set two_path_fs_events{ @@ -717,12 +796,15 @@ static const std::unordered_map> audit_targ {"socket.getnameinfo", {0}}, {"socket.getservbyname", {0}}, {"socket.getservbyport", {0}}, + {"socket.sendto", {1}}, + {"socket.sendmsg", {1}}, {"os.system", {0}}, {"subprocess.Popen", {1, 0}}, {"os.posix_spawn", {1, 0}}, {"os.spawn", {2, 1}}, {"os.startfile", {0}}, + {"os.startfile/2", {0}}, {"pty.spawn", {0}}, {"_winapi.CreateProcess", {1, 0}}, {"_posixsubprocess.fork_exec", {0}}, @@ -767,7 +849,11 @@ std::vector audit_targets(const std::string& event_name, AuditEvent const auto it = audit_target_arg_indices.find(event_name); if (it != audit_target_arg_indices.end()) { for (const Py_ssize_t index : it->second) { - std::string value = python_str(tuple_item(args, index)); + // None is an argument left out (sendmsg without an address), not a target named "None". + PyObject* const item = tuple_item(args, index); + if (item == Py_None) + continue; + std::string value = python_str(item); if (!value.empty()) { targets.push_back(std::move(value)); break; @@ -778,8 +864,14 @@ std::vector audit_targets(const std::string& event_name, AuditEvent return targets; } -std::vector* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) +// The saved list that grants an event: its category's target list, or the event-grant list for +// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an +// os.fork grant is not an os.exec of a command named "os.fork"). +std::vector* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions) { + if (by_event) + return &permissions.events; + switch (category) { case AuditEventCategory::FsRead: return &permissions.fs_read; case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; @@ -792,6 +884,13 @@ std::vector* permission_list_for(AuditEventCategory category, Plugi } } +// An event with no target is granted by its effective category and name, e.g. "FsRead:open" +// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open"). +std::string event_grant_key(AuditEventCategory category, const std::string& event_name) +{ + return std::string(category_name(category)) + ":" + event_name; +} + bool has_permission(const std::vector& granted, const std::string& target) { return std::find(granted.begin(), granted.end(), target) != granted.end(); @@ -902,22 +1001,25 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // freshly because the async prompt outlives the caller's stack copy of it. -void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector& targets) +void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector& keys) { PluginInstallState state; if (!PluginManager::instance().get_install_state(plugin_key, state)) return; - std::vector* permission_list = permission_list_for(category, state.permissions); + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); if (!permission_list) return; - for (const auto& target : targets) - persist_permission(plugin_key, state, *permission_list, target); + for (const auto& key : keys) + persist_permission(plugin_key, state, *permission_list, key); } -// Requests permission for an audited event, returning true when it is already granted or the user -// approves an inline prompt. +static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; + +// Asks the user to grant an audited event, returning true when they approve an inline prompt. +// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has +// no target (by_event), which the prompt does not show as a target. // // An audited event can fire on a thread the UI thread may itself be blocked waiting on: the // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and @@ -929,56 +1031,205 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without // re-prompting. bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, - const std::string& event_name, const std::vector& unresolved) + const std::string& event_name, bool by_event, const std::vector& keys) { + const std::vector shown = by_event ? std::vector{} : keys; + + if (s_permission_prompt_for_testing) + return s_permission_prompt_for_testing(event_name, shown); + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) return false; if (wxIsMainThread()) - return prompt_for_targets(category, plugin_name, event_name, unresolved); + return prompt_for_targets(category, plugin_name, event_name, shown); - GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() { + GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() { if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) return; - if (prompt_for_targets(category, plugin_name, event_name, unresolved)) - persist_grant(plugin_key, category, unresolved); + if (prompt_for_targets(category, plugin_name, event_name, shown)) + persist_grant(plugin_key, category, by_event, keys); }); return false; } +// Decides an event that no allowed root or running operation covers: a saved grant allows it, +// otherwise the user is asked and a Yes is saved. int decide_audited_event(PluginAuditManager& mgr, PluginInstallState& state, const std::string& plugin_key, const std::string& plugin_name, const std::string& event_name, AuditEventCategory category, - const std::vector& targets, - std::vector* permission_list, - const std::vector& call_site_ids) + const std::vector& targets) { - std::vector unresolved = targets; + const bool by_event = targets.empty(); + const std::vector keys = by_event ? std::vector{event_grant_key(category, event_name)} : targets; + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); + + std::vector unresolved = keys; if (permission_list) { unresolved.clear(); - for (const auto& target : targets) - if (!has_permission(*permission_list, target)) - unresolved.push_back(target); - if (!targets.empty() && unresolved.empty()) + for (const auto& key : keys) + if (!has_permission(*permission_list, key)) + unresolved.push_back(key); + if (unresolved.empty()) return 0; } - if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) + // Letting a root frame go runs finalizers inside a scope destructor or another event's hook, + // where no dialog may open: what they do must already be granted. + if (mgr.releasing_frames()) + return report_denied(mgr, event_name, {false, "audit permission required"}); + + if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved)) return report_denied(mgr, event_name, {false, "audit permission required"}); if (permission_list) - for (const auto& target : unresolved) - persist_permission(plugin_key, state, *permission_list, target); - - mgr.record_approved_call_sites(plugin_key, call_site_ids); + for (const auto& key : unresolved) + persist_permission(plugin_key, state, *permission_list, key); return 0; } +// Judges an audited event raised while a plugin context is active. +int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args) +{ + const bool fs_category = is_fs_category(event_type); + const std::vector targets = audit_targets(event_name, event_type, args); + + // A denied path (secrets, certificates, config files -- see is_denied_path) is an + // unconditional block: checked before the running-operation check below, so no granted + // operation can launder access to one, and before the allowed-root shortcut further down, + // so an allowed root (e.g. the read-only resources folder) cannot make a denied path + // underneath it reachable. + if (fs_category) { + for (const auto& target : targets) { + if (mgr.is_denied_path(boost::filesystem::path(target))) + return report_denied(mgr, event_name, {false, "denied path"}, target); + } + } + + // A nested event of an operation already granted, e.g. socket.connect inside an approved + // urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a + // redirect or a nested urlopen() inside a running request is not covered by it. + if (event_name != "urllib.Request" && mgr.covered_by_root_frame()) + return 0; + + // A filesystem target that resolves entirely inside a pre-determined allowed root -- the + // plugin system's own data_dir() tree (which holds each plugin's storage folder and the + // installed/system profile cache), the read-only bundled resources folder, or a per-call + // scoped root such as the current G-code folder -- is part of the plugin system's normal + // workflow and does not need a prompt. + if (fs_category && !targets.empty()) { + const bool is_write = event_type != AuditEventCategory::FsRead; + const bool all_inside_allowed_root = + std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { + return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed; + }); + if (all_inside_allowed_root) + return 0; + } + + PluginInstallState state; + const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state); + if (!have_install_state) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin(); + } + + const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name; + + const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets); + + // A granted operation covers its nested events while it runs. A targetless grant names no + // operation, so it covers nothing else. + if (result == 0 && !targets.empty() && operation_root_events.count(event_name)) + mgr.record_root_frame(); + return result; +} + } // namespace PluginAuditDetail +void PluginAuditManager::record_root_frame() +{ + PyObject* frame = reinterpret_cast(PyEval_GetFrame()); // borrowed reference + if (!frame || !PluginAuditDetail::is_stdlib_frame(frame)) + return; + if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end()) + return; // glob.glob and glob.glob/2 share a frame + Py_INCREF(frame); + m_root_frames.push_back(frame); +} + +bool PluginAuditManager::covered_by_root_frame() const +{ + if (m_root_frames.empty()) + return false; + + // Only stdlib frames may sit between the event and the operation: plugin or library code + // the operation calls back into (an os.walk onerror, a urllib handler) acts on its own. + for (PyObject* frame : PluginAuditDetail::stack_frames()) { + if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end()) + return true; + if (!PluginAuditDetail::is_stdlib_frame(frame)) + return false; + } + return false; +} + +bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; } + +bool PluginAuditManager::is_stdlib_file(const std::string& filename) +{ + // Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename. + if (boost::algorithm::starts_with(filename, "& dirs) { + return std::any_of(dirs.begin(), dirs.end(), + [&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); }); + }; + it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs); + } + return it->second; +} + +void PluginAuditManager::release_frames(std::vector& frames) +{ + if (frames.empty()) + return; + + // A finalizer's own denial must neither replace the PermissionError of the event being + // decided nor leave a stale denial behind for the next call. + PyObject* const error = PyErr_GetRaisedException(); + const bool denial_pending = m_audit_denial_pending; + const bool has_violation = m_has_last_violation; + const AuditViolation violation = m_last_violation; + + std::vector released; + released.swap(frames); + ++m_releasing_frames; + for (PyObject* frame : released) + Py_DECREF(frame); + --m_releasing_frames; + + m_audit_denial_pending = denial_pending; + m_has_last_violation = has_violation; + m_last_violation = violation; + PyErr_SetRaisedException(error); +} + +void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt) +{ + PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt); +} + int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) { auto* mgr = static_cast(user_data); @@ -1006,62 +1257,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user if (event_type == AuditEventCategory::None) return 0; - const bool fs_category = is_fs_category(event_type); - const std::vector targets = PluginAuditDetail::audit_targets(event_name, event_type, args); - - // A denied path (secrets, certificates, config files -- see is_denied_path) is an - // unconditional block: checked before the ancestor-cascade check below, so a cascade - // approval recorded for an unrelated action can never launder access to one, and before - // the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources - // folder) cannot make a denied path underneath it reachable. - if (fs_category) { - for (const auto& target : targets) { - if (mgr->is_denied_path(boost::filesystem::path(target))) - return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target); - } - } - - PluginDescriptor plugin_descriptor; - PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor); - - // Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request - // was already approved, socket.connect won't trigger another permission dialog. - const std::vector call_site_ids = - PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root); - if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) - return 0; - - // A filesystem target that resolves entirely inside a pre-determined allowed root -- the - // plugin system's own data_dir() tree (which holds each plugin's storage folder and the - // installed/system profile cache), the read-only bundled resources folder, or a per-call - // scoped root such as the current G-code folder -- is part of the plugin system's normal - // workflow and does not need a prompt. - if (fs_category && !targets.empty()) { - const bool is_write = event_type != AuditEventCategory::FsRead; - const bool all_inside_allowed_root = - std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { - return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed; - }); - if (all_inside_allowed_root) - return 0; - } - - PluginInstallState state; - const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state); - if (!have_install_state) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin(); - } - - const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name; - - std::vector* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions); - - return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name, - event_type, targets, permission_list, call_site_ids); + // Root frames whose operation has returned are let go once this event is decided, so that a + // finalizer they run re-enters a hook whose held frames are already consistent. + std::vector returned_frames; + PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames); + const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args); + mgr->release_frames(returned_frames); + return result; } void PluginAuditManager::install_hook() { + // Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame). + PluginAuditDetail::collect_stdlib_dirs(); + // data_dir() is the primary globally-allowed root during plugin execution: read+write. It // covers the plugin system's own workflow needs -- each plugin's storage folder // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) -- @@ -1076,8 +1285,9 @@ void PluginAuditManager::install_hook() // "cert" denied-path keyword seeded below. add_global_allowed_root(resources_dir(), /*allow_write=*/false); - // The user's app config and cloud credentials live directly inside data_dir(), so the - // root just granted would otherwise expose them to any plugin. Deny them by name. + // The user's app config and cloud credentials live directly inside data_dir(), and plugin + // install state lives beneath it, so the root just granted would otherwise expose them to + // plugins. Deny them by name. // // Seeded here rather than by each secret's owner because install_hook() runs during lazy // interpreter init and therefore provably precedes any plugin bytecode, whereas diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 41d3dca1e8..ac6d99a4da 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -4,10 +4,9 @@ // Via pybind11 so this file requests the same python3xx.lib as everything else. #include #include +#include #include #include -#include -#include #include #include @@ -144,15 +143,26 @@ public: void clear_last_violation(); bool last_violation(AuditViolation& violation) const; - // --- call-site cascade cache --- - // A single plugin action often fires several nested CPython audit events as it passes - // through stdlib layers (urllib.request calling http.client calling socket, for example). - // Once the user approves one event, every stdlib frame still on the stack for that call - // is recorded here by (filename, function, first line) identity. A later event whose own - // ancestor chain still contains one of those frames is the same logical action seen from - // a deeper layer, so it is auto-approved instead of prompting again. - bool has_approved_ancestor(const std::string& plugin_key, const std::vector& call_site_ids) const; - void record_approved_call_sites(const std::string& plugin_key, const std::vector& call_site_ids); + // --- operation-root frames --- + // One stdlib operation fires several nested audit events as it passes through lower layers: + // urlopen() raises urllib.Request, then http.client and socket events. When an operation's + // own root event is granted (by a saved grant or the user), the stdlib frame that raised it + // is held, and a later event whose stack reaches that frame through stdlib frames only is + // part of the same operation, so it is allowed without a prompt of its own. Plugin or + // library code the operation calls back into is judged on its own. A frame is let go once it + // returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may + // run finalizers of objects its locals held; they are judged as the plugin and never prompt. + void record_root_frame(); + bool covered_by_root_frame() const; + bool releasing_frames() const; + + // True when code from `filename` (a code object's co_filename) is the interpreter's own + // stdlib: the only code a root frame's coverage passes through. Valid after install_hook(). + static bool is_stdlib_file(const std::string& filename); + + // Answers permission prompts in place of the dialog, for tests. Production never sets it. + using PermissionPrompt = std::function& targets)>; + static void set_permission_prompt_for_testing(PermissionPrompt prompt); bool verbose_events = true; @@ -163,24 +173,32 @@ private: static int audit_hook(const char* event, PyObject* args, void* user_data); + // Drops the references in `frames`, keeping the caller's error and denial state intact. + void release_frames(std::vector& frames); + static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_capability_name; static thread_local std::vector m_scoped_allowed_roots; + static thread_local std::vector m_root_frames; // strong references + static thread_local int m_releasing_frames; static thread_local bool m_audit_denial_pending; static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; - // mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. + // mutable: is_denied_filename() is a const query that must lock. mutable std::mutex m_mutex; std::vector m_global_allowed_roots; std::vector m_denied_filenames; std::vector m_denied_path_keywords; - std::unordered_map> m_approved_call_sites; // plugin_key -> call-site ids }; // RAII guard that sets the current plugin key and capability name, restoring the previous // pair on scope exit. `capability_name` may be empty for calls that are not scoped to a // single capability. +// +// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before +// restoring the outer context. That needs the GIL whenever a frame is still held, so construct +// the scope after the GIL guard, to be destroyed before it. class ScopedPluginAuditContext { public: @@ -194,9 +212,12 @@ public: ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete; private: + std::string m_plugin_key; + std::string m_capability; std::string m_previous_id; std::string m_previous_capability; std::vector m_previous_scoped_roots; + std::vector m_previous_root_frames; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 1dd8391ad7..00077d3f65 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall read_string_list("network_socket", parsed.permissions.network_socket); read_string_list("process", parsed.permissions.process); read_string_list("threading", parsed.permissions.threading); + read_string_list("events", parsed.permissions.events); } if (state.contains("enabled") && state["enabled"].is_boolean()) @@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin {"network_socket", state.permissions.network_socket}, {"process", state.permissions.process}, {"threading", state.permissions.threading}, + {"events", state.permissions.events}, }; nlohmann::json capabilities = nlohmann::json::array(); diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 9c864ed73a..ee61d8c957 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -104,6 +104,8 @@ struct PluginPermissions std::vector network_socket; std::vector process; std::vector threading; + // Grants for audit events that expose no target, keyed ":" (e.g. "Socket:socket.__new__"). + std::vector events; }; struct PluginInstallState { diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index 915c578353..6d7ca9135c 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -102,9 +102,9 @@ public: // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. nlohmann::json get_default_config() const override { + pybind11::gil_scoped_acquire gil; ORCA_PY_AUDIT_SCOPE(); try { - pybind11::gil_scoped_acquire gil; pybind11::function override = pybind11::get_override(static_cast(this), "get_default_config"); if (!override) return Base::get_default_config(); diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 790f6e3ac6..812d69c072 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -282,11 +282,11 @@ public: int request_bind_ticket(std::string* ticket) override { try { - ORCA_PY_AUDIT_SCOPE(); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PythonGILState gil; if (!gil) throw std::runtime_error("Python interpreter is shutting down"); + ORCA_PY_AUDIT_SCOPE(); pybind11::function override = pybind11::get_override(static_cast(this), "request_bind_ticket"); if (!override) diff --git a/tests/data/texture_import/README.md b/tests/data/texture_import/README.md new file mode 100644 index 0000000000..c398b0ac28 --- /dev/null +++ b/tests/data/texture_import/README.md @@ -0,0 +1,20 @@ +`two_color_jpeg.glb` is a generated, closed tetrahedron with four faces, UVs, and +an embedded 8×8 red/blue JPEG base-color texture. Its coordinates span 20 mm. + +The core tests load all three fixtures through `Model::read_from_file`, convert +textures or MTL colors to painting with boundary smoothing, and match red and blue +to filaments. They run in CI without a display. A separate layout check compares +the smoothing search-tree type seen by a core consumer with the same type compiled +inside `libslic3r_cgal`. The mismatch is checked directly because undefined behavior +can be masked by which inline template implementations the linker selects. + +`two_color_sphere/test_two_color_sphere.obj` and its matching MTL are the original +red/blue material-color sphere supplied by RF47 in +[the PR discussion](https://github.com/OrcaSlicer/OrcaSlicer/pull/16607#issuecomment-6244475095). +They contain solid `Kd` colors and no texture images. + +`test_two_color_sphere_textured.obj` is a derived version of that sphere. Its MTL +uses white `Kd` values and references `textures/two_color.jpg` by a relative path; +the two face groups sample different halves of that image through their UVs. +The JPEG is extracted unchanged from `two_color_jpeg.glb`. This case must retain +both colors from the external texture, rather than fall back to MTL colors. diff --git a/tests/data/texture_import/two_color_jpeg.glb b/tests/data/texture_import/two_color_jpeg.glb new file mode 100644 index 0000000000..35ca7f9069 Binary files /dev/null and b/tests/data/texture_import/two_color_jpeg.glb differ diff --git a/tests/data/texture_import/two_color_sphere/test_two_color_sphere.mtl b/tests/data/texture_import/two_color_sphere/test_two_color_sphere.mtl new file mode 100644 index 0000000000..2c5433836b --- /dev/null +++ b/tests/data/texture_import/two_color_sphere/test_two_color_sphere.mtl @@ -0,0 +1,11 @@ +# test model for OrcaSlicer tex2color +newmtl Red +Ka 0.0000 0.0000 0.0000 +Kd 0.7500 0.2500 0.2500 +Ks 0.0000 0.0000 0.0000 +d 1.0000 +newmtl Blue +Ka 0.0000 0.0000 0.0000 +Kd 0.0700 0.2100 0.7000 +Ks 0.0000 0.0000 0.0000 +d 1.0000 diff --git a/tests/data/texture_import/two_color_sphere/test_two_color_sphere.obj b/tests/data/texture_import/two_color_sphere/test_two_color_sphere.obj new file mode 100644 index 0000000000..eb22031864 --- /dev/null +++ b/tests/data/texture_import/two_color_sphere/test_two_color_sphere.obj @@ -0,0 +1,457 @@ +# test model for OrcaSlicer tex2color +mtllib test_two_color_sphere.mtl +v 0.000000 0.000000 20.000000 +v 7.653669 0.000000 18.477591 +v 7.071068 2.928932 18.477591 +v 5.411961 5.411961 18.477591 +v 2.928932 7.071068 18.477591 +v 0.000000 7.653669 18.477591 +v -2.928932 7.071068 18.477591 +v -5.411961 5.411961 18.477591 +v -7.071068 2.928932 18.477591 +v -7.653669 0.000000 18.477591 +v -7.071068 -2.928932 18.477591 +v -5.411961 -5.411961 18.477591 +v -2.928932 -7.071068 18.477591 +v -0.000000 -7.653669 18.477591 +v 2.928932 -7.071068 18.477591 +v 5.411961 -5.411961 18.477591 +v 7.071068 -2.928932 18.477591 +v 14.142136 0.000000 14.142136 +v 13.065630 5.411961 14.142136 +v 10.000000 10.000000 14.142136 +v 5.411961 13.065630 14.142136 +v 0.000000 14.142136 14.142136 +v -5.411961 13.065630 14.142136 +v -10.000000 10.000000 14.142136 +v -13.065630 5.411961 14.142136 +v -14.142136 0.000000 14.142136 +v -13.065630 -5.411961 14.142136 +v -10.000000 -10.000000 14.142136 +v -5.411961 -13.065630 14.142136 +v -0.000000 -14.142136 14.142136 +v 5.411961 -13.065630 14.142136 +v 10.000000 -10.000000 14.142136 +v 13.065630 -5.411961 14.142136 +v 18.477591 0.000000 7.653669 +v 17.071068 7.071068 7.653669 +v 13.065630 13.065630 7.653669 +v 7.071068 17.071068 7.653669 +v 0.000000 18.477591 7.653669 +v -7.071068 17.071068 7.653669 +v -13.065630 13.065630 7.653669 +v -17.071068 7.071068 7.653669 +v -18.477591 0.000000 7.653669 +v -17.071068 -7.071068 7.653669 +v -13.065630 -13.065630 7.653669 +v -7.071068 -17.071068 7.653669 +v -0.000000 -18.477591 7.653669 +v 7.071068 -17.071068 7.653669 +v 13.065630 -13.065630 7.653669 +v 17.071068 -7.071068 7.653669 +v 20.000000 0.000000 0.000000 +v 18.477591 7.653669 0.000000 +v 14.142136 14.142136 0.000000 +v 7.653669 18.477591 0.000000 +v 0.000000 20.000000 0.000000 +v -7.653669 18.477591 0.000000 +v -14.142136 14.142136 0.000000 +v -18.477591 7.653669 0.000000 +v -20.000000 0.000000 0.000000 +v -18.477591 -7.653669 0.000000 +v -14.142136 -14.142136 0.000000 +v -7.653669 -18.477591 0.000000 +v -0.000000 -20.000000 0.000000 +v 7.653669 -18.477591 0.000000 +v 14.142136 -14.142136 0.000000 +v 18.477591 -7.653669 0.000000 +v 18.477591 0.000000 -7.653669 +v 17.071068 7.071068 -7.653669 +v 13.065630 13.065630 -7.653669 +v 7.071068 17.071068 -7.653669 +v 0.000000 18.477591 -7.653669 +v -7.071068 17.071068 -7.653669 +v -13.065630 13.065630 -7.653669 +v -17.071068 7.071068 -7.653669 +v -18.477591 0.000000 -7.653669 +v -17.071068 -7.071068 -7.653669 +v -13.065630 -13.065630 -7.653669 +v -7.071068 -17.071068 -7.653669 +v -0.000000 -18.477591 -7.653669 +v 7.071068 -17.071068 -7.653669 +v 13.065630 -13.065630 -7.653669 +v 17.071068 -7.071068 -7.653669 +v 14.142136 0.000000 -14.142136 +v 13.065630 5.411961 -14.142136 +v 10.000000 10.000000 -14.142136 +v 5.411961 13.065630 -14.142136 +v 0.000000 14.142136 -14.142136 +v -5.411961 13.065630 -14.142136 +v -10.000000 10.000000 -14.142136 +v -13.065630 5.411961 -14.142136 +v -14.142136 0.000000 -14.142136 +v -13.065630 -5.411961 -14.142136 +v -10.000000 -10.000000 -14.142136 +v -5.411961 -13.065630 -14.142136 +v -0.000000 -14.142136 -14.142136 +v 5.411961 -13.065630 -14.142136 +v 10.000000 -10.000000 -14.142136 +v 13.065630 -5.411961 -14.142136 +v 7.653669 0.000000 -18.477591 +v 7.071068 2.928932 -18.477591 +v 5.411961 5.411961 -18.477591 +v 2.928932 7.071068 -18.477591 +v 0.000000 7.653669 -18.477591 +v -2.928932 7.071068 -18.477591 +v -5.411961 5.411961 -18.477591 +v -7.071068 2.928932 -18.477591 +v -7.653669 0.000000 -18.477591 +v -7.071068 -2.928932 -18.477591 +v -5.411961 -5.411961 -18.477591 +v -2.928932 -7.071068 -18.477591 +v -0.000000 -7.653669 -18.477591 +v 2.928932 -7.071068 -18.477591 +v 5.411961 -5.411961 -18.477591 +v 7.071068 -2.928932 -18.477591 +v 0.000000 0.000000 -20.000000 +vt 0 0 +vn 0.000000 -0.000000 1.000000 +vn 0.439605 0.035510 0.897489 +vn 0.392553 0.201037 0.897489 +vn 0.285739 0.335958 0.897489 +vn 0.135423 0.419732 0.897489 +vn -0.035510 0.439605 0.897489 +vn -0.201037 0.392553 0.897489 +vn -0.335958 0.285739 0.897489 +vn -0.419732 0.135423 0.897489 +vn -0.439605 -0.035510 0.897489 +vn -0.392553 -0.201037 0.897489 +vn -0.285739 -0.335958 0.897489 +vn -0.135423 -0.419732 0.897489 +vn 0.035510 -0.439605 0.897489 +vn 0.201037 -0.392553 0.897489 +vn 0.335958 -0.285739 0.897489 +vn 0.419732 -0.135423 0.897489 +vn 0.725001 0.027230 0.688209 +vn 0.659393 0.302603 0.688209 +vn 0.493399 0.531907 0.688209 +vn 0.252289 0.680234 0.688209 +vn -0.027230 0.725001 0.688209 +vn -0.302603 0.659393 0.688209 +vn -0.531907 0.493399 0.688209 +vn -0.680234 0.252289 0.688209 +vn -0.725001 -0.027230 0.688209 +vn -0.659393 -0.302603 0.688209 +vn -0.493399 -0.531907 0.688209 +vn -0.252289 -0.680234 0.688209 +vn 0.027230 -0.725001 0.688209 +vn 0.302603 -0.659393 0.688209 +vn 0.531907 -0.493399 0.688209 +vn 0.680234 -0.252289 0.688209 +vn 0.927851 0.014745 0.372658 +vn 0.851580 0.368696 0.372658 +vn 0.645664 0.666516 0.372658 +vn 0.341451 0.862865 0.372658 +vn -0.014745 0.927851 0.372658 +vn -0.368696 0.851580 0.372658 +vn -0.666516 0.645664 0.372658 +vn -0.862865 0.341451 0.372658 +vn -0.927851 -0.014745 0.372658 +vn -0.851580 -0.368696 0.372658 +vn -0.645664 -0.666516 0.372658 +vn -0.341451 -0.862865 0.372658 +vn 0.014745 -0.927851 0.372658 +vn 0.368696 -0.851580 0.372658 +vn 0.666516 -0.645664 0.372658 +vn 0.862865 -0.341451 0.372658 +vn 1.000000 -0.000000 -0.000000 +vn 0.923880 0.382683 0.000000 +vn 0.707107 0.707107 0.000000 +vn 0.382683 0.923880 0.000000 +vn 0.000000 1.000000 0.000000 +vn -0.382683 0.923880 0.000000 +vn -0.707107 0.707107 0.000000 +vn -0.923880 0.382683 0.000000 +vn -1.000000 0.000000 0.000000 +vn -0.923880 -0.382683 -0.000000 +vn -0.707107 -0.707107 0.000000 +vn -0.382683 -0.923880 -0.000000 +vn -0.000000 -1.000000 -0.000000 +vn 0.382683 -0.923880 0.000000 +vn 0.707107 -0.707107 0.000000 +vn 0.923880 -0.382683 -0.000000 +vn 0.927851 -0.014745 -0.372658 +vn 0.862865 0.341451 -0.372658 +vn 0.666516 0.645664 -0.372658 +vn 0.368696 0.851580 -0.372658 +vn 0.014745 0.927851 -0.372658 +vn -0.341451 0.862865 -0.372658 +vn -0.645664 0.666516 -0.372658 +vn -0.851580 0.368696 -0.372658 +vn -0.927851 0.014745 -0.372658 +vn -0.862865 -0.341451 -0.372658 +vn -0.666516 -0.645664 -0.372658 +vn -0.368696 -0.851580 -0.372658 +vn -0.014745 -0.927851 -0.372658 +vn 0.341451 -0.862865 -0.372658 +vn 0.645664 -0.666516 -0.372658 +vn 0.851580 -0.368696 -0.372658 +vn 0.725001 -0.027230 -0.688209 +vn 0.680234 0.252289 -0.688209 +vn 0.531907 0.493399 -0.688209 +vn 0.302603 0.659393 -0.688209 +vn 0.027230 0.725001 -0.688209 +vn -0.252289 0.680234 -0.688209 +vn -0.493399 0.531907 -0.688209 +vn -0.659393 0.302603 -0.688209 +vn -0.725001 0.027230 -0.688209 +vn -0.680234 -0.252289 -0.688209 +vn -0.531907 -0.493399 -0.688209 +vn -0.302603 -0.659393 -0.688209 +vn -0.027230 -0.725001 -0.688209 +vn 0.252289 -0.680234 -0.688209 +vn 0.493399 -0.531907 -0.688209 +vn 0.659393 -0.302603 -0.688209 +vn 0.439605 -0.035510 -0.897489 +vn 0.419732 0.135423 -0.897489 +vn 0.335958 0.285739 -0.897489 +vn 0.201037 0.392553 -0.897489 +vn 0.035510 0.439605 -0.897489 +vn -0.135423 0.419732 -0.897489 +vn -0.285739 0.335958 -0.897489 +vn -0.392553 0.201037 -0.897489 +vn -0.439605 0.035510 -0.897489 +vn -0.419732 -0.135423 -0.897489 +vn -0.335958 -0.285739 -0.897489 +vn -0.201037 -0.392553 -0.897489 +vn -0.035510 -0.439605 -0.897489 +vn 0.135423 -0.419732 -0.897489 +vn 0.285739 -0.335958 -0.897489 +vn 0.392553 -0.201037 -0.897489 +vn 0.000000 -0.000000 -1.000000 +usemtl Red +f 1/1/1 2/1/2 3/1/3 +f 1/1/1 3/1/3 4/1/4 +f 1/1/1 4/1/4 5/1/5 +f 1/1/1 5/1/5 6/1/6 +f 1/1/1 6/1/6 7/1/7 +f 1/1/1 7/1/7 8/1/8 +f 1/1/1 8/1/8 9/1/9 +f 1/1/1 9/1/9 10/1/10 +f 1/1/1 10/1/10 11/1/11 +f 1/1/1 11/1/11 12/1/12 +f 1/1/1 12/1/12 13/1/13 +f 1/1/1 13/1/13 14/1/14 +f 1/1/1 14/1/14 15/1/15 +f 1/1/1 15/1/15 16/1/16 +f 1/1/1 16/1/16 17/1/17 +f 1/1/1 17/1/17 2/1/2 +f 2/1/2 18/1/18 19/1/19 +f 2/1/2 19/1/19 3/1/3 +f 3/1/3 19/1/19 20/1/20 +f 3/1/3 20/1/20 4/1/4 +f 4/1/4 20/1/20 21/1/21 +f 4/1/4 21/1/21 5/1/5 +f 5/1/5 21/1/21 22/1/22 +f 5/1/5 22/1/22 6/1/6 +f 6/1/6 22/1/22 23/1/23 +f 6/1/6 23/1/23 7/1/7 +f 7/1/7 23/1/23 24/1/24 +f 7/1/7 24/1/24 8/1/8 +f 8/1/8 24/1/24 25/1/25 +f 8/1/8 25/1/25 9/1/9 +f 9/1/9 25/1/25 26/1/26 +f 9/1/9 26/1/26 10/1/10 +f 10/1/10 26/1/26 27/1/27 +f 10/1/10 27/1/27 11/1/11 +f 11/1/11 27/1/27 28/1/28 +f 11/1/11 28/1/28 12/1/12 +f 12/1/12 28/1/28 29/1/29 +f 12/1/12 29/1/29 13/1/13 +f 13/1/13 29/1/29 30/1/30 +f 13/1/13 30/1/30 14/1/14 +f 14/1/14 30/1/30 31/1/31 +f 14/1/14 31/1/31 15/1/15 +f 15/1/15 31/1/31 32/1/32 +f 15/1/15 32/1/32 16/1/16 +f 16/1/16 32/1/32 33/1/33 +f 16/1/16 33/1/33 17/1/17 +f 17/1/17 33/1/33 18/1/18 +f 17/1/17 18/1/18 2/1/2 +f 18/1/18 34/1/34 35/1/35 +f 18/1/18 35/1/35 19/1/19 +f 19/1/19 35/1/35 36/1/36 +f 19/1/19 36/1/36 20/1/20 +f 20/1/20 36/1/36 37/1/37 +f 20/1/20 37/1/37 21/1/21 +f 21/1/21 37/1/37 38/1/38 +f 21/1/21 38/1/38 22/1/22 +f 22/1/22 38/1/38 39/1/39 +f 22/1/22 39/1/39 23/1/23 +f 23/1/23 39/1/39 40/1/40 +f 23/1/23 40/1/40 24/1/24 +f 24/1/24 40/1/40 41/1/41 +f 24/1/24 41/1/41 25/1/25 +f 25/1/25 41/1/41 42/1/42 +f 25/1/25 42/1/42 26/1/26 +f 26/1/26 42/1/42 43/1/43 +f 26/1/26 43/1/43 27/1/27 +f 27/1/27 43/1/43 44/1/44 +f 27/1/27 44/1/44 28/1/28 +f 28/1/28 44/1/44 45/1/45 +f 28/1/28 45/1/45 29/1/29 +f 29/1/29 45/1/45 46/1/46 +f 29/1/29 46/1/46 30/1/30 +f 30/1/30 46/1/46 47/1/47 +f 30/1/30 47/1/47 31/1/31 +f 31/1/31 47/1/47 48/1/48 +f 31/1/31 48/1/48 32/1/32 +f 32/1/32 48/1/48 49/1/49 +f 32/1/32 49/1/49 33/1/33 +f 33/1/33 49/1/49 34/1/34 +f 33/1/33 34/1/34 18/1/18 +f 34/1/34 50/1/50 51/1/51 +f 34/1/34 51/1/51 35/1/35 +f 35/1/35 51/1/51 52/1/52 +f 35/1/35 52/1/52 36/1/36 +f 36/1/36 52/1/52 53/1/53 +f 36/1/36 53/1/53 37/1/37 +f 37/1/37 53/1/53 54/1/54 +f 37/1/37 54/1/54 38/1/38 +f 38/1/38 54/1/54 55/1/55 +f 38/1/38 55/1/55 39/1/39 +f 39/1/39 55/1/55 56/1/56 +f 39/1/39 56/1/56 40/1/40 +f 40/1/40 56/1/56 57/1/57 +f 40/1/40 57/1/57 41/1/41 +f 41/1/41 57/1/57 58/1/58 +f 41/1/41 58/1/58 42/1/42 +f 42/1/42 58/1/58 59/1/59 +f 42/1/42 59/1/59 43/1/43 +f 43/1/43 59/1/59 60/1/60 +f 43/1/43 60/1/60 44/1/44 +f 44/1/44 60/1/60 61/1/61 +f 44/1/44 61/1/61 45/1/45 +f 45/1/45 61/1/61 62/1/62 +f 45/1/45 62/1/62 46/1/46 +f 46/1/46 62/1/62 63/1/63 +f 46/1/46 63/1/63 47/1/47 +f 47/1/47 63/1/63 64/1/64 +f 47/1/47 64/1/64 48/1/48 +f 48/1/48 64/1/64 65/1/65 +f 48/1/48 65/1/65 49/1/49 +f 49/1/49 65/1/65 50/1/50 +f 49/1/49 50/1/50 34/1/34 +usemtl Blue +f 50/1/50 66/1/66 67/1/67 +f 50/1/50 67/1/67 51/1/51 +f 51/1/51 67/1/67 68/1/68 +f 51/1/51 68/1/68 52/1/52 +f 52/1/52 68/1/68 69/1/69 +f 52/1/52 69/1/69 53/1/53 +f 53/1/53 69/1/69 70/1/70 +f 53/1/53 70/1/70 54/1/54 +f 54/1/54 70/1/70 71/1/71 +f 54/1/54 71/1/71 55/1/55 +f 55/1/55 71/1/71 72/1/72 +f 55/1/55 72/1/72 56/1/56 +f 56/1/56 72/1/72 73/1/73 +f 56/1/56 73/1/73 57/1/57 +f 57/1/57 73/1/73 74/1/74 +f 57/1/57 74/1/74 58/1/58 +f 58/1/58 74/1/74 75/1/75 +f 58/1/58 75/1/75 59/1/59 +f 59/1/59 75/1/75 76/1/76 +f 59/1/59 76/1/76 60/1/60 +f 60/1/60 76/1/76 77/1/77 +f 60/1/60 77/1/77 61/1/61 +f 61/1/61 77/1/77 78/1/78 +f 61/1/61 78/1/78 62/1/62 +f 62/1/62 78/1/78 79/1/79 +f 62/1/62 79/1/79 63/1/63 +f 63/1/63 79/1/79 80/1/80 +f 63/1/63 80/1/80 64/1/64 +f 64/1/64 80/1/80 81/1/81 +f 64/1/64 81/1/81 65/1/65 +f 65/1/65 81/1/81 66/1/66 +f 65/1/65 66/1/66 50/1/50 +f 66/1/66 82/1/82 83/1/83 +f 66/1/66 83/1/83 67/1/67 +f 67/1/67 83/1/83 84/1/84 +f 67/1/67 84/1/84 68/1/68 +f 68/1/68 84/1/84 85/1/85 +f 68/1/68 85/1/85 69/1/69 +f 69/1/69 85/1/85 86/1/86 +f 69/1/69 86/1/86 70/1/70 +f 70/1/70 86/1/86 87/1/87 +f 70/1/70 87/1/87 71/1/71 +f 71/1/71 87/1/87 88/1/88 +f 71/1/71 88/1/88 72/1/72 +f 72/1/72 88/1/88 89/1/89 +f 72/1/72 89/1/89 73/1/73 +f 73/1/73 89/1/89 90/1/90 +f 73/1/73 90/1/90 74/1/74 +f 74/1/74 90/1/90 91/1/91 +f 74/1/74 91/1/91 75/1/75 +f 75/1/75 91/1/91 92/1/92 +f 75/1/75 92/1/92 76/1/76 +f 76/1/76 92/1/92 93/1/93 +f 76/1/76 93/1/93 77/1/77 +f 77/1/77 93/1/93 94/1/94 +f 77/1/77 94/1/94 78/1/78 +f 78/1/78 94/1/94 95/1/95 +f 78/1/78 95/1/95 79/1/79 +f 79/1/79 95/1/95 96/1/96 +f 79/1/79 96/1/96 80/1/80 +f 80/1/80 96/1/96 97/1/97 +f 80/1/80 97/1/97 81/1/81 +f 81/1/81 97/1/97 82/1/82 +f 81/1/81 82/1/82 66/1/66 +f 82/1/82 98/1/98 99/1/99 +f 82/1/82 99/1/99 83/1/83 +f 83/1/83 99/1/99 100/1/100 +f 83/1/83 100/1/100 84/1/84 +f 84/1/84 100/1/100 101/1/101 +f 84/1/84 101/1/101 85/1/85 +f 85/1/85 101/1/101 102/1/102 +f 85/1/85 102/1/102 86/1/86 +f 86/1/86 102/1/102 103/1/103 +f 86/1/86 103/1/103 87/1/87 +f 87/1/87 103/1/103 104/1/104 +f 87/1/87 104/1/104 88/1/88 +f 88/1/88 104/1/104 105/1/105 +f 88/1/88 105/1/105 89/1/89 +f 89/1/89 105/1/105 106/1/106 +f 89/1/89 106/1/106 90/1/90 +f 90/1/90 106/1/106 107/1/107 +f 90/1/90 107/1/107 91/1/91 +f 91/1/91 107/1/107 108/1/108 +f 91/1/91 108/1/108 92/1/92 +f 92/1/92 108/1/108 109/1/109 +f 92/1/92 109/1/109 93/1/93 +f 93/1/93 109/1/109 110/1/110 +f 93/1/93 110/1/110 94/1/94 +f 94/1/94 110/1/110 111/1/111 +f 94/1/94 111/1/111 95/1/95 +f 95/1/95 111/1/111 112/1/112 +f 95/1/95 112/1/112 96/1/96 +f 96/1/96 112/1/112 113/1/113 +f 96/1/96 113/1/113 97/1/97 +f 97/1/97 113/1/113 98/1/98 +f 97/1/97 98/1/98 82/1/82 +f 114/1/114 99/1/99 98/1/98 +f 114/1/114 100/1/100 99/1/99 +f 114/1/114 101/1/101 100/1/100 +f 114/1/114 102/1/102 101/1/101 +f 114/1/114 103/1/103 102/1/102 +f 114/1/114 104/1/104 103/1/103 +f 114/1/114 105/1/105 104/1/104 +f 114/1/114 106/1/106 105/1/105 +f 114/1/114 107/1/107 106/1/106 +f 114/1/114 108/1/108 107/1/107 +f 114/1/114 109/1/109 108/1/108 +f 114/1/114 110/1/110 109/1/109 +f 114/1/114 111/1/111 110/1/110 +f 114/1/114 112/1/112 111/1/111 +f 114/1/114 113/1/113 112/1/112 +f 114/1/114 98/1/98 113/1/113 diff --git a/tests/data/texture_import/two_color_sphere/test_two_color_sphere_textured.mtl b/tests/data/texture_import/two_color_sphere/test_two_color_sphere_textured.mtl new file mode 100644 index 0000000000..a4fb020656 --- /dev/null +++ b/tests/data/texture_import/two_color_sphere/test_two_color_sphere_textured.mtl @@ -0,0 +1,8 @@ +# Derived texture-file regression: color comes from map_Kd, not Kd. +newmtl Red +Kd 1 1 1 +map_Kd textures/two_color.jpg + +newmtl Blue +Kd 1 1 1 +map_Kd textures/two_color.jpg diff --git a/tests/data/texture_import/two_color_sphere/test_two_color_sphere_textured.obj b/tests/data/texture_import/two_color_sphere/test_two_color_sphere_textured.obj new file mode 100644 index 0000000000..a62f0fefb6 --- /dev/null +++ b/tests/data/texture_import/two_color_sphere/test_two_color_sphere_textured.obj @@ -0,0 +1,458 @@ +# test model for OrcaSlicer tex2color +mtllib test_two_color_sphere_textured.mtl +v 0.000000 0.000000 20.000000 +v 7.653669 0.000000 18.477591 +v 7.071068 2.928932 18.477591 +v 5.411961 5.411961 18.477591 +v 2.928932 7.071068 18.477591 +v 0.000000 7.653669 18.477591 +v -2.928932 7.071068 18.477591 +v -5.411961 5.411961 18.477591 +v -7.071068 2.928932 18.477591 +v -7.653669 0.000000 18.477591 +v -7.071068 -2.928932 18.477591 +v -5.411961 -5.411961 18.477591 +v -2.928932 -7.071068 18.477591 +v -0.000000 -7.653669 18.477591 +v 2.928932 -7.071068 18.477591 +v 5.411961 -5.411961 18.477591 +v 7.071068 -2.928932 18.477591 +v 14.142136 0.000000 14.142136 +v 13.065630 5.411961 14.142136 +v 10.000000 10.000000 14.142136 +v 5.411961 13.065630 14.142136 +v 0.000000 14.142136 14.142136 +v -5.411961 13.065630 14.142136 +v -10.000000 10.000000 14.142136 +v -13.065630 5.411961 14.142136 +v -14.142136 0.000000 14.142136 +v -13.065630 -5.411961 14.142136 +v -10.000000 -10.000000 14.142136 +v -5.411961 -13.065630 14.142136 +v -0.000000 -14.142136 14.142136 +v 5.411961 -13.065630 14.142136 +v 10.000000 -10.000000 14.142136 +v 13.065630 -5.411961 14.142136 +v 18.477591 0.000000 7.653669 +v 17.071068 7.071068 7.653669 +v 13.065630 13.065630 7.653669 +v 7.071068 17.071068 7.653669 +v 0.000000 18.477591 7.653669 +v -7.071068 17.071068 7.653669 +v -13.065630 13.065630 7.653669 +v -17.071068 7.071068 7.653669 +v -18.477591 0.000000 7.653669 +v -17.071068 -7.071068 7.653669 +v -13.065630 -13.065630 7.653669 +v -7.071068 -17.071068 7.653669 +v -0.000000 -18.477591 7.653669 +v 7.071068 -17.071068 7.653669 +v 13.065630 -13.065630 7.653669 +v 17.071068 -7.071068 7.653669 +v 20.000000 0.000000 0.000000 +v 18.477591 7.653669 0.000000 +v 14.142136 14.142136 0.000000 +v 7.653669 18.477591 0.000000 +v 0.000000 20.000000 0.000000 +v -7.653669 18.477591 0.000000 +v -14.142136 14.142136 0.000000 +v -18.477591 7.653669 0.000000 +v -20.000000 0.000000 0.000000 +v -18.477591 -7.653669 0.000000 +v -14.142136 -14.142136 0.000000 +v -7.653669 -18.477591 0.000000 +v -0.000000 -20.000000 0.000000 +v 7.653669 -18.477591 0.000000 +v 14.142136 -14.142136 0.000000 +v 18.477591 -7.653669 0.000000 +v 18.477591 0.000000 -7.653669 +v 17.071068 7.071068 -7.653669 +v 13.065630 13.065630 -7.653669 +v 7.071068 17.071068 -7.653669 +v 0.000000 18.477591 -7.653669 +v -7.071068 17.071068 -7.653669 +v -13.065630 13.065630 -7.653669 +v -17.071068 7.071068 -7.653669 +v -18.477591 0.000000 -7.653669 +v -17.071068 -7.071068 -7.653669 +v -13.065630 -13.065630 -7.653669 +v -7.071068 -17.071068 -7.653669 +v -0.000000 -18.477591 -7.653669 +v 7.071068 -17.071068 -7.653669 +v 13.065630 -13.065630 -7.653669 +v 17.071068 -7.071068 -7.653669 +v 14.142136 0.000000 -14.142136 +v 13.065630 5.411961 -14.142136 +v 10.000000 10.000000 -14.142136 +v 5.411961 13.065630 -14.142136 +v 0.000000 14.142136 -14.142136 +v -5.411961 13.065630 -14.142136 +v -10.000000 10.000000 -14.142136 +v -13.065630 5.411961 -14.142136 +v -14.142136 0.000000 -14.142136 +v -13.065630 -5.411961 -14.142136 +v -10.000000 -10.000000 -14.142136 +v -5.411961 -13.065630 -14.142136 +v -0.000000 -14.142136 -14.142136 +v 5.411961 -13.065630 -14.142136 +v 10.000000 -10.000000 -14.142136 +v 13.065630 -5.411961 -14.142136 +v 7.653669 0.000000 -18.477591 +v 7.071068 2.928932 -18.477591 +v 5.411961 5.411961 -18.477591 +v 2.928932 7.071068 -18.477591 +v 0.000000 7.653669 -18.477591 +v -2.928932 7.071068 -18.477591 +v -5.411961 5.411961 -18.477591 +v -7.071068 2.928932 -18.477591 +v -7.653669 0.000000 -18.477591 +v -7.071068 -2.928932 -18.477591 +v -5.411961 -5.411961 -18.477591 +v -2.928932 -7.071068 -18.477591 +v -0.000000 -7.653669 -18.477591 +v 2.928932 -7.071068 -18.477591 +v 5.411961 -5.411961 -18.477591 +v 7.071068 -2.928932 -18.477591 +v 0.000000 0.000000 -20.000000 +vt 0.25 0.5 +vt 0.75 0.5 +vn 0.000000 -0.000000 1.000000 +vn 0.439605 0.035510 0.897489 +vn 0.392553 0.201037 0.897489 +vn 0.285739 0.335958 0.897489 +vn 0.135423 0.419732 0.897489 +vn -0.035510 0.439605 0.897489 +vn -0.201037 0.392553 0.897489 +vn -0.335958 0.285739 0.897489 +vn -0.419732 0.135423 0.897489 +vn -0.439605 -0.035510 0.897489 +vn -0.392553 -0.201037 0.897489 +vn -0.285739 -0.335958 0.897489 +vn -0.135423 -0.419732 0.897489 +vn 0.035510 -0.439605 0.897489 +vn 0.201037 -0.392553 0.897489 +vn 0.335958 -0.285739 0.897489 +vn 0.419732 -0.135423 0.897489 +vn 0.725001 0.027230 0.688209 +vn 0.659393 0.302603 0.688209 +vn 0.493399 0.531907 0.688209 +vn 0.252289 0.680234 0.688209 +vn -0.027230 0.725001 0.688209 +vn -0.302603 0.659393 0.688209 +vn -0.531907 0.493399 0.688209 +vn -0.680234 0.252289 0.688209 +vn -0.725001 -0.027230 0.688209 +vn -0.659393 -0.302603 0.688209 +vn -0.493399 -0.531907 0.688209 +vn -0.252289 -0.680234 0.688209 +vn 0.027230 -0.725001 0.688209 +vn 0.302603 -0.659393 0.688209 +vn 0.531907 -0.493399 0.688209 +vn 0.680234 -0.252289 0.688209 +vn 0.927851 0.014745 0.372658 +vn 0.851580 0.368696 0.372658 +vn 0.645664 0.666516 0.372658 +vn 0.341451 0.862865 0.372658 +vn -0.014745 0.927851 0.372658 +vn -0.368696 0.851580 0.372658 +vn -0.666516 0.645664 0.372658 +vn -0.862865 0.341451 0.372658 +vn -0.927851 -0.014745 0.372658 +vn -0.851580 -0.368696 0.372658 +vn -0.645664 -0.666516 0.372658 +vn -0.341451 -0.862865 0.372658 +vn 0.014745 -0.927851 0.372658 +vn 0.368696 -0.851580 0.372658 +vn 0.666516 -0.645664 0.372658 +vn 0.862865 -0.341451 0.372658 +vn 1.000000 -0.000000 -0.000000 +vn 0.923880 0.382683 0.000000 +vn 0.707107 0.707107 0.000000 +vn 0.382683 0.923880 0.000000 +vn 0.000000 1.000000 0.000000 +vn -0.382683 0.923880 0.000000 +vn -0.707107 0.707107 0.000000 +vn -0.923880 0.382683 0.000000 +vn -1.000000 0.000000 0.000000 +vn -0.923880 -0.382683 -0.000000 +vn -0.707107 -0.707107 0.000000 +vn -0.382683 -0.923880 -0.000000 +vn -0.000000 -1.000000 -0.000000 +vn 0.382683 -0.923880 0.000000 +vn 0.707107 -0.707107 0.000000 +vn 0.923880 -0.382683 -0.000000 +vn 0.927851 -0.014745 -0.372658 +vn 0.862865 0.341451 -0.372658 +vn 0.666516 0.645664 -0.372658 +vn 0.368696 0.851580 -0.372658 +vn 0.014745 0.927851 -0.372658 +vn -0.341451 0.862865 -0.372658 +vn -0.645664 0.666516 -0.372658 +vn -0.851580 0.368696 -0.372658 +vn -0.927851 0.014745 -0.372658 +vn -0.862865 -0.341451 -0.372658 +vn -0.666516 -0.645664 -0.372658 +vn -0.368696 -0.851580 -0.372658 +vn -0.014745 -0.927851 -0.372658 +vn 0.341451 -0.862865 -0.372658 +vn 0.645664 -0.666516 -0.372658 +vn 0.851580 -0.368696 -0.372658 +vn 0.725001 -0.027230 -0.688209 +vn 0.680234 0.252289 -0.688209 +vn 0.531907 0.493399 -0.688209 +vn 0.302603 0.659393 -0.688209 +vn 0.027230 0.725001 -0.688209 +vn -0.252289 0.680234 -0.688209 +vn -0.493399 0.531907 -0.688209 +vn -0.659393 0.302603 -0.688209 +vn -0.725001 0.027230 -0.688209 +vn -0.680234 -0.252289 -0.688209 +vn -0.531907 -0.493399 -0.688209 +vn -0.302603 -0.659393 -0.688209 +vn -0.027230 -0.725001 -0.688209 +vn 0.252289 -0.680234 -0.688209 +vn 0.493399 -0.531907 -0.688209 +vn 0.659393 -0.302603 -0.688209 +vn 0.439605 -0.035510 -0.897489 +vn 0.419732 0.135423 -0.897489 +vn 0.335958 0.285739 -0.897489 +vn 0.201037 0.392553 -0.897489 +vn 0.035510 0.439605 -0.897489 +vn -0.135423 0.419732 -0.897489 +vn -0.285739 0.335958 -0.897489 +vn -0.392553 0.201037 -0.897489 +vn -0.439605 0.035510 -0.897489 +vn -0.419732 -0.135423 -0.897489 +vn -0.335958 -0.285739 -0.897489 +vn -0.201037 -0.392553 -0.897489 +vn -0.035510 -0.439605 -0.897489 +vn 0.135423 -0.419732 -0.897489 +vn 0.285739 -0.335958 -0.897489 +vn 0.392553 -0.201037 -0.897489 +vn 0.000000 -0.000000 -1.000000 +usemtl Red +f 1/1/1 2/1/2 3/1/3 +f 1/1/1 3/1/3 4/1/4 +f 1/1/1 4/1/4 5/1/5 +f 1/1/1 5/1/5 6/1/6 +f 1/1/1 6/1/6 7/1/7 +f 1/1/1 7/1/7 8/1/8 +f 1/1/1 8/1/8 9/1/9 +f 1/1/1 9/1/9 10/1/10 +f 1/1/1 10/1/10 11/1/11 +f 1/1/1 11/1/11 12/1/12 +f 1/1/1 12/1/12 13/1/13 +f 1/1/1 13/1/13 14/1/14 +f 1/1/1 14/1/14 15/1/15 +f 1/1/1 15/1/15 16/1/16 +f 1/1/1 16/1/16 17/1/17 +f 1/1/1 17/1/17 2/1/2 +f 2/1/2 18/1/18 19/1/19 +f 2/1/2 19/1/19 3/1/3 +f 3/1/3 19/1/19 20/1/20 +f 3/1/3 20/1/20 4/1/4 +f 4/1/4 20/1/20 21/1/21 +f 4/1/4 21/1/21 5/1/5 +f 5/1/5 21/1/21 22/1/22 +f 5/1/5 22/1/22 6/1/6 +f 6/1/6 22/1/22 23/1/23 +f 6/1/6 23/1/23 7/1/7 +f 7/1/7 23/1/23 24/1/24 +f 7/1/7 24/1/24 8/1/8 +f 8/1/8 24/1/24 25/1/25 +f 8/1/8 25/1/25 9/1/9 +f 9/1/9 25/1/25 26/1/26 +f 9/1/9 26/1/26 10/1/10 +f 10/1/10 26/1/26 27/1/27 +f 10/1/10 27/1/27 11/1/11 +f 11/1/11 27/1/27 28/1/28 +f 11/1/11 28/1/28 12/1/12 +f 12/1/12 28/1/28 29/1/29 +f 12/1/12 29/1/29 13/1/13 +f 13/1/13 29/1/29 30/1/30 +f 13/1/13 30/1/30 14/1/14 +f 14/1/14 30/1/30 31/1/31 +f 14/1/14 31/1/31 15/1/15 +f 15/1/15 31/1/31 32/1/32 +f 15/1/15 32/1/32 16/1/16 +f 16/1/16 32/1/32 33/1/33 +f 16/1/16 33/1/33 17/1/17 +f 17/1/17 33/1/33 18/1/18 +f 17/1/17 18/1/18 2/1/2 +f 18/1/18 34/1/34 35/1/35 +f 18/1/18 35/1/35 19/1/19 +f 19/1/19 35/1/35 36/1/36 +f 19/1/19 36/1/36 20/1/20 +f 20/1/20 36/1/36 37/1/37 +f 20/1/20 37/1/37 21/1/21 +f 21/1/21 37/1/37 38/1/38 +f 21/1/21 38/1/38 22/1/22 +f 22/1/22 38/1/38 39/1/39 +f 22/1/22 39/1/39 23/1/23 +f 23/1/23 39/1/39 40/1/40 +f 23/1/23 40/1/40 24/1/24 +f 24/1/24 40/1/40 41/1/41 +f 24/1/24 41/1/41 25/1/25 +f 25/1/25 41/1/41 42/1/42 +f 25/1/25 42/1/42 26/1/26 +f 26/1/26 42/1/42 43/1/43 +f 26/1/26 43/1/43 27/1/27 +f 27/1/27 43/1/43 44/1/44 +f 27/1/27 44/1/44 28/1/28 +f 28/1/28 44/1/44 45/1/45 +f 28/1/28 45/1/45 29/1/29 +f 29/1/29 45/1/45 46/1/46 +f 29/1/29 46/1/46 30/1/30 +f 30/1/30 46/1/46 47/1/47 +f 30/1/30 47/1/47 31/1/31 +f 31/1/31 47/1/47 48/1/48 +f 31/1/31 48/1/48 32/1/32 +f 32/1/32 48/1/48 49/1/49 +f 32/1/32 49/1/49 33/1/33 +f 33/1/33 49/1/49 34/1/34 +f 33/1/33 34/1/34 18/1/18 +f 34/1/34 50/1/50 51/1/51 +f 34/1/34 51/1/51 35/1/35 +f 35/1/35 51/1/51 52/1/52 +f 35/1/35 52/1/52 36/1/36 +f 36/1/36 52/1/52 53/1/53 +f 36/1/36 53/1/53 37/1/37 +f 37/1/37 53/1/53 54/1/54 +f 37/1/37 54/1/54 38/1/38 +f 38/1/38 54/1/54 55/1/55 +f 38/1/38 55/1/55 39/1/39 +f 39/1/39 55/1/55 56/1/56 +f 39/1/39 56/1/56 40/1/40 +f 40/1/40 56/1/56 57/1/57 +f 40/1/40 57/1/57 41/1/41 +f 41/1/41 57/1/57 58/1/58 +f 41/1/41 58/1/58 42/1/42 +f 42/1/42 58/1/58 59/1/59 +f 42/1/42 59/1/59 43/1/43 +f 43/1/43 59/1/59 60/1/60 +f 43/1/43 60/1/60 44/1/44 +f 44/1/44 60/1/60 61/1/61 +f 44/1/44 61/1/61 45/1/45 +f 45/1/45 61/1/61 62/1/62 +f 45/1/45 62/1/62 46/1/46 +f 46/1/46 62/1/62 63/1/63 +f 46/1/46 63/1/63 47/1/47 +f 47/1/47 63/1/63 64/1/64 +f 47/1/47 64/1/64 48/1/48 +f 48/1/48 64/1/64 65/1/65 +f 48/1/48 65/1/65 49/1/49 +f 49/1/49 65/1/65 50/1/50 +f 49/1/49 50/1/50 34/1/34 +usemtl Blue +f 50/2/50 66/2/66 67/2/67 +f 50/2/50 67/2/67 51/2/51 +f 51/2/51 67/2/67 68/2/68 +f 51/2/51 68/2/68 52/2/52 +f 52/2/52 68/2/68 69/2/69 +f 52/2/52 69/2/69 53/2/53 +f 53/2/53 69/2/69 70/2/70 +f 53/2/53 70/2/70 54/2/54 +f 54/2/54 70/2/70 71/2/71 +f 54/2/54 71/2/71 55/2/55 +f 55/2/55 71/2/71 72/2/72 +f 55/2/55 72/2/72 56/2/56 +f 56/2/56 72/2/72 73/2/73 +f 56/2/56 73/2/73 57/2/57 +f 57/2/57 73/2/73 74/2/74 +f 57/2/57 74/2/74 58/2/58 +f 58/2/58 74/2/74 75/2/75 +f 58/2/58 75/2/75 59/2/59 +f 59/2/59 75/2/75 76/2/76 +f 59/2/59 76/2/76 60/2/60 +f 60/2/60 76/2/76 77/2/77 +f 60/2/60 77/2/77 61/2/61 +f 61/2/61 77/2/77 78/2/78 +f 61/2/61 78/2/78 62/2/62 +f 62/2/62 78/2/78 79/2/79 +f 62/2/62 79/2/79 63/2/63 +f 63/2/63 79/2/79 80/2/80 +f 63/2/63 80/2/80 64/2/64 +f 64/2/64 80/2/80 81/2/81 +f 64/2/64 81/2/81 65/2/65 +f 65/2/65 81/2/81 66/2/66 +f 65/2/65 66/2/66 50/2/50 +f 66/2/66 82/2/82 83/2/83 +f 66/2/66 83/2/83 67/2/67 +f 67/2/67 83/2/83 84/2/84 +f 67/2/67 84/2/84 68/2/68 +f 68/2/68 84/2/84 85/2/85 +f 68/2/68 85/2/85 69/2/69 +f 69/2/69 85/2/85 86/2/86 +f 69/2/69 86/2/86 70/2/70 +f 70/2/70 86/2/86 87/2/87 +f 70/2/70 87/2/87 71/2/71 +f 71/2/71 87/2/87 88/2/88 +f 71/2/71 88/2/88 72/2/72 +f 72/2/72 88/2/88 89/2/89 +f 72/2/72 89/2/89 73/2/73 +f 73/2/73 89/2/89 90/2/90 +f 73/2/73 90/2/90 74/2/74 +f 74/2/74 90/2/90 91/2/91 +f 74/2/74 91/2/91 75/2/75 +f 75/2/75 91/2/91 92/2/92 +f 75/2/75 92/2/92 76/2/76 +f 76/2/76 92/2/92 93/2/93 +f 76/2/76 93/2/93 77/2/77 +f 77/2/77 93/2/93 94/2/94 +f 77/2/77 94/2/94 78/2/78 +f 78/2/78 94/2/94 95/2/95 +f 78/2/78 95/2/95 79/2/79 +f 79/2/79 95/2/95 96/2/96 +f 79/2/79 96/2/96 80/2/80 +f 80/2/80 96/2/96 97/2/97 +f 80/2/80 97/2/97 81/2/81 +f 81/2/81 97/2/97 82/2/82 +f 81/2/81 82/2/82 66/2/66 +f 82/2/82 98/2/98 99/2/99 +f 82/2/82 99/2/99 83/2/83 +f 83/2/83 99/2/99 100/2/100 +f 83/2/83 100/2/100 84/2/84 +f 84/2/84 100/2/100 101/2/101 +f 84/2/84 101/2/101 85/2/85 +f 85/2/85 101/2/101 102/2/102 +f 85/2/85 102/2/102 86/2/86 +f 86/2/86 102/2/102 103/2/103 +f 86/2/86 103/2/103 87/2/87 +f 87/2/87 103/2/103 104/2/104 +f 87/2/87 104/2/104 88/2/88 +f 88/2/88 104/2/104 105/2/105 +f 88/2/88 105/2/105 89/2/89 +f 89/2/89 105/2/105 106/2/106 +f 89/2/89 106/2/106 90/2/90 +f 90/2/90 106/2/106 107/2/107 +f 90/2/90 107/2/107 91/2/91 +f 91/2/91 107/2/107 108/2/108 +f 91/2/91 108/2/108 92/2/92 +f 92/2/92 108/2/108 109/2/109 +f 92/2/92 109/2/109 93/2/93 +f 93/2/93 109/2/109 110/2/110 +f 93/2/93 110/2/110 94/2/94 +f 94/2/94 110/2/110 111/2/111 +f 94/2/94 111/2/111 95/2/95 +f 95/2/95 111/2/111 112/2/112 +f 95/2/95 112/2/112 96/2/96 +f 96/2/96 112/2/112 113/2/113 +f 96/2/96 113/2/113 97/2/97 +f 97/2/97 113/2/113 98/2/98 +f 97/2/97 98/2/98 82/2/82 +f 114/2/114 99/2/99 98/2/98 +f 114/2/114 100/2/100 99/2/99 +f 114/2/114 101/2/101 100/2/100 +f 114/2/114 102/2/102 101/2/101 +f 114/2/114 103/2/103 102/2/102 +f 114/2/114 104/2/104 103/2/103 +f 114/2/114 105/2/105 104/2/104 +f 114/2/114 106/2/106 105/2/105 +f 114/2/114 107/2/107 106/2/106 +f 114/2/114 108/2/108 107/2/107 +f 114/2/114 109/2/109 108/2/108 +f 114/2/114 110/2/110 109/2/109 +f 114/2/114 111/2/111 110/2/110 +f 114/2/114 112/2/112 111/2/111 +f 114/2/114 113/2/113 112/2/112 +f 114/2/114 98/2/98 113/2/113 diff --git a/tests/data/texture_import/two_color_sphere/textures/two_color.jpg b/tests/data/texture_import/two_color_sphere/textures/two_color.jpg new file mode 100644 index 0000000000..7c4e3aa676 Binary files /dev/null and b/tests/data/texture_import/two_color_sphere/textures/two_color.jpg differ diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 7ca8487965..b2d0ef9abe 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -1719,9 +1719,10 @@ TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", " const SparseInfillShape smooth = shape_for("100%"); REQUIRE(sharp.path_count > 0); - // Smoothing should leave the loop count about where it was; uncapping the smoothing reach, the - // regression this guards against, adds about 10%. - const size_t allowed_extra = sharp.path_count / 50; // 2% + // Smoothing should leave the path count about where it was; uncapping the smoothing reach, the + // regression this guards against, adds a quarter to a third. Outline ends next to each other are + // joined into one path, so a few loops more or less are a larger share of the count. + const size_t allowed_extra = sharp.path_count / 10; // 10% REQUIRE(smooth.path_count <= sharp.path_count + allowed_extra); // The outlines are still rounded. REQUIRE(smooth.point_count > sharp.point_count); @@ -2353,3 +2354,66 @@ TEST_CASE("Adaptive TPMS anchors match the printed infill", "[Fill][InternalBrid max_distance = std::max(max_distance, printed_tree.distance_from_lines(point)); CHECK(unscale(max_distance) <= 0.012); } + +TEST_CASE("Infill lines whose ends are next to each other join into one path", "[Fill]") +{ + // An out-and-back pair 1 mm apart, as multiline infill leaves, and a line 19 mm away. + Polylines lines { Polyline(Point::new_scale(0., 0.), Point::new_scale(10., 0.)), Polyline(Point::new_scale(0., 1.), Point::new_scale(10., 1.)), + Polyline(Point::new_scale(0., 20.), Point::new_scale(10., 20.)) }; + join_close_ends(lines, scale_(1.5), true); + + REQUIRE(lines.size() == 2); + const auto joined = std::find_if(lines.begin(), lines.end(), [](const Polyline &pl) { return pl.size() == 4; }); + REQUIRE(joined != lines.end()); + // Both lines plus the 1 mm between them, joined at one end only, as joining the other end too would close a loop. + CHECK_THAT(unscale(joined->length()), Catch::Matchers::WithinAbs(21., 1e-6)); + CHECK(joined->first_point() != joined->last_point()); +} + +TEST_CASE("Joined infill lines print each line once", "[Fill]") +{ + // The middle line comes first and both of its ends get joined, so the group has to be walked from an outer line. + Polylines lines { Polyline(Point::new_scale(0., 0.), Point::new_scale(10., 0.)), Polyline(Point::new_scale(0., 1.), Point::new_scale(10., 1.)), + Polyline(Point::new_scale(10., -1.), Point::new_scale(0., -1.)) }; + join_close_ends(lines, scale_(1.5), true); + + REQUIRE(lines.size() == 1); + CHECK(lines.front().size() == 6); + // Three 10 mm lines and two 1 mm joins. + CHECK_THAT(unscale(lines.front().length()), Catch::Matchers::WithinAbs(32., 1e-6)); +} + +TEST_CASE("Infill line ends are not joined across another line", "[Fill]") +{ + // A pair 1 mm apart whose left ends have a third line running between them, which a join there would cross. + Polylines lines { Polyline(Point::new_scale(0., 0.), Point::new_scale(10., 0.)), Polyline(Point::new_scale(0., 1.), Point::new_scale(10., 1.)), + Polyline(Point::new_scale(-5., 0.5), Point::new_scale(5., 0.5)) }; + join_close_ends(lines, scale_(1.5), true); + + REQUIRE(lines.size() == 2); + const auto joined = std::find_if(lines.begin(), lines.end(), [](const Polyline &pl) { return pl.size() == 4; }); + REQUIRE(joined != lines.end()); + // Joined on the right, where nothing is in between, so the left ends stay open. + CHECK(joined->first_point().x() == 0); + CHECK(joined->last_point().x() == 0); +} + +TEST_CASE("Infill lines that cannot reverse join only end to start", "[Fill]") +{ + const Point a(Point::new_scale(0., 0.)), b(Point::new_scale(10., 0.)), c(Point::new_scale(10., 1.)), d(Point::new_scale(0., 1.)); + SECTION("Ends that would need a reversal stay apart") { + Polylines lines { Polyline(a, b), Polyline(d, c) }; + join_close_ends(lines, scale_(1.5), false); + CHECK(lines.size() == 2); + } + SECTION("The end of one line joins the start of the next, both kept in their direction") { + Polylines lines { Polyline(c, d), Polyline(a, b) }; + join_close_ends(lines, scale_(1.5), false); + REQUIRE(lines.size() == 1); + const Points &points = lines.front().points; + REQUIRE(points.size() == 4); + auto position = [&points](const Point &p) { return std::find(points.begin(), points.end(), p) - points.begin(); }; + CHECK(position(a) < position(b)); + CHECK(position(c) < position(d)); + } +} diff --git a/tests/fff_print/test_gcode_timing.cpp b/tests/fff_print/test_gcode_timing.cpp index b3388340dc..cb1b2c4062 100644 --- a/tests/fff_print/test_gcode_timing.cpp +++ b/tests/fff_print/test_gcode_timing.cpp @@ -2,15 +2,18 @@ #include #include +#include #include #include #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" #include "libslic3r/Utils.hpp" #include "test_utils.hpp" +#include #include #include #include @@ -30,6 +33,7 @@ namespace Slic3r { enum ExtrusionRole : uint8_t; } using namespace Slic3r; using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; // Regression coverage for filament/tool-change time being folded into the first // pending motion block (an extrusion move) instead of the tool-change move, and @@ -570,6 +574,33 @@ double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double j } // namespace +TEST_CASE("Klipper extrusion ratio changes use its own corner velocity, not E jerk", "[GCodeTiming][JunctionDeviation]") +{ + const double jerk_e = GENERATE(0.0, 100.0); + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_jerk_e.values = {jerk_e, jerk_e}; + GCodeProcessor processor; + run_processor(processor, config, + "G90\nM83\nSET_VELOCITY_LIMIT ACCEL=1000 MINIMUM_CRUISE_RATIO=0\n" + "G1 X20 Y60 F6000\nM400\n" + "G1 X60 Y60 E0.8 F6000\nG1 X100 Y60 E1.6 F6000\nM400\n"); + // A 0.02 -> 0.04 extrusion ratio change is capped at 1 / 0.02 = 50 mm/s. + REQUIRE_THAT(corner_speed(processor.get_result()), Catch::Matchers::WithinAbs(50.0, 0.01)); +} + +TEST_CASE("Klipper legacy smoothing stays absolute across acceleration changes", "[GCodeTiming][Klipper]") +{ + const bool modern = GENERATE(false, true); + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + const std::string gcode = std::string("G90\nSET_VELOCITY_LIMIT ACCEL=1000 ") + + (modern ? "MINIMUM_CRUISE_RATIO=0.75\n" : "ACCEL_TO_DECEL=250\n") + + "G1 X40 F30000\nM400\nM204 S2000\nG1 X0\nM400\n"; + GCodeProcessor processor; + run_processor(processor, config, gcode.c_str()); + const double expected = modern ? 0.5 + 0.25 * std::sqrt(2.0) : 0.95; + REQUIRE_THAT(processor.get_time(PrintEstimatedStatistics::ETimeMode::Normal), WithinAbs(expected, 1e-5)); +} + TEST_CASE("Klipper corners are planned with junction deviation derived from the square corner velocity", "[GCodeTiming][JunctionDeviation]") { @@ -669,3 +700,172 @@ TEST_CASE("How fast a corner is taken does not depend on how much is extruded th Catch::Matchers::WithinRel(marlin, 0.02)); } } + +// Expected times are Klipper's own planner math (klippy/toolhead.py). For a move from rest to rest, +// LookAheadQueue.flush() reduces to cruise_v2 = min(vmax^2, d * accel, d * pseudo_accel) with +// pseudo_accel = accel * (1 - minimum_cruise_ratio), and Move.set_junction() to a trapezoid at accel; +// a move too short to reach its speed still spends that ratio of its length cruising. +namespace { + +constexpr double klipper_accel = 1000.0; // make_junction_config's acceleration +constexpr double klipper_cruise_ratio = 0.5; +constexpr double klipper_pseudo_accel = klipper_accel * (1.0 - klipper_cruise_ratio); + +// Time of a move that starts and ends at rest. +double rest_to_rest_time(double distance, double max_speed, double accel, double pseudo_accel) +{ + const double cruise_v2 = std::min(sqr(max_speed), distance * pseudo_accel); + const double cruise = std::sqrt(cruise_v2); + return 2.0 * cruise / accel + (distance - cruise_v2 / accel) / cruise; +} + +// Every run states the cruise ratio rather than relying on the estimator's built-in default. +void run_klipper(GCodeProcessor& proc, const std::string& gcode, + const FullPrintConfig& config = make_junction_config(gcfKlipper, 5.0, 0.0)) +{ + const std::string limits = "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=" + std::to_string(klipper_cruise_ratio) + "\n"; + run_processor(proc, config, (limits + gcode).c_str()); +} + +double klipper_time(const std::string& gcode, const FullPrintConfig& config = make_junction_config(gcfKlipper, 5.0, 0.0)) +{ + GCodeProcessor proc; + run_klipper(proc, gcode, config); + return proc.get_time(PrintEstimatedStatistics::ETimeMode::Normal); +} + +double time_after_retract(const GCodeProcessorResult& r) +{ + double t = 0.0; + bool after = false; + for (const auto& mv : r.moves) { + if (mv.type == EMoveType::Retract) + after = true; + else if (after) + t += mv.time[NORMAL]; + } + return t; +} + +} // namespace + +TEST_CASE("A straight line split into short segments is planned as one move", "[GCodeTiming][Klipper]") +{ + // 100 mm at 50 mm/s queues 2 s of motion, so the look-ahead flushes lazily part-way along the + // line; at 10 mm/s^2 the ramps span many segments and the cruise ratio caps the speed. + const int segments = GENERATE(1, 100, 1000); + std::ostringstream gcode; + gcode << "SET_VELOCITY_LIMIT ACCEL=10\n" << std::fixed << std::setprecision(3); + for (int i = 1; i <= segments; ++i) + gcode << "G1 X" << 100.0 * i / segments << " F3000\n"; + REQUIRE_THAT(klipper_time(gcode.str()), + WithinRel(rest_to_rest_time(100.0, 50.0, 10.0, 10.0 * (1.0 - klipper_cruise_ratio)), 1e-6)); +} + +TEST_CASE("Where the look-ahead flushes does not change the estimate", "[GCodeTiming][Klipper]") +{ + // A retraction stops the toolhead, so the zig-zag after it is planned on its own; the straight run + // before it only shifts where the queue flushes lazily (1 s of motion after a stop, then every 0.15 s). + auto zigzag_time = [](int run_moves) { + std::string gcode = "M83\nG91\n"; + for (int i = 0; i < run_moves; ++i) + gcode += "G1 X1 F6000\n"; + gcode += "G1 E-1 F2400\n"; + const char* moves[] = {"G1 X2 F3000\n", "G1 Y2 F9000\n", "G1 X2 F1200\n", "G1 Y-2 F9000\n"}; + for (int i = 0; i < 24; ++i) + gcode += moves[i % 4]; + GCodeProcessor proc; + run_klipper(proc, gcode); + return time_after_retract(proc.get_result()); + }; + const double reference = zigzag_time(0); + REQUIRE(reference > 0.0); + for (int run_moves : {30, 80, 105, 130}) { + INFO("run moves = " << run_moves); + REQUIRE_THAT(zigzag_time(run_moves), WithinAbs(reference, 1e-9)); + } +} + +TEST_CASE("A short move into a corner is limited by the centripetal term, not the corner velocity", + "[GCodeTiming][Klipper]") +{ + // A 50 mm/s square corner velocity allows 50 mm/s through the right angle; the 1 mm move into it + // allows sqrt(0.5 * 1 * 1000 * tan(45 deg)) = 22.4 mm/s (Move.calc_junction's move_centripetal_v2). + GCodeProcessor proc; + run_klipper(proc, "G90\nG1 X59 Y60 F6000\nM400\nG1 X60 Y60 F9000\nG1 X60 Y100 F9000\n", + make_junction_config(gcfKlipper, 50.0, 0.0)); + REQUIRE_THAT(corner_speed(proc.get_result()), WithinRel(std::sqrt(500.0), 1e-6)); +} + +TEST_CASE("A corner keeps the square corner velocity when the acceleration changes between its moves", + "[GCodeTiming][Klipper]") +{ + GCodeProcessor proc; + run_klipper(proc, "G90\nG1 X20 Y60 F6000\nM400\nG1 X60 Y60 F9000\nM204 S4000\nG1 X60 Y100 F9000\n"); + REQUIRE_THAT(corner_speed(proc.get_result()), WithinRel(5.0, 1e-6)); +} + +TEST_CASE("A retraction stops the toolhead and runs at the extruder's own limits", "[GCodeTiming][Klipper]") +{ + // An E acceleration of 0 is unset, and the retraction then uses the retract acceleration. + const auto [e_accel, retract_accel] = GENERATE(table({{20000.0, 20000.0}, {0.0, 3000.0}})); + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_acceleration_e.values = {e_accel, e_accel}; + config.machine_max_acceleration_retracting.values = {3000.0, 3000.0}; + GCodeProcessor proc; + run_klipper(proc, "G90\nM83\nG1 X60 Y60 F9000\nG1 E-1 F2400\nG1 X100 Y60 F9000\n", config); + REQUIRE_THAT(corner_speed(proc.get_result()), WithinAbs(0.0, 1e-6)); + const double expected = rest_to_rest_time(std::sqrt(2.0) * 60.0, 150.0, klipper_accel, klipper_pseudo_accel) + + rest_to_rest_time(1.0, 40.0, retract_accel, std::min(retract_accel, klipper_pseudo_accel)) + + rest_to_rest_time(40.0, 150.0, klipper_accel, klipper_pseudo_accel); + REQUIRE_THAT(proc.get_time(PrintEstimatedStatistics::ETimeMode::Normal), WithinRel(expected, 1e-6)); +} + +TEST_CASE("Extruder limits apply to retractions, not to extrusion along a path", "[GCodeTiming][Klipper]") +{ + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_speed_e.values = {10.0, 10.0}; + config.machine_max_acceleration_e.values = {10.0, 10.0}; + REQUIRE_THAT(klipper_time("M83\nG1 X40 E20 F30000\n", config), + WithinRel(rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6)); + REQUIRE_THAT(klipper_time("M83\nG1 E-1 F2400\n", config), + WithinRel(rest_to_rest_time(1.0, 10.0, 10.0, 10.0), 1e-6)); +} + +TEST_CASE("A Z move is limited by the Z axis speed and acceleration", "[GCodeTiming][Klipper]") +{ + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_speed_z.values = {20.0, 20.0}; + config.machine_max_acceleration_z.values = {100.0, 100.0}; + REQUIRE_THAT(klipper_time("G1 Z10 F1200\n", config), + WithinRel(rest_to_rest_time(10.0, 20.0, 100.0, 100.0), 1e-6)); +} + +TEST_CASE("M204 sets the acceleration from S, or the smaller of P and T", "[GCodeTiming][Klipper]") +{ + const char* m204 = GENERATE("M204 S500", "M204 P500 T2000", "M204 P2000 T500"); + REQUIRE_THAT(klipper_time(std::string(m204) + "\nG1 X100 F30000\n"), + WithinRel(rest_to_rest_time(100.0, 500.0, 500.0, 500.0 * (1.0 - klipper_cruise_ratio)), 1e-6)); +} + +TEST_CASE("SET_VELOCITY_LIMIT applies every limit on the line and ignores the comment", "[GCodeTiming][Klipper]") +{ + REQUIRE_THAT(klipper_time("SET_VELOCITY_LIMIT VELOCITY=80 ACCEL=500 MINIMUM_CRUISE_RATIO=0.2 ; ACCEL=9999\n" + "G1 X20 F30000\n"), + WithinRel(rest_to_rest_time(20.0, 80.0, 500.0, 500.0 * (1.0 - 0.2)), 1e-6)); +} + +TEST_CASE("ACCEL_TO_DECEL is ignored on a line that also sets the minimum cruise ratio", "[GCodeTiming][Klipper]") +{ + // Klipper 0.13 prefers the cruise ratio and later releases no longer read ACCEL_TO_DECEL at all. + // ACCEL_TO_DECEL comes last, so applying the parameters in line order would let it win. + REQUIRE_THAT(klipper_time("SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=" + std::to_string(klipper_cruise_ratio) + + " ACCEL_TO_DECEL=100\nG1 X40 F30000\n"), + WithinRel(rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6)); +} + +TEST_CASE("M400 without a delay waits for the queue to drain", "[GCodeTiming][Klipper]") +{ + REQUIRE_THAT(klipper_time("G90\nG1 X40 F30000\nM400\nG1 X80\n"), + WithinRel(2.0 * rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6)); +} diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index d491527db6..6dad1251f9 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -514,7 +514,7 @@ TEST_CASE("Acceleration and velocity limit commands print their values in genera const Case c = GENERATE(values({ {gcfKlipper, Command::KlipperLimits, 2000000, 25. / 3., false, {"SET_VELOCITY_LIMIT ACCEL=2000000 ", "ACCEL_TO_DECEL=1e+06 ", "SQUARE_CORNER_VELOCITY=8.33333\n"}, {}}, - {gcfKlipper, Command::KlipperLimits, 12345, 0., false, {"ACCEL=12345 ", "ACCEL_TO_DECEL=6172.5\n"}, {"SQUARE_CORNER_VELOCITY"}}, + {gcfKlipper, Command::KlipperLimits, 12345, 0., false, {"ACCEL=12345 ", "ACCEL_TO_DECEL=6172.5 ", "MINIMUM_CRUISE_RATIO=0.5\n"}, {"SQUARE_CORNER_VELOCITY"}}, {gcfKlipper, Command::KlipperLimits, 0, 0.25, true, {"SQUARE_CORNER_VELOCITY=0.25 ", "; adjust VELOCITY_LIMIT"}, {"ACCEL"}}, {gcfKlipper, Command::Print, 3001, 0., true, {"ACCEL=3001 ", "ACCEL_TO_DECEL=1500.5 ", "; adjust ACCEL_TO_DECEL", "; adjust acceleration"}, {}}, {gcfMarlinFirmware, Command::Print, 2500, 0., false, {"M204 P2500\n"}, {}}, @@ -1272,6 +1272,41 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos); } +TEST_CASE("Klipper acceleration commands send accel_to_decel both as ACCEL_TO_DECEL and as the cruise ratio", "[GCodeWriter]") +{ + // Klipper 0.13 defines MINIMUM_CRUISE_RATIO = 1 - ACCEL_TO_DECEL / ACCEL; older releases read only the + // first parameter and current ones only the second, so both have to describe the same limit. + GCodeWriter writer; + writer.config.gcode_flavor.value = gcfKlipper; + writer.config.accel_to_decel_enable.value = true; + // The writer appends "; adjust ..." comments by default; keep them out so the line ends at the ratio. + struct CommentGuard + { + bool saved = GCodeWriter::full_gcode_comment; + ~CommentGuard() { GCodeWriter::full_gcode_comment = saved; } + } comment_guard; + GCodeWriter::full_gcode_comment = false; + + SECTION("a 25% factor") { + writer.config.accel_to_decel_factor.value = 25; + REQUIRE_THAT(writer.set_print_acceleration(2000), + Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000 ACCEL_TO_DECEL=500 MINIMUM_CRUISE_RATIO=0.75")); + REQUIRE_THAT(writer.set_accel_and_jerk(3000, 0.0), + Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=3000 ACCEL_TO_DECEL=750 MINIMUM_CRUISE_RATIO=0.75")); + } + SECTION("a 100% factor turns the cruise ratio off") { + writer.config.accel_to_decel_factor.value = 100; + REQUIRE_THAT(writer.set_print_acceleration(2000), + Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000 ACCEL_TO_DECEL=2000 MINIMUM_CRUISE_RATIO=0\n")); + } + SECTION("neither is sent while the option is off") { + writer.config.accel_to_decel_enable.value = false; + const std::string line = writer.set_print_acceleration(2000); + REQUIRE_THAT(line, Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000")); + REQUIRE_THAT(line, !Catch::Matchers::ContainsSubstring("ACCEL_TO_DECEL") && !Catch::Matchers::ContainsSubstring("MINIMUM_CRUISE_RATIO")); + } +} + // Regression test for the belt-printer "illegal gantry move at print start" bug. // // On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index c094a656f7..78adb000be 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -202,6 +202,23 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", } } +TEST_CASE("The wipe tower sets Klipper's acceleration and accel_to_decel on one line", "[WipeTower]") +{ + // On separate lines, current Klipper finds nothing it reads on the ACCEL_TO_DECEL one and 0.13 + // derives the cruise ratio from the acceleration before the change. + DynamicPrintConfig config = wipe_tower_toolchange_config("klipper"); + // Type 2 towers leave the acceleration to the main G-code writer. + config.set_deserialize_strict({ { "wipe_tower_type", "type1" }, + { "accel_to_decel_enable", "1" }, { "accel_to_decel_factor", "50%" }, + { "default_acceleration", "1000" }, { "travel_acceleration", "2000" }, + { "machine_max_acceleration_extruding", "5000" } }); + const std::string tower = wipe_tower_regions(slice_with_prime_tower(config)); + REQUIRE_THAT(tower, Catch::Matchers::ContainsSubstring("SET_VELOCITY_LIMIT ACCEL=")); + CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(" MINIMUM_CRUISE_RATIO=0.5\n")); + CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring("SET_VELOCITY_LIMIT ACCEL_TO_DECEL") && + !Catch::Matchers::ContainsSubstring("M204")); +} + // What Print feeds the shared estimate. The libslic3r WipeTowerEstimate cases cannot see this: // they call the estimator directly. The estimate counts the filaments the print really uses, // so the two-filament shape gives the outer wall the second one. diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 5c0ecfbbde..44afdece7c 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -1,5 +1,9 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) +# Observe the geometry backend's actual CGAL layout without duplicating its flags. +# This source is only added when the test suite is enabled. +target_sources(libslic3r_cgal PRIVATE texture_import_cgal.cpp) + add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests.cpp test_3mf.cpp @@ -31,6 +35,7 @@ add_executable(${_TEST_NAME}_tests test_preset_diff.cpp test_vendor_cache.cpp test_preset_options.cpp + test_edge_grid.cpp test_elephant_foot_compensation.cpp test_fill_corner_smoothing.cpp test_filament_mixer.cpp @@ -68,10 +73,12 @@ add_executable(${_TEST_NAME}_tests test_wipe_path.cpp test_optimizers.cpp test_ordering_strategies.cpp + test_shortest_path.cpp # test_png_io.cpp test_indexed_triangle_set.cpp test_connected_bodies.cpp test_texture_displacement.cpp + test_texture_import.cpp test_instance_lock.cpp ../libnest2d/printer_parts.cpp ) diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 8017c60dcf..dcf808ab92 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") { REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys)); } +TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") { + using Catch::Matchers::WithinAbs; + + SECTION("float") { + ConfigOptionFloat opt(7.5); + REQUIRE_FALSE(opt.deserialize("abc")); + CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9)); + } + SECTION("int") { + ConfigOptionInt opt(3); + REQUIRE_FALSE(opt.deserialize("abc")); + CHECK(opt.value == 3); + } + SECTION("percent") { + ConfigOptionPercent opt(40.); + REQUIRE_FALSE(opt.deserialize("abc%")); + CHECK_THAT(opt.value, WithinAbs(40., 1e-9)); + } + SECTION("float or percent") { + ConfigOptionFloatOrPercent opt(0.4, false); + REQUIRE_FALSE(opt.deserialize("abc%")); + CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9)); + CHECK_FALSE(opt.percent); + } + SECTION("point") { + ConfigOptionPoint opt(Vec2d(1., 2.)); + REQUIRE_FALSE(opt.deserialize("5,abc")); + CHECK(opt.value == Vec2d(1., 2.)); + } + SECTION("point3") { + ConfigOptionPoint3 opt(Vec3d(1., 2., 3.)); + REQUIRE_FALSE(opt.deserialize("5,6,abc")); + CHECK(opt.value == Vec3d(1., 2., 3.)); + } +} + +TEST_CASE("A scalar option still takes a valid value", "[Config]") { + using Catch::Matchers::WithinAbs; + + ConfigOptionFloatOrPercent opt(0.4, false); + REQUIRE(opt.deserialize("50%")); + CHECK_THAT(opt.value, WithinAbs(50., 1e-9)); + CHECK(opt.percent); + + ConfigOptionPoint point; + REQUIRE(point.deserialize("3x4")); + CHECK(point.value == Vec2d(3., 4.)); +} + TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") { Slic3r::DynamicPrintConfig config; t_config_option_keys extra, keys; @@ -1678,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con } CHECK(enum_lists > 0); } + +TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]") +{ + std::set no_keys; + Slic3r::DynamicPrintConfig parent; + parent.set_deserialize_strict("filament_retract_before_wipe", "100%"); + parent.set_deserialize_strict("filament_ironing_flow", "15%"); + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_retract_before_wipe", "nil"); + child.set_deserialize_strict("filament_ironing_flow", "nil"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil"); + CHECK(parent.opt_serialize("filament_ironing_flow") == "nil"); +} + +SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]") +{ + std::set no_keys; + GIVEN("A two-variant filament parent") { + Slic3r::DynamicPrintConfig parent; + parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow"); + parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%"); + parent.set_deserialize_strict("filament_flow_ratio", "1,1"); + WHEN("the child disables the override on one variant and leaves a plain option nil on it") { + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow"); + child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%"); + child.set_deserialize_strict("filament_flow_ratio", "nil,1.1"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + THEN("the override column is disabled while the plain option's nil column inherits") { + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%"); + CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1"); + } + } + WHEN("the child lists the same variants in the other order") { + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard"); + child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil"); + child.set_deserialize_strict("filament_flow_ratio", "1.1,nil"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + THEN("columns are matched by variant name, the disabled one included") { + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%"); + CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1"); + } + } + WHEN("the child was saved with a single variant") { + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard"); + child.set_deserialize_strict("filament_retract_before_wipe", "nil"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + THEN("its one value applies to every variant of that extruder, as slicing does") { + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil"); + } + } + } +} + +TEST_CASE("Every listed filament override is nullable", "[Config]") +{ + std::set overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end()); + overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" }); + for (const auto &[key, def] : Slic3r::print_config_def.options) { + INFO(key); + CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1)); + if (overrides.count(key) == 1) + CHECK(def.nullable); + } +} diff --git a/tests/libslic3r/test_edge_grid.cpp b/tests/libslic3r/test_edge_grid.cpp new file mode 100644 index 0000000000..406ea198cf --- /dev/null +++ b/tests/libslic3r/test_edge_grid.cpp @@ -0,0 +1,33 @@ +#include +#include +#include "catch2/matchers/catch_matchers.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/EdgeGrid.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" +#include "libslic3r/libslic3r.h" + +using namespace Slic3r; + +TEST_CASE("EdgeGrid propagates signed distances across the interior and exterior", "[EdgeGrid][Regression]") +{ + const Polygon rectangle {Point::new_scale(-10., -12.), Point::new_scale(10., -12.), + Point::new_scale(10., 12.), Point::new_scale(-10., 12.)}; + const Polygons outlines {rectangle}; + const BoundingBox bounds(Point::new_scale(-30., -32.), Point::new_scale(30., 32.)); + EdgeGrid::Grid grid(bounds); + grid.create(outlines, scale_(1.)); + // Distances beyond the initialized edge band require backward propagation. + // With -fsanitize=shift, this also checks negative neighbour offsets. + grid.calculate_sdf(); + const auto distance = [&](double x, double y) { + return grid.signed_distance_bilinear(Point::new_scale(x, y)) * SCALING_FACTOR; + }; + REQUIRE_THAT(distance(0., 0.), Catch::Matchers::WithinAbs(-10., 0.01)); + for (double sign : {-1., 1.}) { + REQUIRE_THAT(distance(sign * 5., 0.), Catch::Matchers::WithinAbs(-5., 0.01)); + REQUIRE_THAT(distance(0., sign * 7.), Catch::Matchers::WithinAbs(-5., 0.01)); + REQUIRE_THAT(distance(sign * 20., 0.), Catch::Matchers::WithinAbs(10., 0.01)); + REQUIRE_THAT(distance(0., sign * 22.), Catch::Matchers::WithinAbs(10., 0.01)); + } +} diff --git a/tests/libslic3r/test_ordering_strategies.cpp b/tests/libslic3r/test_ordering_strategies.cpp index 97e5a83f60..f4df7e8b5e 100644 --- a/tests/libslic3r/test_ordering_strategies.cpp +++ b/tests/libslic3r/test_ordering_strategies.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -12,10 +13,13 @@ #include "libslic3r/Point.hpp" #include "libslic3r/GCode/OrderingStrategies.hpp" +#include "libslic3r/ShortestPath.hpp" #include "libslic3r/Geometry.hpp" #include #include +#include +#include #include using namespace Slic3r; @@ -304,3 +308,60 @@ TEST_CASE("snake_core handles three points", "[Snake]") { REQUIRE(is_permutation(p2, 3)); } + +// The shortest cycle through the points, trying every order. +static double shortest_cycle_length(const Points& centers) +{ + std::vector path(centers.size()); + std::iota(path.begin(), path.end(), 0); + double best = std::numeric_limits::max(); + do { + best = std::min(best, tsp_cycle_path_length(path, centers)); + } while (std::next_permutation(path.begin() + 1, path.end())); + return best; +} + +static Points random_centers(unsigned seed, size_t count) +{ + std::mt19937 rng(seed); + std::uniform_real_distribution<> coordinate(0., 200.); + Points centers; + for (size_t i = 0; i < count; ++i) { + // One draw per statement, as the evaluation order of function arguments is unspecified. + const double x = coordinate(rng); + const double y = coordinate(rng); + centers.emplace_back(Point::new_scale(x, y)); + } + return centers; +} + +TEST_CASE("tsp_exact_cycle finds the shortest cycle", "[TSPPostProcessing]") { + const size_t count = GENERATE(4, 5, 6, 8); + for (unsigned seed = 1; seed <= 10; ++seed) { + const Points centers = random_centers(seed, count); + const std::vector path = tsp_exact_cycle(centers); + REQUIRE(is_permutation(path, count)); + CHECK(euclidean_path_length(path, centers) <= shortest_cycle_length(centers) + SCALED_EPSILON); + } +} + +TEST_CASE("Default ordering tours up to ten points along the shortest cycle", "[TSPPostProcessing]") { + const bool from_start = GENERATE(false, true); + const Point start = Point::new_scale(-20., -20.); + for (unsigned seed = 1; seed <= 5; ++seed) { + const Points centers = random_centers(seed, 9); + const std::vector path = chain_points_with_postprocessing(centers, from_start ? &start : nullptr); + REQUIRE(is_permutation(path, centers.size())); + CHECK(euclidean_path_length(path, centers) <= shortest_cycle_length(centers) + SCALED_EPSILON); + } +} + +TEST_CASE("Default ordering of many points is never longer than the greedy cycle", "[TSPPostProcessing]") { + for (unsigned seed = 1; seed <= 10; ++seed) { + const Points centers = random_centers(seed, 150); + const std::vector path = chain_points_with_postprocessing(centers, nullptr); + REQUIRE(is_permutation(path, centers.size())); + CHECK(euclidean_path_length(path, centers) <= euclidean_path_length(chain_points(centers, nullptr), centers) + SCALED_EPSILON); + CHECK_FALSE(has_crossings(path, centers)); + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 1b4927f08c..9053fc5496 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -8417,3 +8417,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow"); CHECK(config.option("filament_multi_colour")->values.empty()); } + +TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]") +{ + ScopedPresetDataDir data_dir; + PresetBundle bundle; + + Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System"); + parent.config.option("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" }; + parent.config.option("filament_retract_before_wipe", true)->values = { 100., 100. }; + parent.config.option("filament_flow_ratio", true)->values = { 1., 1. }; + + // The child turns the override off on the first variant and changes a plain option on the second. + const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json"; + Preset child(Preset::TYPE_FILAMENT, "Child PLA"); + child.file = file.string(); + child.version = *Semver::parse("1.0.0"); + child.config = parent.config; + child.config.option(BBL_JSON_KEY_INHERITS, true)->value = parent.name; + child.config.option("filament_retract_before_wipe")->set_at_to_nil(0); + child.config.option("filament_flow_ratio")->values = { 1., 1.1 }; + REQUIRE(child.save(&parent.config)); + + // On disk the override keeps every column, so its nil can only mean "off"; a plain + // option still stores nil for the columns that match the parent. + DynamicPrintConfig saved; + std::map key_values; + std::string reason; + saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason); + REQUIRE(reason.empty()); + CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%"); + CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1"); + + PresetsConfigSubstitutions substitutions; + bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions, + ForwardCompatibilitySubstitutionRule::EnableSilent); + const Preset *loaded = bundle.filaments.find_preset("Child PLA"); + REQUIRE(loaded != nullptr); + CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%"); + CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1"); +} + +TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]") +{ + PresetBundle bundle; + add_inmemory_preset(bundle.filaments, "Parent PLA @System"); + Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System"); + // Every override is nil in both parent and child, so none is in the plain diff. + std::map key_values; + REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0); + // Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent. + CHECK(key_values.count("filament_retract_before_wipe") == 1); + CHECK(key_values["filament_retract_before_wipe"] == "nil"); + // An ironing override defaults to nil: an absent key stays nil, so it is not sent. + CHECK(key_values.count("filament_ironing_flow") == 0); +} diff --git a/tests/libslic3r/test_shortest_path.cpp b/tests/libslic3r/test_shortest_path.cpp new file mode 100644 index 0000000000..e4b8c60ddc --- /dev/null +++ b/tests/libslic3r/test_shortest_path.cpp @@ -0,0 +1,202 @@ +#include +#include +#include +#include + +#include "libslic3r/ExtrusionEntity.hpp" +#include "libslic3r/ExtrusionEntityCollection.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polyline.hpp" +#include "libslic3r/ShortestPath.hpp" +#include "libslic3r/libslic3r.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; + +namespace { + +// Infill-like paths: lines of 1 to 30 mm scattered over a 100 mm square. The collection owns them, and entities hands +// them out as the raw pointers chain_and_reorder_extrusion_entities() reorders. +struct Paths +{ + ExtrusionEntityCollection collection; + std::vector entities; + + Paths(unsigned seed, size_t count, bool can_reverse) + { + std::mt19937 rng(seed); + std::uniform_real_distribution<> coordinate(0., 100.), length(1., 30.), angle(0., 2. * M_PI); + Polylines lines; + for (size_t i = 0; i < count; ++ i) { + // One draw per statement, as the evaluation order of function arguments is unspecified. + const double x = coordinate(rng); + const double y = coordinate(rng); + const double l = length(rng); + const double t = angle(rng); + const Vec2d a(x, y); + const Vec2d b = a + l * Vec2d(std::cos(t), std::sin(t)); + lines.emplace_back(Point::new_scale(a), Point::new_scale(b)); + } + extrusion_entities_append_paths(collection.entities, std::move(lines), erInternalInfill, 0.1, 0.45f, 0.2f, can_reverse); + entities = collection.entities; + } +}; + +double travel(const std::vector &entities, const Point &start) +{ + double length = 0.; + Point last = start; + for (const ExtrusionEntity *ee : entities) { + length += (ee->first_point() - last).cast().norm(); + last = ee->last_point(); + } + return length; +} + +// Travel of the chain that always goes on to the nearest free path end. +double nearest_next_travel(const std::vector &entities, bool can_reverse, const Point &from) +{ + std::vector taken(entities.size(), 0); + double length = 0.; + Point last = from; + for (size_t step = 0; step < entities.size(); ++ step) { + double best = std::numeric_limits::max(); + size_t best_idx = 0; + bool best_reversed = false; + for (size_t i = 0; i < entities.size(); ++ i) + for (const bool reversed : { false, true }) + if (! taken[i] && (can_reverse || ! reversed)) { + const double d = ((reversed ? entities[i]->last_point() : entities[i]->first_point()) - last).cast().norm(); + if (d < best) { + best = d; + best_idx = i; + best_reversed = reversed; + } + } + taken[best_idx] = 1; + length += best; + last = best_reversed ? entities[best_idx]->first_point() : entities[best_idx]->last_point(); + } + return length; +} + +// The shortest travel over every order and allowed reversal of the paths, from start. +double shortest_travel(const std::vector &entities, bool can_reverse, const Point &from) +{ + std::vector order(entities.size()); + std::iota(order.begin(), order.end(), 0); + double best = std::numeric_limits::max(); + do { + for (size_t mask = 0; mask < (size_t(1) << order.size()) && (can_reverse || mask == 0); ++ mask) { + double length = 0.; + Point last = from; + for (size_t k = 0; k < order.size(); ++ k) { + const ExtrusionEntity *ee = entities[order[k]]; + const bool reversed = (mask >> k & 1) != 0; + length += ((reversed ? ee->last_point() : ee->first_point()) - last).cast().norm(); + last = reversed ? ee->first_point() : ee->last_point(); + } + best = std::min(best, length); + } + } while (std::next_permutation(order.begin(), order.end())); + return best; +} + +const Point start = Point::new_scale(-10., -10.); + +} // namespace + +TEST_CASE("An improved infill chain is never longer than the greedy or nearest-next one and keeps every path", "[ShortestPath]") +{ + const size_t count = GENERATE(2, 3, 10, 60, 300); + const bool can_reverse = GENERATE(true, false); + double greedy_total = 0., improved_total = 0.; + for (unsigned seed = 1; seed <= 20; ++ seed) { + Paths greedy(seed, count, can_reverse), improved(seed, count, can_reverse); + const double nearest_travel = nearest_next_travel(improved.entities, can_reverse, start); + chain_and_reorder_extrusion_entities(greedy.entities, &start); + chain_and_reorder_extrusion_entities(improved.entities, start); + + std::vector sorted = improved.entities; + std::sort(sorted.begin(), sorted.end()); + REQUIRE(std::unique(sorted.begin(), sorted.end()) == sorted.end()); + REQUIRE(sorted.size() == count); + + const double greedy_travel = travel(greedy.entities, start), improved_travel = travel(improved.entities, start); + CHECK(improved_travel <= greedy_travel + SCALED_EPSILON); + CHECK(improved_travel <= nearest_travel + SCALED_EPSILON); + greedy_total += greedy_travel; + improved_total += improved_travel; + } + // Twenty random sets of ten or more paths always leave the greedy chain something to shorten. + if (count >= 10) + CHECK(improved_total < 0.95 * greedy_total); +} + +TEST_CASE("Improving an infill chain keeps the direction of paths that cannot reverse", "[ShortestPath]") +{ + Paths paths(7, 100, false); + std::map first_points; + for (const ExtrusionEntity *ee : paths.entities) + first_points.emplace(ee, ee->first_point()); + + chain_and_reorder_extrusion_entities(paths.entities, start); + + for (const ExtrusionEntity *ee : paths.entities) + CHECK(ee->first_point() == first_points.at(ee)); +} + +TEST_CASE("Chains of a few paths are the shortest possible", "[ShortestPath]") +{ + const size_t count = GENERATE(2, 3, 4, 5, 6); + const bool can_reverse = GENERATE(true, false); + for (unsigned seed = 1; seed <= 10; ++ seed) { + Paths paths(seed, count, can_reverse); + const double shortest = shortest_travel(paths.entities, can_reverse, start); + chain_and_reorder_extrusion_entities(paths.entities, start); + CHECK(travel(paths.entities, start) <= shortest + SCALED_EPSILON); + } +} + +TEST_CASE("The greedy chain of two to four paths is the shortest possible", "[ShortestPath]") +{ + const size_t count = GENERATE(2, 3, 4); + const bool can_reverse = GENERATE(true, false); + for (unsigned seed = 1; seed <= 10; ++ seed) { + Paths paths(seed, count, can_reverse); + const double shortest = shortest_travel(paths.entities, can_reverse, start); + chain_and_reorder_extrusion_entities(paths.entities, &start); + CHECK(travel(paths.entities, start) <= shortest + SCALED_EPSILON); + } +} + +TEST_CASE("A short chain need not start with the path nearest to its start", "[ShortestPath]") +{ + // The nearest end leads 10 mm away from the two other paths, so the shortest chain leaves that path for last. + const Point from = Point::new_scale(0., 0.); + for (const bool greedy : { false, true }) { + ExtrusionEntityCollection collection; + extrusion_entities_append_paths(collection.entities, + Polylines{ Polyline(Point::new_scale(-0.9, 0.), Point::new_scale(-10., 0.)), Polyline(Point::new_scale(1., 0.), Point::new_scale(2., 0.)), + Polyline(Point::new_scale(3., 1.), Point::new_scale(4., 1.)) }, + erInternalInfill, 0.1, 0.45f, 0.2f); + std::vector entities = collection.entities; + const double shortest = shortest_travel(entities, true, from); + if (greedy) + chain_and_reorder_extrusion_entities(entities, &from); + else + chain_and_reorder_extrusion_entities(entities, from); + CHECK(entities.back() == collection.entities.front()); + CHECK_THAT(travel(entities, from), Catch::Matchers::WithinAbs(shortest, SCALED_EPSILON)); + } +} diff --git a/tests/libslic3r/test_texture_import.cpp b/tests/libslic3r/test_texture_import.cpp new file mode 100644 index 0000000000..1106045847 --- /dev/null +++ b/tests/libslic3r/test_texture_import.cpp @@ -0,0 +1,76 @@ +#include +#include + +#include "libslic3r/Model.hpp" +#include "libslic3r/TexturePainting.hpp" +#include "texture_import_cgal.hpp" + +#include +#include +#include +#include + +namespace { + +void check_texture_import(const std::string& fixture) +{ + const std::string filename = std::string(TEST_DATA_DIR) + "/texture_import/" + fixture; + INFO(filename); + const Slic3r::Model model = Slic3r::Model::read_from_file(filename); + REQUIRE_FALSE(model.objects.empty()); + REQUIRE(model.texture_mesh); + const Slic3r::TexturedMesh& mesh = *model.texture_mesh; + + Slic3r::TexturePaintingSettings settings; + settings.target_colors_num = 0; // Match the dialog's initial automatic color count. + settings.smooth_weight = 0.5; + settings.mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; + Slic3r::PaintedMesh painted; + // Follow the dialog worker's format-dependent path, including the boundary + // smoothing that needs a consistent CGAL Kd_tree layout across libraries. + if (!mesh.precomputed_face_colors.empty()) + REQUIRE(Slic3r::face_colors_to_painting(mesh, painted, settings)); + else + REQUIRE(Slic3r::texture_to_painting(mesh, painted, settings)); + + REQUIRE_FALSE(painted.indices.empty()); + REQUIRE(painted.face_colors.size() == painted.indices.size()); + REQUIRE(std::any_of(painted.face_colors.begin(), painted.face_colors.end(), + [](const auto& color) { return color[0] > color[2]; })); + REQUIRE(std::any_of(painted.face_colors.begin(), painted.face_colors.end(), + [](const auto& color) { return color[2] > color[0]; })); + + const std::vector> filament_colors{ + {1.f, 0.f, 0.f, 1.f}, {0.f, 0.f, 1.f, 1.f}}; + const auto matches = Slic3r::match_clusters_to_filaments( + painted.cluster_colors, filament_colors, {"Red PLA", "Blue PLA"}); + REQUIRE(matches.size() == painted.cluster_colors.size()); + REQUIRE(std::any_of(matches.begin(), matches.end(), + [](const auto& match) { return match.filament_index == 0; })); + REQUIRE(std::any_of(matches.begin(), matches.end(), + [](const auto& match) { return match.filament_index == 1; })); +} + +} // namespace + +TEST_CASE("Color-boundary smoothing shares its CGAL tree layout with the geometry backend", "[TextureImport][CGAL][Regression]") +{ + // An ABI mismatch need not crash every link of the importer. Compare the + // layouts directly so a lucky choice of inline symbols cannot mask it. + REQUIRE(sizeof(TextureImportTest::SearchTree) == TextureImportTest::backend_search_tree_size()); +} + +TEST_CASE("A JPEG textured GLB retains both colors through filament matching", "[TextureImport][Regression]") +{ + check_texture_import("two_color_jpeg.glb"); +} + +TEST_CASE("An OBJ preserves both MTL colors through filament matching", "[TextureImport][OBJ][Regression]") +{ + check_texture_import("two_color_sphere/test_two_color_sphere.obj"); +} + +TEST_CASE("An OBJ resolves its MTL and external JPEG through filament matching", "[TextureImport][OBJ][Regression]") +{ + check_texture_import("two_color_sphere/test_two_color_sphere_textured.obj"); +} diff --git a/tests/libslic3r/texture_import_cgal.cpp b/tests/libslic3r/texture_import_cgal.cpp new file mode 100644 index 0000000000..d3ee691d46 --- /dev/null +++ b/tests/libslic3r/texture_import_cgal.cpp @@ -0,0 +1,8 @@ +#include "texture_import_cgal.hpp" + +#include + +std::size_t TextureImportTest::backend_search_tree_size() +{ + return sizeof(SearchTree); +} diff --git a/tests/libslic3r/texture_import_cgal.hpp b/tests/libslic3r/texture_import_cgal.hpp new file mode 100644 index 0000000000..62b3211da4 --- /dev/null +++ b/tests/libslic3r/texture_import_cgal.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace TextureImportTest { + +// The distance-query tree used by ColorUtils.cpp's boundary smoothing. Its +// type name stays the same when CGAL changes its storage for TBB support. +using Kernel = CGAL::Exact_predicates_inexact_constructions_kernel; +using Mesh = CGAL::Surface_mesh; +using Primitive = CGAL::AABB_face_graph_triangle_primitive; +using Traits = CGAL::AABB_traits_3; +using PointId = Traits::Point_and_primitive_id; +using SearchTraits = CGAL::Search_traits_adapter, Traits>; +using SearchTree = CGAL::Kd_tree; + +// Implemented inside the actual geometry target, using its private definitions. +std::size_t backend_search_tree_size(); + +} // namespace TextureImportTest diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index c9285b955e..727709f5b2 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -44,6 +44,7 @@ add_executable(${_TEST_NAME}_tests test_shortcuts.cpp test_texture_color_palette.cpp test_texture_island_view.cpp + test_timelapse_codecs.cpp test_file_url.cpp test_imgui_wrapper.cpp test_user_manager.cpp diff --git a/tests/slic3rutils/test_bambu_filament_ids.cpp b/tests/slic3rutils/test_bambu_filament_ids.cpp index 7d9e0df1f1..fef2c8a224 100644 --- a/tests/slic3rutils/test_bambu_filament_ids.cpp +++ b/tests/slic3rutils/test_bambu_filament_ids.cpp @@ -81,3 +81,24 @@ TEST_CASE("Payload rewrite covers nested trays, calibration lists and mapping in CHECK(BBLPrinterAgent::from_orca_payload("not json") == "not json"); CHECK(BBLPrinterAgent::from_orca_payload(R"({"print":{"command":"pushall"}})") == R"({"print":{"command":"pushall"}})"); } + +TEST_CASE("Payload rewrite covers the nozzle rack status and the auto nozzle mapping request", "[BambuFilamentIds]") +{ + const ScopedRepoResourcesDir repo_resources; + const std::string petg_hf = orca_id_of("GFG96"); + + const std::string rack_status = + R"({"print":{"device":{"nozzle":{"info":[{"id":16,"fila_id":"GFG96","color_m":"FFFFFFFF"},{"id":17,"fila_id":"P22f54c9"}]}}}})"; + json inbound = json::parse(BBLPrinterAgent::to_orca_payload(rack_status)); + CHECK(inbound["print"]["device"]["nozzle"]["info"][0]["fila_id"] == petg_hf); + CHECK(inbound["print"]["device"]["nozzle"]["info"][1]["fila_id"] == "P22f54c9"); // user id: untouched + + const std::string request = R"({"print":{"command":"get_auto_nozzle_mapping","ams_mapping":[65535,65024],)" + R"("fila_info":[{"id":2,"cate":")" + petg_hf + R"(","color":"FFFFFFFF"}],)" + R"("nozzle_info":[{"pos":16,"cate":")" + petg_hf + R"(","color":"FFFFFFFF"},{"pos":1,"cate":""}]}})"; + json outbound = json::parse(BBLPrinterAgent::from_orca_payload(request)); + CHECK(outbound["print"]["fila_info"][0]["cate"] == "GFG96"); + CHECK(outbound["print"]["nozzle_info"][0]["cate"] == "GFG96"); + CHECK(outbound["print"]["nozzle_info"][1]["cate"] == ""); // empty nozzle + CHECK(outbound["print"]["ams_mapping"] == json::parse("[65535,65024]")); +} diff --git a/tests/slic3rutils/test_moonraker_printer_agent.cpp b/tests/slic3rutils/test_moonraker_printer_agent.cpp index d3559cea1f..d3d61903fb 100644 --- a/tests/slic3rutils/test_moonraker_printer_agent.cpp +++ b/tests/slic3rutils/test_moonraker_printer_agent.cpp @@ -420,12 +420,13 @@ TEST_CASE("Moonraker discovery waits for a complete device identity", "[Moonrake 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.device_info.model_name = "Test model"; agent.start_discovery(true, false); REQUIRE(announcements == 1); - REQUIRE(agent.fetch_count == 1); + REQUIRE(agent.fetch_count == 0); 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"); + REQUIRE(announcement.at("dev_name") == "Test model"); } TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]") diff --git a/tests/slic3rutils/test_orca_cloud_agent.cpp b/tests/slic3rutils/test_orca_cloud_agent.cpp index 485b662c0b..f84a14ae76 100644 --- a/tests/slic3rutils/test_orca_cloud_agent.cpp +++ b/tests/slic3rutils/test_orca_cloud_agent.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -91,7 +92,9 @@ using boost::asio::ip::tcp; class LoopbackServer { public: - enum class Mode { Ok, Unavailable, Silent }; + // Session answers every request with the body given to set_session_body(), as the token endpoint + // does, after 200 ms so the client is still waiting when the test makes its next call. + enum class Mode { Ok, Unavailable, Silent, Session }; struct Request { @@ -113,6 +116,11 @@ public: std::string url() const { return "http://127.0.0.1:" + std::to_string(m_acceptor.local_endpoint().port()); } void set_mode(Mode mode) { m_mode = mode; } + void set_session_body(std::string body) + { + std::lock_guard lock(m_mutex); + m_session_body = std::move(body); + } bool wait_for_accepts(size_t count, std::chrono::milliseconds timeout) { @@ -172,13 +180,31 @@ private: const Mode mode = m_mode; if (mode == Mode::Silent) return; - const std::string_view response = mode == Mode::Ok ? - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}" : - "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - boost::asio::async_write(connection->socket, boost::asio::buffer(response), - [connection](boost::system::error_code, size_t) { - boost::system::error_code ignored; - connection->socket.shutdown(tcp::socket::shutdown_both, ignored); + auto response = std::make_shared(); + if (mode == Mode::Session) { + std::lock_guard lock(m_mutex); + *response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + std::to_string(m_session_body.size()) + "\r\nConnection: close\r\n\r\n" + m_session_body; + } else { + *response = mode == Mode::Ok ? + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}" : + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + } + auto write = [connection, response] { + boost::asio::async_write(connection->socket, boost::asio::buffer(*response), + [connection, response](boost::system::error_code, size_t) { + boost::system::error_code ignored; + connection->socket.shutdown(tcp::socket::shutdown_both, ignored); + }); + }; + if (mode != Mode::Session) { + write(); + return; + } + auto timer = std::make_shared(m_io, 200ms); + timer->async_wait([timer, write](boost::system::error_code ec) { + if (!ec) + write(); }); }); } @@ -192,8 +218,12 @@ private: std::condition_variable m_cv; std::vector m_requests; std::vector> m_connections; + std::string m_session_body; }; +// A JWT whose only claim is {"exp":4102444800} (2100-01-01); the agent reads nothing else from it. +constexpr const char* FAR_FUTURE_JWT = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.sig"; + // What the agent reported through its callbacks, recorded on whichever thread delivers it so the // assertions can run on the test thread. struct CallbackLog @@ -515,3 +545,24 @@ TEST_CASE_METHOD(HealthCheckFixture, "The health check does no token work", "[Or CHECK(agent->is_user_login()); CHECK(agent->get_access_token() == "test-token"); } + +TEST_CASE_METHOD(HealthCheckFixture, "A token request made while start() refreshes waits for it instead of refreshing again", "[OrcaCloudServiceAgent]") +{ + // A session saved by a previous run: start() refreshes it on its own thread. + agent->persist_user_secret(nlohmann::json{{"refresh_token", "stored-refresh-token"}, {"user_id", "test-user-id"}}.dump()); + const std::string access_token = FAR_FUTURE_JWT; + server.set_session_body(nlohmann::json{{"access_token", access_token}, + {"refresh_token", "rotated-refresh-token"}, + {"user_id", "test-user-id"}}.dump()); + server.set_mode(LoopbackServer::Mode::Session); + + REQUIRE(agent->start() == BAMBU_NETWORK_SUCCESS); + CHECK(agent->ensure_token_fresh("test")); + CHECK(agent->get_access_token() == access_token); + + size_t token_posts = 0; + for (const auto& request : server.requests()) + if (boost::starts_with(request.request_line, "POST /auth/v1/token")) + ++token_posts; + CHECK(token_posts == 1); +} diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 267cdb4099..de0c1cf87e 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -1,22 +1,48 @@ #include #include +#include #include #include #include // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) #include +#include +#include +#include +#include +#include +#include #include // secret_constants::USER_SECRET_FILENAME +#include #include +#include +#include #include "libslic3r_version.h" #include "plugin_test_utils.hpp" +#include "test_utils.hpp" #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include using namespace Slic3r; namespace fs = boost::filesystem; +namespace py = pybind11; namespace { @@ -43,7 +69,7 @@ void seed_denied_keywords() } // namespace -TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") +TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]") { seed_denied_names(); const PluginAuditManager& mgr = PluginAuditManager::instance(); @@ -55,6 +81,7 @@ TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME))); + CHECK(mgr.is_denied_filename(fs::path(".install_state.json"))); } SECTION("companions holding the same secrets are denied by the prefix rule") @@ -110,6 +137,7 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]") const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; + const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json"; SECTION("a non-denied file inside the allowed root is writable (root really grants writes)") { @@ -150,6 +178,17 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]") CHECK_FALSE(decision.allowed); } + SECTION("plugin install state is blocked for reads and writes despite the allowed root") + { + AuditDecision read_decision = mgr.check_open(install_state.string(), "r"); + CHECK_FALSE(read_decision.allowed); + CHECK(read_decision.reason == "denied filename"); + + AuditDecision write_decision = mgr.check_open(install_state.string(), "w"); + CHECK_FALSE(write_decision.allowed); + CHECK(write_decision.reason == "denied filename"); + } + SECTION("a traversal path resolving to the config is blocked") { const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"); @@ -349,3 +388,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed); } } + +// --------------------------------------------------------------------------- +// The audit hook, run in the embedded interpreter +// --------------------------------------------------------------------------- +// +// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system +// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their +// cases instead of using SECTIONs, which would restart the interpreter for every section. + +namespace { + +// Discovered, never loaded: the hook only needs the plugin's root and its sidecar. +const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Audit Plugin" +# description = "Plugin audit hook fixture" +# author = "OrcaSlicer" +# version = "1.0" +# type = "script" +# /// +)PY"; + +// A third-party library, installed outside the stdlib and outside the plugin. +const char* const LIBRARY_SOURCE = R"PY( +import socket +import urllib.request + +def read_all(paths): + for path in paths: + with open(path) as f: + f.read() + +def connect_from_host(port): + socket.gethostname() + socket.create_connection(("127.0.0.1", port)).close() + +def fetch(url): + with urllib.request.urlopen(url) as response: + return response.read() + +def fetch_each(urls): + for url in urls: + yield fetch(url) + +class ReadingHandler(urllib.request.BaseHandler): + """Reads a file while each response is processed.""" + def __init__(self, path): + self.path = path + self.outcome = None + + def http_response(self, request, response): + try: + open(self.path).close() + self.outcome = "ok" + except PermissionError: + self.outcome = "denied" + return response +)PY"; + +// Test-side Python, run outside any plugin context: files outside every allowed root, a local +// HTTP server, the library, and every module the tests use imported up front, so that no import +// happens inside a plugin context. Code from exec() runs as "", which is not stdlib, so +// attempt() and fetch() stand in for plugin code. +const char* const AUDIT_SETUP = R"PY( +import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading +import urllib.request, zipfile +if os.name != "nt": + import pty + +APPROVED = os.path.join(OUTSIDE, "approved.txt") +PRIVATE = os.path.join(OUTSIDE, "private.txt") +MISSING = os.path.join(OUTSIDE, "missing") +TREE = os.path.join(OUTSIDE, "tree") +TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"] +for path in (APPROVED, PRIVATE): + with open(path, "w") as f: + f.write("x") +for sub in ("one", "two"): + os.makedirs(os.path.join(TREE, sub)) + with open(os.path.join(TREE, sub, "f.txt"), "w") as f: + f.write("x") + +LIBRARY = os.path.join(OUTSIDE, "library") +os.makedirs(LIBRARY) +with open(os.path.join(LIBRARY, "netlib.py"), "w") as f: + f.write(LIBRARY_SOURCE) +sys.path.insert(0, LIBRARY) +import netlib + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/redirect": + self.send_response(302) + self.send_header("Location", URL_B) + body = b"" + else: + self.send_response(200) + body = b"ok" + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +threading.Thread(target=server.serve_forever, daemon=True).start() +PORT = server.server_address[1] +BASE = "http://127.0.0.1:%d" % PORT +URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect" +urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({}))) + +def fetch(url): + with urllib.request.urlopen(url) as response: + return response.read() + +fetch(URL_A) # urllib imports some modules on its first request + +def attempt(action): + try: + action() + return "ok" + except PermissionError: + return "denied" + +def spawn_checked(argv): + parent = os.getpid() + try: + return pty.spawn(argv) + finally: + if os.getpid() != parent: + os._exit(1) # a child whose exec was refused must not go on running the tests + +finalizer_outcomes = [] + +class TrackedRequest(urllib.request.Request): + def __del__(self): + finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close())) +)PY"; + +struct Prompt +{ + std::string plugin; + std::string event; + std::vector targets; +}; + +// One line per prompt, "event: target ...", so a failed check shows every prompt. +std::string describe(const std::vector& prompts) +{ + std::string text; + for (const Prompt& prompt : prompts) { + text += prompt.event + ":"; + for (const std::string& target : prompt.targets) + text += " " + target; + text += "\n"; + } + return text; +} + +// The plugin system with the audit hook installed, and the permission dialog replaced by a +// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last. +struct AuditHarness +{ + // Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log. + // data_dir() is a global allowed root from then on, so the test files live in `outside`. + ScopedDataDir data{"plugin-audit-hook"}; + bool initialized = PluginManager::instance().initialize(); + ScopedTemporaryDir outside{"orca-plugin-audit-outside"}; + std::vector prompts; + std::function approve = [](const Prompt&) { return false; }; + + AuditHarness() + { + PluginAuditManager::set_permission_prompt_for_testing( + [this](const std::string& event, const std::vector& targets) { + prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets}); + return approve(prompts.back()); + }); + } + + ~AuditHarness() + { + PluginAuditManager::set_permission_prompt_for_testing(nullptr); + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } + + // Call before taking the GIL: discovery is not Python work. + void add_plugin(const std::string& key) + { + const fs::path root = data.plugins_dir() / key; + fs::create_directories(root); + std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE; + save_permissions(key, {}); + PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true); + } + + // Grants as if saved in an earlier session. + void save_permissions(const std::string& key, const PluginPermissions& permissions) + { + PluginInstallState state; + state.installed_from = "local"; + state.installed_version = "1.0"; + state.plugin_name = key; + state.permissions = permissions; + REQUIRE(write_install_state(data.plugins_dir() / key, state)); + } + + PluginPermissions saved_permissions(const std::string& key) const + { + PluginInstallState state; + read_install_state(data.plugins_dir() / key, state); + return state.permissions; + } +}; + +// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP +// server before the interpreter shuts down. +struct PythonSide +{ + py::dict globals; + + explicit PythonSide(const AuditHarness& harness) + { + globals["OUTSIDE"] = harness.outside.path().string(); + globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE; + exec(AUDIT_SETUP); + } + + // A Python error fails the test here, as text: an error_already_set left to propagate would + // outlive the interpreter the harness shuts down. + void exec(const char* code) + { + std::string error; + try { + py::exec(code, globals); + } catch (py::error_already_set& raised) { + error = raised.what(); + } + if (!error.empty()) + FAIL(error); + } + + ~PythonSide() + { + try { + globals["server"].attr("shutdown")(); + globals["server"].attr("server_close")(); + } catch (...) { + } + } + + std::string str(const char* name) const { return py::str(globals[name]).cast(); } + std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast(); } + std::vector strings(const char* name) const { return globals[name].cast>(); } + + // Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an + // audit scope of its own. + void run_as(const std::string& plugin_key, const char* code) + { + ScopedPluginAuditContext audit(plugin_key); + exec(code); + } +}; + +PluginPermissions http_grants(std::vector urls) +{ + PluginPermissions permissions; + permissions.network_http = std::move(urls); + return permissions; +} + +const char* const PLUGIN = "AuditPlugin"; + +} // namespace + +TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")})); + + python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))"); + + CHECK(python.str("outcome") == "ok"); + CHECK(describe(harness.prompts).empty()); +} + +TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")})); + + python.run_as(PLUGIN, R"PY( +outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)] +outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)] +outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B])))) +)PY"); + + // The redirect target is asked for inside the approved redirecting request, and a library's + // wrapper or generator asks for the second URL as plain code does. + CHECK(python.strings("outcomes") == std::vector{"ok", "denied", "denied", "ok", "denied", "denied"}); + const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n"; + CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b); +} + +TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + python.exec(R"PY( +CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy") +handler = netlib.ReadingHandler(PRIVATE) +handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler) +walk_outcomes = [] +copy_outcomes = [] + +def on_walk_error(error): + walk_outcomes.append(attempt(lambda: open(PRIVATE).close())) + +def copy_file(source, destination): + copy_outcomes.append(attempt(lambda: open(PRIVATE).close())) +)PY"); + PluginPermissions permissions = http_grants({python.str("URL_A")}); + permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")}; + permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")}; + harness.save_permissions(PLUGIN, permissions); + + // A library handler that urllib calls while the approved request runs, the plugin's own + // os.walk error callback and copytree copy function (called once per file), and a library + // loop over a saved file and an unsaved one. + python.run_as(PLUGIN, R"PY( +request_outcome = attempt(lambda: handler_opener.open(URL_A).read()) +for _ in os.walk(MISSING, onerror=on_walk_error): + pass +shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file) +loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE])) +)PY"); + + CHECK(python.str("request_outcome") == "ok"); + CHECK(python.eval("handler.outcome") == "denied"); + CHECK(python.strings("walk_outcomes") == std::vector{"denied"}); + CHECK(python.strings("copy_outcomes") == std::vector{"denied", "denied"}); + CHECK(python.str("loop_outcome") == "denied"); + const std::string private_file = "open: " + python.str("PRIVATE") + "\n"; + CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file); +} + +TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + PluginPermissions permissions; + permissions.fs_read = {python.str("TREE"), python.str("APPROVED")}; + harness.save_permissions(PLUGIN, permissions); + + // The walk lists the tree's two subdirectories on later resumes, after the plugin's own + // audited reads in between. Abandoned walks leave frames whose generator is gone, which the + // next audited event lets go. + python.run_as(PLUGIN, R"PY( +walked = [] +for root, dirs, files in os.walk(TREE): + walked.append(root) + open(APPROVED).close() +globbed = list(pathlib.Path(TREE).glob("**/*")) + +abandoned = os.walk(TREE) +next(abandoned) +del abandoned +open(APPROVED).close() +for _ in pathlib.Path(TREE).glob("*"): + break +open(APPROVED).close() +)PY"); + + CHECK(py::len(python.globals["walked"]) == 3); + CHECK(py::len(python.globals["globbed"]) == 4); + CHECK(describe(harness.prompts).empty()); +} + +TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + + struct Case + { + const char* prompts; + const char* code; + }; + // Each operation names targets no earlier one saved, so each is asked once. FsCreate and + // FsDelete grants are never saved, so those operations are asked every time. Only the + // expected prompts are approved, so a nested event left uncovered fails the operation. + std::vector cases = { + {"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"}, + {"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"}, + {"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"}, + {"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"}, + {"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"}, + {"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"}, + {"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"}, + {"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"}, + {"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"}, + {"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"}, + {"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"}, + // send() runs after connect() has returned, so its own grant is asked once. + {"http.client.connect\nhttp.client.send", + "connection = http.client.HTTPConnection('127.0.0.1', PORT)\n" + "connection.request('GET', '/a')\n" + "connection.getresponse().read()\n" + "connection.close()"}, + }; +#ifndef _WIN32 + cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"}); + // The forked child's exec is covered by the spawn too: had it prompted, the child's own + // denial would end it with status 1. + cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"}); +#endif + + for (const Case& operation : cases) { + INFO(operation.code); + const std::string expected = std::string(operation.prompts) + "\n"; + harness.approve = [&expected](const Prompt& prompt) { + return expected.find(prompt.event + "\n") != std::string::npos; + }; + harness.prompts.clear(); + python.run_as(PLUGIN, operation.code); + + std::string asked; + for (const Prompt& prompt : harness.prompts) + asked += (asked.empty() ? "" : "\n") + prompt.event; + CHECK(asked == operation.prompts); + } +#ifndef _WIN32 + CHECK(python.eval("spawn_status") == "0"); +#endif +} + +TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; }; + py::gil_scoped_acquire gil; + PythonSide python(harness); + + python.run_as(PLUGIN, R"PY( +socket.socket().close() +udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9))) +udp.close() +)PY"); + + // The socket is asked for once, without a target to show; the datagram's address is a target. + CHECK(python.str("sendto_outcome") == "denied"); + CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n"); + CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"Socket:socket.__new__"}); + CHECK(harness.saved_permissions(PLUGIN).network_socket.empty()); + + harness.prompts.clear(); + python.run_as(PLUGIN, "socket.socket().close()"); + CHECK(describe(harness.prompts).empty()); + + // A grant for an event with no target covers nothing else: a library's approved + // gethostname() does not carry the connection it makes next. + harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; }; + python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))"); + CHECK(python.str("connect_outcome") == "denied"); + CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n"); + harness.prompts.clear(); + harness.approve = [](const Prompt&) { return true; }; + +#ifndef _WIN32 + // sendmsg() without an address names no target either. + python.run_as(PLUGIN, R"PY( +left, right = socket.socketpair() +left.sendmsg([b"x"]) +left.close() +right.close() +)PY"); + CHECK(describe(harness.prompts) == "socket.sendmsg:\n"); + CHECK(harness.saved_permissions(PLUGIN).events == + std::vector{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"}); +#endif +} + +TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; }; + py::gil_scoped_acquire gil; + PythonSide python(harness); + python.exec(R"PY( +READ_FD = os.open(APPROVED, os.O_RDONLY) +WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT) +)PY"); + + // A file descriptor has no path to show or save: a granted read of one is not a write. + python.run_as(PLUGIN, R"PY( +fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()), + attempt(lambda: open(WRITE_FD, "w", closefd=False).close())] +)PY"); + CHECK(python.strings("fd_outcomes") == std::vector{"ok", "denied"}); + CHECK(describe(harness.prompts) == "open:\nopen:\n"); + CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"FsRead:open"}); + + // A saved os.fork event grant is not an os.system command named "os.fork". + PluginPermissions permissions; + permissions.events = {"FsRead:open", "ProcessCreate:os.fork"}; + harness.save_permissions(PLUGIN, permissions); + harness.prompts.clear(); + python.run_as(PLUGIN, R"PY( +reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close()) +system_outcome = attempt(lambda: os.system("os.fork")) +os.close(READ_FD) +os.close(WRITE_FD) +)PY"); + CHECK(python.str("reread_outcome") == "ok"); + CHECK(python.str("system_outcome") == "denied"); + REQUIRE(harness.prompts.size() == 1); + CHECK(harness.prompts.front().event == "os.system"); + CHECK(harness.saved_permissions(PLUGIN).process.empty()); +} + +TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + PluginPermissions permissions = http_grants({python.str("URL_A")}); + permissions.fs_read = {python.str("APPROVED")}; + harness.save_permissions(PLUGIN, permissions); + PluginAuditManager& mgr = PluginAuditManager::instance(); + + // Once the request returns, only its held frame keeps the request object alive, and the + // request's finalizer reads a file the plugin was never granted. + python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))"); + CHECK(python.strings("finalizer_outcomes") == std::vector{"denied"}); // at scope end + CHECK(describe(harness.prompts).empty()); + CHECK_FALSE(mgr.audit_denial_pending()); + + python.exec("finalizer_outcomes.clear()"); + python.run_as(PLUGIN, R"PY( +fetch(TrackedRequest(URL_A)) +open(APPROVED).close() # the next event lets the returned request's frame go +outcomes_before_scope_end = list(finalizer_outcomes) +)PY"); + CHECK(python.strings("outcomes_before_scope_end") == std::vector{"denied"}); + CHECK(describe(harness.prompts).empty()); + + // The finalizer's denial during another event's decision leaves that event's own denial intact. + python.exec("finalizer_outcomes.clear()"); + python.run_as(PLUGIN, R"PY( +fetch(TrackedRequest(URL_A)) +try: + os.listdir(OUTSIDE) + outer_error = "" +except PermissionError as error: + outer_error = str(error) +)PY"); + CHECK(python.strings("finalizer_outcomes") == std::vector{"denied"}); + CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir")); + CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n"); + AuditViolation violation; + REQUIRE(mgr.last_violation(violation)); + CHECK(violation.event_name == "os.listdir"); + CHECK(mgr.audit_denial_pending()); +} + +TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin("PluginA"); + harness.add_plugin("PluginB"); + py::gil_scoped_acquire gil; + PythonSide python(harness); + PluginPermissions permissions; + permissions.fs_read = {python.str("TREE")}; + harness.save_permissions("PluginA", permissions); + + { + // Two walks plugin A started and suspended after listing the tree itself. + ScopedPluginAuditContext plugin_a("PluginA"); + python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)"); + { + // Plugin B resuming one of them is asked for each subdirectory, as plugin B. + ScopedPluginAuditContext plugin_b("PluginB"); + python.exec("listed_by_b = len(list(first))"); + } + // Back in plugin A's scope, its walks are covered again. + python.exec("listed_by_a = len(list(second))"); + } + + CHECK(python.globals["listed_by_b"].cast() == 0); + CHECK(python.globals["listed_by_a"].cast() == 2); + REQUIRE(harness.prompts.size() == 2); + for (const Prompt& prompt : harness.prompts) { + CHECK(prompt.plugin == "PluginB"); + CHECK(prompt.event == "os.scandir"); + } +} + +TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; }; + py::gil_scoped_acquire gil; + PythonSide python(harness); + (void) PythonPluginBridge::instance(); // force the embedded module registration into the binary + python.globals["orca"] = py::module_::import("orca"); + + // walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker + // in its locals; the frame is let go when the capability call ends, which runs the marker's + // finalizer. + python.exec(R"PY( +class Marker: + def __init__(self, action=None): + self.action = action + def __del__(self): + finalized.append(self.action() if self.action else "ran") + +finalized = [] + +def walk_and_drop(marker): + walker = os.walk(TREE, onerror=marker) + next(walker) + del walker + +class ConfigCapability(orca.PythonPluginBase): + def get_name(self): + return "config_cap" + def get_default_config(self): + walk_and_drop(Marker()) + return {"finalized_during_call": len(finalized)} + def has_config_ui(self): + walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close()))) + next(walker) + attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key + del walker # the walk's frame is now held by the audit scope alone + raise RuntimeError("the capability fails after a denial") + +class TicketAgent(orca.printer_agent.PrinterAgentBase): + def get_name(self): + return "ticket_agent" + def request_bind_ticket(self): + walk_and_drop(Marker()) + return (0, "ticket") +)PY"); + + py::object config_instance = python.globals["ConfigCapability"](); + auto config_cap = config_instance.cast>(); + config_cap->set_audit_plugin_key("GilPlugin"); + config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script); + + py::object agent_instance = python.globals["TicketAgent"](); + auto agent_cap = agent_instance.cast>(); + agent_cap->set_audit_plugin_key("GilPlugin"); + agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection); + auto agent = std::dynamic_pointer_cast(agent_cap); + REQUIRE(agent); + + // A host call arrives without the GIL; both calls take it before their audit scope. + nlohmann::json config; + int ticket_result = -1; + std::string ticket; + { + py::gil_scoped_release released; + config = config_cap->get_default_config(); + ticket_result = agent->request_bind_ticket(&ticket); + } + CHECK(config == nlohmann::json{{"finalized_during_call", 0}}); + CHECK(ticket_result == 0); + CHECK(ticket == "ticket"); + CHECK(python.strings("finalized") == std::vector{"ran", "ran"}); + + // On the trampoline's denial path the frame is let go as the plugin, though the ambient key + // was cleared: the finalizer's read is audited and denied without a prompt. + python.exec("finalized.clear()"); + harness.prompts.clear(); + CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set); + CHECK(python.strings("finalized") == std::vector{"denied"}); + CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n"); +} + +TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; + + const py::dict paths = py::module_::import("sysconfig").attr("get_paths")(); + const fs::path stdlib = py::str(paths["stdlib"]).cast(); + const fs::path purelib = py::str(paths["purelib"]).cast(); + const fs::path link = harness.outside.path() / "linked-stdlib"; + boost::system::error_code ec; + fs::create_directory_symlink(stdlib, link, ec); + if (ec) + SKIP("Cannot create a directory symlink here: " + ec.message()); + + CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string())); + CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string())); + CHECK(PluginAuditManager::is_stdlib_file("")); + CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string())); + CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string())); + CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string())); + CHECK_FALSE(PluginAuditManager::is_stdlib_file("")); +} diff --git a/tests/slic3rutils/test_timelapse_codecs.cpp b/tests/slic3rutils/test_timelapse_codecs.cpp new file mode 100644 index 0000000000..ede43d8f06 --- /dev/null +++ b/tests/slic3rutils/test_timelapse_codecs.cpp @@ -0,0 +1,100 @@ +#include + +#include "libslic3r/GCode/ThumbnailData.hpp" +#include "libslic3r/GCode/Thumbnails.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/TexturePainting.hpp" +#include "slic3r/GUI/AVVideoDecoder.hpp" +#include "slic3r/GUI/Printer/BambuTunnel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TEST_CASE("JPEG texture decoding coexists with thumbnails and video frames", "[TimelapseCodecs][Regression]") +{ +#if defined(__linux__) + if (!std::getenv("DISPLAY") && !std::getenv("WAYLAND_DISPLAY")) + SKIP("This integration test needs a display (for example, xvfb-run)."); +#endif + wxInitializer init; + REQUIRE(init.IsOk()); + wxInitAllImageHandlers(); + + // Generate a JPEG through the same encoder used for G-code thumbnails. + constexpr int width = 160, height = 90, iterations = 1000; + Slic3r::ThumbnailData thumbnail; + thumbnail.set(width, height); + for (std::size_t i = 0; i < thumbnail.pixels.size(); i += 4) { + thumbnail.pixels[i] = 180; + thumbnail.pixels[i + 1] = 70; + thumbnail.pixels[i + 2] = 30; + thumbnail.pixels[i + 3] = 255; + } + const auto jpeg = Slic3r::GCodeThumbnails::compress_thumbnail(thumbnail, Slic3r::GCodeThumbnailsFormat::JPG); + REQUIRE(jpeg); + REQUIRE(jpeg->size > 0); + const auto* bytes = static_cast(jpeg->data); + Slic3r::TextureImage texture; + texture.width = texture.height = -1; + texture.data.assign(bytes, bytes + jpeg->size); + + std::atomic texture_ok{true}; + std::thread texture_worker([&] { + for (int i = 0; i < iterations; ++i) { + std::vector pixels; + int decoded_width = 0, decoded_height = 0; + if (!Slic3r::decode_texture_to_pixels(texture, pixels, decoded_width, decoded_height) || + decoded_width != width || decoded_height != height || pixels.size() != width * height * 3) + texture_ok = false; + } + }); + + // PrinterFileSystem decodes timelapse thumbnails with wxImage. Do not + // assert until the worker is joined: Catch assertions can unwind the scope. + bool thumbnails_ok = true; + for (int i = 0; i < iterations; ++i) { + wxMemoryInputStream stream(jpeg->data, jpeg->size); + wxImage image(stream, "image/jpeg"); + if (!image.IsOk() || image.GetWidth() != width || image.GetHeight() != height || + image.GetRed(width / 2, height / 2) < 170 || image.GetGreen(width / 2, height / 2) > 80) + thumbnails_ok = false; + } + texture_worker.join(); + REQUIRE(texture_ok.load()); + REQUIRE(thumbnails_ok); + + // Exercise Orca's real FFmpeg decoder and its platform display conversion. + AVVideoDecoder decoder; + Bambu_StreamInfo info{}; + info.type = VIDE; + info.sub_type = MJPG; + info.format_type = video_jpeg; + REQUIRE(decoder.open(info) == 0); + Bambu_Sample sample{}; + sample.size = static_cast(jpeg->size); + sample.buffer = bytes; + for (int i = 0; i < iterations; ++i) { + REQUIRE(decoder.decode(sample) == 0); + wxImage image; + REQUIRE(decoder.toWxImage(image, wxSize(width, height))); + REQUIRE(image.IsOk()); + REQUIRE(image.GetWidth() == width); + REQUIRE(image.GetHeight() == height); +#ifdef _WIN32 + // Playback uses this raw 32-bit bitmap conversion only on Windows. + wxBitmap bitmap; + REQUIRE(decoder.toWxBitmap(bitmap, wxSize(width, height))); + REQUIRE(bitmap.IsOk()); + REQUIRE(bitmap.GetWidth() == width); + REQUIRE(bitmap.GetHeight() == height); +#endif + } +}