From 46fb5126903578e2b32f1a3caa6cb848370a496a Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:49:54 +0800 Subject: [PATCH 1/9] enable python unit test (#15593) * enable python unit test * fix Windows * Require numpy for the plugin tests in CI --- .github/workflows/unit_tests.yml | 8 ++++ .gitignore | 4 +- scripts/run_unit_tests.sh | 49 ++++++++++++++++++++++++- src/slic3r/plugin/PythonInterpreter.cpp | 15 +++++--- tests/slic3rutils/CMakeLists.txt | 27 ++++++++++++-- 5 files changed, 92 insertions(+), 11 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index f5850d9fd7..41ef0b1f4b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -44,6 +44,14 @@ jobs: uses: actions/download-artifact@v8 with: name: ${{ inputs.artifact }} + # run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages + # beside them; the Windows arm64 build bundles none, so put one on PATH there. + - name: Install uv + if: runner.os == 'Windows' && runner.arch == 'ARM64' + uses: astral-sh/setup-uv@v10.2.0 + with: + version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt + enable-cache: false - uses: lukka/get-cmake@latest with: cmakeVersion: "~4.3.0" # use most recent 4.3.x version diff --git a/.gitignore b/.gitignore index b029afa180..5224ecad6b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,6 @@ internal_docs/ __pycache__/ *.pyc *.opc -docs/superpowers/ \ No newline at end of file +/.test/ +docs/superpowers/ +ctest_results.xml diff --git a/scripts/run_unit_tests.sh b/scripts/run_unit_tests.sh index 1d3f0046fd..c4433df04d 100755 --- a/scripts/run_unit_tests.sh +++ b/scripts/run_unit_tests.sh @@ -2,7 +2,8 @@ # This file is made to support the unit tests workflow. # It should only require the directories build/tests, scripts/, and tests/ to function, -# and cmake (with ctest) installed. +# and cmake (with ctest) installed -- plus network access to PyPI whenever numpy has to +# be installed into a freshly built test tree (see below). # (otherwise, update the workflow too, but try to avoid to keep things self-contained) # # Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG] @@ -18,6 +19,52 @@ cd "${ROOT_DIR}" || exit 1 TEST_DIR="${1:-build/tests}" BUILD_CONFIG="${2:-}" +# The slic3rutils plugin-host tests build numpy arrays through the CPython copied next +# to the test binary (see tests/slic3rutils/CMakeLists.txt), which ships no numpy. +# Install it with the uv staged beside that runtime -- the tool the app installs plugin +# dependencies with -- straight into the interpreter's own site-packages: no pip needed +# in the runtime, no PYTHONPATH. Re-checked every run because a rebuild of the test +# target re-copies the runtime; needs network whenever it installs. Pinned so a numpy +# release cannot change results on its own. +NUMPY_VERSION="2.5.3" + +# Without numpy those tests assert the numpy-absent error path instead, so a local run +# only warns. Under CI it fails the run, which would otherwise stay green while silently +# dropping the array coverage. (The Flatpak leg runs this inside `flatpak build`, whose +# minimal environment has no CI, and its offline build stages no uv, so numpy stays +# best-effort there.) +numpy_unavailable() { + if [ -n "${CI:-}" ]; then + echo "error: $1" >&2 + exit 1 + fi + echo "warning: $1; the numpy-backed binding tests will cover only the numpy-absent path." +} + +has_pinned_numpy() { + "${python_exe}" -c "import sys, numpy; sys.exit(numpy.__version__ != '${NUMPY_VERSION}')" >/dev/null 2>&1 +} + +find_args=("${TEST_DIR}" \( -path '*/python/bin/python3' -o -path '*/python/python.exe' \)) +# Multi-config trees hold one copy per configuration; only bootstrap the one being run. +[ -n "${BUILD_CONFIG}" ] && find_args+=(-path "*/${BUILD_CONFIG}/*") +python_exe="$(find "${find_args[@]}" -print -quit 2>/dev/null)" + +if [ -z "${python_exe}" ]; then + numpy_unavailable "no bundled Python under ${TEST_DIR}" +elif ! has_pinned_numpy; then + uv_exe="${python_exe%/python/*}/tools/uv/uv" + # Builds that bundle no uv (Windows arm64) fall back to one on PATH. + [ -x "${uv_exe}" ] || uv_exe="$(command -v uv)" + echo "Installing numpy ${NUMPY_VERSION} into the embedded test interpreter (${python_exe})..." + if [ -z "${uv_exe}" ]; then + numpy_unavailable "no uv staged beside the tests or on PATH" + elif ! "${uv_exe}" pip install --python "${python_exe}" --only-binary :all: "numpy==${NUMPY_VERSION}" \ + || ! has_pinned_numpy; then + numpy_unavailable "could not install numpy ${NUMPY_VERSION} into ${python_exe}" + fi +fi + # Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp, # which run the built orca-slicer binary that this directory does not contain. # --no-tests=error fails the job if the filter matches nothing (instead of passing green). diff --git a/src/slic3r/plugin/PythonInterpreter.cpp b/src/slic3r/plugin/PythonInterpreter.cpp index 3d180c2f2a..d82bf52a13 100644 --- a/src/slic3r/plugin/PythonInterpreter.cpp +++ b/src/slic3r/plugin/PythonInterpreter.cpp @@ -345,16 +345,21 @@ boost::filesystem::path find_bundled_python_home() fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python"; if (valid_python_home(bundle_python)) return bundle_python; -#elif defined(_WIN32) - fs::path exe_python = boost::dll::program_location().parent_path() / "python"; - if (valid_python_home(exe_python)) - return exe_python; -#else +#elif !defined(_WIN32) fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python"; if (valid_python_home(linux_python)) return linux_python; #endif + // Next to the executable: the Windows install layout, and the runtime copied + // beside every platform's unit-test binary (tests/slic3rutils/CMakeLists.txt). + // The CI test runner only receives the build/tests tree, so the candidates + // below -- all of which point into the deps or install trees -- never resolve + // there. + fs::path exe_python = boost::dll::program_location().parent_path() / "python"; + if (valid_python_home(exe_python)) + return exe_python; + fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT; if (!configured_python.empty() && valid_python_home(configured_python)) return configured_python; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 58262eef99..be796e814b 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -51,9 +51,16 @@ if (WIN32) COMMENT "Copying Python runtime for slic3rutils plugin host API tests" VERBATIM ) -elseif (APPLE) - target_link_options(${_TEST_NAME}_tests PRIVATE - "LINKER:-rpath,@executable_path/python/lib") +elseif (NOT FLATPAK) + # The CI unit-test runner only receives the build/tests tree, so both the + # interpreter and the libpython the test binary links have to travel next to + # the executable; find_bundled_python_home() picks the copy up from there. + if (APPLE) + target_link_options(${_TEST_NAME}_tests PRIVATE + "LINKER:-rpath,@executable_path/python/lib") + else () + set_property(TARGET ${_TEST_NAME}_tests APPEND PROPERTY BUILD_RPATH "$ORIGIN/python/lib") + endif () add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -rf @@ -61,7 +68,7 @@ elseif (APPLE) COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_PREFIX_PATH}/libpython" "$/python" - COMMENT "Copying Python runtime for macOS plugin host API tests" + COMMENT "Copying Python runtime for the plugin host API tests" VERBATIM ) elseif (FLATPAK) @@ -78,4 +85,16 @@ elseif (FLATPAK) ) endif() +# scripts/run_unit_tests.sh installs the tests' numpy into that runtime with this uv, +# staged where the app build tree keeps it (/tools/uv, see src/CMakeLists.txt). +if (ORCA_BUNDLED_UV_EXECUTABLE) + add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/tools/uv" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ORCA_BUNDLED_UV_EXECUTABLE}" + "$/tools/uv/${ORCA_BUNDLED_UV_FILENAME}" + COMMENT "Copying uv for the plugin host API tests" + VERBATIM + ) +endif() + orcaslicer_discover_tests(${_TEST_NAME}_tests) From ef0c6569320c56a028289c629b5a22e680ca119e Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 29 Sep 2026 17:52:13 +0800 Subject: [PATCH 2/9] Tune pressure advance separately for each extruder variant Pressure advance, adaptive pressure advance and its model can now take a different value for each extruder variant of a filament, such as Standard and High Flow nozzles, like the other per-variant filament settings. Projects saved with one value per filament apply it to every variant of that filament, and the addnorth BBL filaments in the Orca Filament Library are updated to the per-variant layout. --- .../references/extruder-variants.md | 7 +- .../references/filament-profiles.md | 6 +- resources/profiles/OrcaFilamentLibrary.json | 2 +- .../addnorth/BBL/addnorth ABS rABS.json | 2 + .../addnorth/BBL/addnorth PA Adura FDA.json | 2 + .../addnorth/BBL/addnorth PA Adura.json | 2 + .../addnorth/BBL/addnorth PA-CF Adura X.json | 2 + .../addnorth/BBL/addnorth PA6 Addlantis.json | 2 + .../BBL/addnorth PC BLend HT LCF.json | 2 + .../addnorth/BBL/addnorth PETG Base.json | 2 + .../addnorth/BBL/addnorth PETG ESD.json | 2 + .../addnorth/BBL/addnorth PETG Economy.json | 2 + .../addnorth/BBL/addnorth PETG Flame v0.json | 2 + .../addnorth/BBL/addnorth PETG PRO Matte.json | 2 + .../BBL/addnorth PETG rPETG Matte.json | 2 + .../BBL/addnorth PETG-CF Rigid X.json | 2 + .../addnorth/BBL/addnorth PLA E-PLA.json | 2 + .../addnorth/BBL/addnorth PLA Economy.json | 2 + .../BBL/addnorth PLA HT-PLA PRO Matte.json | 2 + .../BBL/addnorth PLA Premium Silk.json | 2 + .../addnorth/BBL/addnorth PLA Textura.json | 2 + .../addnorth/BBL/addnorth PLA Wood.json | 2 + .../BBL/addnorth PLA X-PLA High Speed.json | 2 + .../addnorth/BBL/addnorth PLA X-PLA.json | 2 + .../BBL/addnorth PLA rPLA RE-ADD.json | 2 + .../BBL/addnorth PVDF Adamant S1.json | 2 + .../addnorth/BBL/addnorth TPU EasyFlex.json | 2 + .../BBL/addnorth TPU Pro Matte 85A.json | 2 + .../BBL/addnorth TPU Pro Matte 95A.json | 2 + src/libslic3r/GCode.cpp | 118 +++++++---------- src/libslic3r/GCode.hpp | 2 + src/libslic3r/GCode/AdaptivePAProcessor.cpp | 59 ++++----- src/libslic3r/GCode/AdaptivePAProcessor.hpp | 18 +-- src/libslic3r/Print.cpp | 18 ++- src/libslic3r/PrintConfig.cpp | 23 ++++ src/slic3r/GUI/ConfigManipulation.cpp | 14 +- src/slic3r/GUI/Plater.cpp | 42 +++--- src/slic3r/GUI/Tab.cpp | 35 ++--- tests/fff_print/test_multifilament.cpp | 125 ++++++++++++++++++ .../libslic3r/test_preset_bundle_loading.cpp | 36 +++++ 40 files changed, 392 insertions(+), 165 deletions(-) diff --git a/.claude/skills/orca-profiles/references/extruder-variants.md b/.claude/skills/orca-profiles/references/extruder-variants.md index 322d79d56f..5059fd5c6b 100644 --- a/.claude/skills/orca-profiles/references/extruder-variants.md +++ b/.claude/skills/orca-profiles/references/extruder-variants.md @@ -189,8 +189,7 @@ the presets to a base is in [shared-bases.md](shared-bases.md#variant-arrays-on- 2. Every key in `filament_options_with_variant` ([the full list](#the-four-key-sets)) that reaches the preset, whether written, included or inherited, is resized to its variant count: write it at exactly that width, or leave it out. Keys outside the set - (`filament_type`, plate temperatures, `fan_max_speed`, `slow_down_min_speed`, `pressure_advance`, - …) are never addressed by variant index; the preset contributes their first value however wide a + (`filament_type`, plate temperatures, `fan_max_speed`, `slow_down_min_speed`, …) are never addressed by variant index; the preset contributes their first value however wide a file writes them. 3. Cover every variant the material is meant to print on across its `compatible_printers`. Leave out a variant deliberately when the material should not be tuned for it (e.g. a TPU High Flow variant @@ -455,7 +454,7 @@ no set: the variant-length resize leaves it alone, and only the pair rebuild of last for contrast; it is not a variant set. `check` and `fix-variant` read the four sets from `PrintConfig.cpp` on every run, so they follow the -engine. The lists below are from the 2026-09-25 checkout; regenerate them from the repository root +engine. The lists below are from the 2026-09-29 checkout; regenerate them from the repository root before relying on them (the recipe strips comments, since an initializer can carry a commented-out entry): ```bash @@ -478,7 +477,7 @@ EOF **`print_options_with_variant`**, process, stride 1 (45): `bridge_acceleration`, `bridge_speed`, `default_acceleration`, `default_jerk`, `default_junction_deviation`, `enable_overhang_speed`, `gap_infill_speed`, `infill_jerk`, `initial_layer_acceleration`, `initial_layer_infill_speed`, `initial_layer_jerk`, `initial_layer_speed`, `initial_layer_travel_acceleration`, `initial_layer_travel_jerk`, `initial_layer_travel_speed`, `inner_wall_acceleration`, `inner_wall_jerk`, `inner_wall_speed`, `internal_bridge_speed`, `internal_solid_infill_acceleration`, `internal_solid_infill_speed`, `outer_wall_acceleration`, `outer_wall_jerk`, `outer_wall_speed`, `overhang_1_4_speed`, `overhang_2_4_speed`, `overhang_3_4_speed`, `overhang_4_4_speed`, `print_extruder_id`, `print_extruder_variant`, `slowdown_for_curled_perimeters`, `small_perimeter_speed`, `small_perimeter_threshold`, `sparse_infill_acceleration`, `sparse_infill_speed`, `support_interface_speed`, `support_speed`, `top_solid_infill_flow_ratio`, `top_surface_acceleration`, `top_surface_jerk`, `top_surface_speed`, `travel_acceleration`, `travel_jerk`, `travel_speed`, `travel_speed_z` -**`filament_options_with_variant`**, filament, stride 1 (48): `activate_air_filtration`, `activate_air_filtration_during_print`, `activate_air_filtration_on_completion`, `complete_print_exhaust_fan_speed`, `during_print_exhaust_fan_speed`, `filament_adaptive_volumetric_speed`, `filament_cooling_before_tower`, `filament_deretraction_speed`, `filament_extruder_variant`, `filament_flow_ratio`, `filament_flush_temp`, `filament_flush_volumetric_speed`, `filament_ironing_flow`, `filament_ironing_inset`, `filament_ironing_spacing`, `filament_ironing_speed`, `filament_long_retractions_when_cut`, `filament_max_volumetric_speed`, `filament_pre_cooling_temperature`, `filament_pre_cooling_temperature_nc`, `filament_preheat_temperature_delta`, `filament_ramming_travel_time`, `filament_ramming_travel_time_nc`, `filament_ramming_volumetric_speed`, `filament_ramming_volumetric_speed_nc`, `filament_retract_after_wipe`, `filament_retract_before_wipe`, `filament_retract_length_nc`, `filament_retract_length_toolchange`, `filament_retract_lift_above`, `filament_retract_lift_below`, `filament_retract_lift_enforce`, `filament_retract_restart_extra`, `filament_retract_restart_extra_toolchange`, `filament_retract_when_changing_layer`, `filament_retraction_distances_when_cut`, `filament_retraction_length`, `filament_retraction_minimum_travel`, `filament_retraction_speed`, `filament_wipe`, `filament_wipe_distance`, `filament_z_hop`, `filament_z_hop_types`, `long_retractions_when_ec`, `nozzle_temperature`, `nozzle_temperature_initial_layer`, `retraction_distances_when_ec`, `volumetric_speed_coefficients` +**`filament_options_with_variant`**, filament, stride 1 (54): `activate_air_filtration`, `activate_air_filtration_during_print`, `activate_air_filtration_on_completion`, `adaptive_pressure_advance`, `adaptive_pressure_advance_bridges`, `adaptive_pressure_advance_model`, `adaptive_pressure_advance_overhangs`, `complete_print_exhaust_fan_speed`, `during_print_exhaust_fan_speed`, `enable_pressure_advance`, `filament_adaptive_volumetric_speed`, `filament_cooling_before_tower`, `filament_deretraction_speed`, `filament_extruder_variant`, `filament_flow_ratio`, `filament_flush_temp`, `filament_flush_volumetric_speed`, `filament_ironing_flow`, `filament_ironing_inset`, `filament_ironing_spacing`, `filament_ironing_speed`, `filament_long_retractions_when_cut`, `filament_max_volumetric_speed`, `filament_pre_cooling_temperature`, `filament_pre_cooling_temperature_nc`, `filament_preheat_temperature_delta`, `filament_ramming_travel_time`, `filament_ramming_travel_time_nc`, `filament_ramming_volumetric_speed`, `filament_ramming_volumetric_speed_nc`, `filament_retract_after_wipe`, `filament_retract_before_wipe`, `filament_retract_length_nc`, `filament_retract_length_toolchange`, `filament_retract_lift_above`, `filament_retract_lift_below`, `filament_retract_lift_enforce`, `filament_retract_restart_extra`, `filament_retract_restart_extra_toolchange`, `filament_retract_when_changing_layer`, `filament_retraction_distances_when_cut`, `filament_retraction_length`, `filament_retraction_minimum_travel`, `filament_retraction_speed`, `filament_wipe`, `filament_wipe_distance`, `filament_z_hop`, `filament_z_hop_types`, `long_retractions_when_ec`, `nozzle_temperature`, `nozzle_temperature_initial_layer`, `pressure_advance`, `retraction_distances_when_ec`, `volumetric_speed_coefficients` **`printer_extruder_options`**, machine, one value per extruder, not a variant set (8): `default_nozzle_volume_type`, `extruder_max_nozzle_count`, `extruder_printable_area`, diff --git a/.claude/skills/orca-profiles/references/filament-profiles.md b/.claude/skills/orca-profiles/references/filament-profiles.md index bc80176eaa..0ec5eca24e 100644 --- a/.claude/skills/orca-profiles/references/filament-profiles.md +++ b/.claude/skills/orca-profiles/references/filament-profiles.md @@ -244,10 +244,10 @@ preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure required direction of change, from diameter alone. On a printer with extruder variants, a filament tunes these per variant too: -`filament_max_volumetric_speed`, `filament_flow_ratio`, `nozzle_temperature` and the retraction -overrides carry one value per variant of `filament_extruder_variant` (Standard, High Flow, …). The +`filament_max_volumetric_speed`, `filament_flow_ratio`, `nozzle_temperature`, pressure advance and the +retraction overrides carry one value per variant of `filament_extruder_variant` (Standard, High Flow, …). The exact key set is [`filament_options_with_variant`](extruder-variants.md#the-four-key-sets); -`slow_down_min_speed` and `pressure_advance` are not in it. Keep every such array at exactly that width, even where the +`slow_down_min_speed` and `fan_max_speed` are not in it. Keep every such array at exactly that width, even where the setting does not differ per variant, and measure the High Flow variant rather than copying Standard ([extruder-variants.md](extruder-variants.md#filament)). diff --git a/resources/profiles/OrcaFilamentLibrary.json b/resources/profiles/OrcaFilamentLibrary.json index 084a12993f..ebf4b7b928 100644 --- a/resources/profiles/OrcaFilamentLibrary.json +++ b/resources/profiles/OrcaFilamentLibrary.json @@ -1,6 +1,6 @@ { "name": "OrcaFilamentLibrary", - "version": "02.04.00.15", + "version": "02.04.00.16", "force_update": "0", "description": "Orca Filament Library", "filament_list": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth ABS rABS.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth ABS rABS.json index 4d06956b11..b9ee6494c5 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth ABS rABS.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth ABS rABS.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura FDA.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura FDA.json index 7bc7435c74..8532e514a3 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura FDA.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura FDA.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura.json index b33951c7ba..c7690e1178 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA Adura.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA-CF Adura X.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA-CF Adura X.json index 9f938e51e9..7dab608a6f 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA-CF Adura X.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA-CF Adura X.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA6 Addlantis.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA6 Addlantis.json index 352bd80ac6..6e068507f1 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA6 Addlantis.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PA6 Addlantis.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PC BLend HT LCF.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PC BLend HT LCF.json index 839c055ebf..de13a67d83 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PC BLend HT LCF.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PC BLend HT LCF.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Base.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Base.json index f2e5824c14..b1120899d7 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Base.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Base.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG ESD.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG ESD.json index 1074484973..cc7012c75d 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG ESD.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG ESD.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Economy.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Economy.json index 3747f7a86f..48119c7b04 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Economy.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Economy.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Flame v0.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Flame v0.json index 7dea674787..0650202cea 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Flame v0.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG Flame v0.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG PRO Matte.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG PRO Matte.json index 8dc175d8e1..978db741a7 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG PRO Matte.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG PRO Matte.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "1" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG rPETG Matte.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG rPETG Matte.json index 0454a13f5c..8514e62315 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG rPETG Matte.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG rPETG Matte.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG-CF Rigid X.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG-CF Rigid X.json index 024faed63d..d2789e2d15 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG-CF Rigid X.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PETG-CF Rigid X.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "0" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA E-PLA.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA E-PLA.json index 1f6892b815..b92f8c27b5 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA E-PLA.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA E-PLA.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Economy.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Economy.json index ccacef16f4..de0cdb9f05 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Economy.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Economy.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA HT-PLA PRO Matte.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA HT-PLA PRO Matte.json index aa466f3c3e..e8e2fca28a 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA HT-PLA PRO Matte.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA HT-PLA PRO Matte.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Premium Silk.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Premium Silk.json index f794753c4a..f3cb324a87 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Premium Silk.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Premium Silk.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Textura.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Textura.json index 73cfea81a4..9b49ebac26 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Textura.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Textura.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Wood.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Wood.json index 5d64f416b7..e29534a5ad 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Wood.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA Wood.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "0" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA High Speed.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA High Speed.json index d752b3adaf..98a98c4536 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA High Speed.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA High Speed.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA.json index 2deaf8d323..def3487467 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA X-PLA.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA rPLA RE-ADD.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA rPLA RE-ADD.json index 5ef8ccc6b6..ef614b39ae 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA rPLA RE-ADD.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PLA rPLA RE-ADD.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PVDF Adamant S1.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PVDF Adamant S1.json index 608d67a5bd..e0a99b8b05 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PVDF Adamant S1.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth PVDF Adamant S1.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU EasyFlex.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU EasyFlex.json index 25f2c310db..a1d9deab44 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU EasyFlex.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU EasyFlex.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 85A.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 85A.json index a922a91ba1..e944d203a9 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 85A.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 85A.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 95A.json b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 95A.json index 80c80328e8..d0e272d902 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 95A.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/addnorth/BBL/addnorth TPU Pro Matte 95A.json @@ -111,6 +111,7 @@ "1" ], "enable_pressure_advance": [ + "0", "0" ], "eng_plate_temp": [ @@ -316,6 +317,7 @@ "2" ], "pressure_advance": [ + "0.02", "0.02" ], "reduce_fan_stop_start_freq": [ diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 8bbeb05514..9f06ce9d3d 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -1497,12 +1497,7 @@ static std::vector get_path_of_change_filament(const Print& print) check_add_eol(toolchange_gcode_str); // SoftFever: set new PA for new filament - if (gcodegen.config().enable_pressure_advance.get_at(new_filament_id)) { - gcode += gcodegen.writer().set_pressure_advance(gcodegen.config().pressure_advance.get_at(new_filament_id)); - // Orca: Adaptive PA - // Reset Adaptive PA processor last PA value - gcodegen.m_pa_processor->resetPreviousPA(gcodegen.config().pressure_advance.get_at(new_filament_id)); - } + gcode += gcodegen.set_filament_pressure_advance(new_filament_id); // A phony move to the end position at the wipe tower. gcodegen.writer().travel_to_xy((end_pos + plate_origin_2d).cast()); @@ -1826,12 +1821,8 @@ static std::vector get_path_of_change_filament(const Print& print) check_add_eol(toolchange_gcode_str); // SoftFever: set new PA for new filament - if (new_extruder_id != -1 && gcodegen.config().enable_pressure_advance.get_at(new_extruder_id)) { - gcode += gcodegen.writer().set_pressure_advance(gcodegen.config().pressure_advance.get_at(new_extruder_id)); - // Orca: Adaptive PA - // Reset Adaptive PA processor last PA value - gcodegen.m_pa_processor->resetPreviousPA(gcodegen.config().pressure_advance.get_at(new_extruder_id)); - } + if (new_extruder_id != -1) + gcode += gcodegen.set_filament_pressure_advance(new_extruder_id); // A phony move to the end position at the wipe tower. gcodegen.writer().travel_to_xy((end_pos + plate_origin_2d).cast()); @@ -3232,7 +3223,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato m_cooling_buffer->set_current_extruder(initial_extruder_id, extruder_id); // Orca: Initialise AdaptivePA processor filter - m_pa_processor = std::make_unique(*this, tool_ordering.all_extruders()); + m_pa_processor = std::make_unique(*this); // Update output variables after the extruders were initialized. m_placeholder_parser_integration.init(m_writer); @@ -3797,12 +3788,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato } } // Orca: add missing PA settings for initial filament - if (m_config.enable_pressure_advance.get_at(initial_non_support_extruder_id)) { - file.write(m_writer.set_pressure_advance(m_config.pressure_advance.get_at(initial_non_support_extruder_id))); - // Orca: Adaptive PA - // Reset Adaptive PA processor last PA value - m_pa_processor->resetPreviousPA(m_config.pressure_advance.get_at(initial_non_support_extruder_id)); - } + file.write(set_filament_pressure_advance(initial_non_support_extruder_id)); } //flush FanMover buffer to avoid modifying the start gcode if it's manual. @@ -8390,12 +8376,16 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // Orca: Dynamic PA // If adaptive PA is enabled, by default evaluate PA on all extrusion moves + // The PA settings and the PA_CHANGE tags use the filament config index of the filament's extruder variant + const unsigned int pa_config_index = (unsigned int) get_filament_config_index(m_writer.filament()->id()); + const bool adaptive_pa = m_config.adaptive_pressure_advance.get_at(pa_config_index) && m_config.enable_pressure_advance.get_at(pa_config_index); + const bool adaptive_pa_overhangs = adaptive_pa && m_config.adaptive_pressure_advance_overhangs.get_at(pa_config_index); bool is_pa_calib = m_curr_print->calib_mode() == CalibMode::Calib_PA_Line || m_curr_print->calib_mode() == CalibMode::Calib_PA_Pattern || m_curr_print->calib_mode() == CalibMode::Calib_PA_Tower; bool evaluate_adaptive_pa = false; bool role_change = (m_last_extrusion_role != path.role()); - if (!is_pa_calib && FILAMENT_CONFIG(adaptive_pressure_advance) && FILAMENT_CONFIG(enable_pressure_advance)) { + if (!is_pa_calib && adaptive_pa) { evaluate_adaptive_pa = true; // If we have already emmited a PA change because the m_multi_flow_segment_path_pa_set is set // skip re-issuing the PA change tag. @@ -8478,7 +8468,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // Post processor flag generation code segment when option to emit only at role changes is enabled // Variables published to the post processor: // 1) Tag to trigger a PA evaluation (because a role change was identified and the user has requested dynamic PA adjustments) - // 2) Current extruder ID (to identify the PA model for the currently used extruder) + // 2) Current filament config index (to identify the PA model for the currently used filament and its extruder variant) // 3) mm3_per_mm value (to then multiply by the final model print speed after slowdown for cooling is applied) // 4) the current acceleration (to pass to the model for evaluation) // 5) whether this is an external perimeter (for future use) @@ -8489,7 +8479,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, if (m_multi_flow_segment_path_average_mm3_per_mm > 0) { sprintf(buf, ";%sT%u MM3MM:%g ACCEL:%u BR:%d RC:%d OV:%d\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::PA_Change).c_str(), - m_writer.filament()->id(), + pa_config_index, m_multi_flow_segment_path_average_mm3_per_mm, acceleration_i, ((path.role() == erBridgeInfill) ||(path.role() == erOverhangPerimeter)), @@ -8502,7 +8492,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // to issue a zero flow PA change command for this sprintf(buf, ";%sT%u MM3MM:%g ACCEL:%u BR:%d RC:%d OV:%d\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::PA_Change).c_str(), - m_writer.filament()->id(), + pa_config_index, _mm3_per_mm, acceleration_i, ((path.role() == erBridgeInfill) ||(path.role() == erOverhangPerimeter)), @@ -8610,11 +8600,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // or a flow change, so emit the flag to evaluate PA for the upcomming extrusion // Emit tag before new speed is set so the post processor reads the next speed immediately and uses it. // Dont emit tag if it has just already been emitted from a role change above - if(_mm3_per_mm >0 && - FILAMENT_CONFIG(adaptive_pressure_advance) && - FILAMENT_CONFIG(enable_pressure_advance) && - FILAMENT_CONFIG(adaptive_pressure_advance_overhangs) && - !evaluate_adaptive_pa){ + if(_mm3_per_mm >0 && adaptive_pa_overhangs && !evaluate_adaptive_pa){ if(writer().get_current_speed() > F){ // Ramping down speed - use overhang logic where the minimum speed is used between current and upcoming extrusion if(m_config.gcode_comments){ sprintf(buf, "; Ramp down-non-variable\n"); @@ -8622,7 +8608,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, } sprintf(buf, ";%sT%u MM3MM:%g ACCEL:%u BR:%d RC:%d OV:%d\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::PA_Change).c_str(), - m_writer.filament()->id(), + pa_config_index, _mm3_per_mm, acceleration_i, ((path.role() == erBridgeInfill) ||(path.role() == erOverhangPerimeter)), @@ -8637,7 +8623,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, } sprintf(buf, ";%sT%u MM3MM:%g ACCEL:%u BR:%d RC:%d OV:%d\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::PA_Change).c_str(), - m_writer.filament()->id(), + pa_config_index, _mm3_per_mm, acceleration_i, ((path.role() == erBridgeInfill) ||(path.role() == erOverhangPerimeter)), @@ -8843,10 +8829,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // ORCA: Adaptive PA code segment when adjusting PA within the same feature // There is a speed change or flow change so emit the flag to evaluate PA for the upcomming extrusion // Emit tag before new speed is set so the post processor reads the next speed immediately and uses it. - if(_mm3_per_mm >0 && - EXTRUDER_CONFIG(adaptive_pressure_advance) && - EXTRUDER_CONFIG(enable_pressure_advance) && - EXTRUDER_CONFIG(adaptive_pressure_advance_overhangs) ){ + if(_mm3_per_mm >0 && adaptive_pa_overhangs){ if(last_set_speed > new_speed){ // Ramping down speed - use overhang logic where the minimum speed is used between current and upcoming extrusion if(m_config.gcode_comments) { sprintf(buf, "; Ramp up-variable\n"); @@ -8854,7 +8837,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, } sprintf(buf, ";%sT%u MM3MM:%g ACCEL:%u BR:%d RC:%d OV:%d\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::PA_Change).c_str(), - m_writer.filament()->id(), + pa_config_index, _mm3_per_mm, acceleration_i, ((path.role() == erBridgeInfill) ||(path.role() == erOverhangPerimeter)), @@ -8869,7 +8852,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, } sprintf(buf, ";%sT%u MM3MM:%g ACCEL:%u BR:%d RC:%d OV:%d\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::PA_Change).c_str(), - m_writer.filament()->id(), + pa_config_index, _mm3_per_mm, acceleration_i, ((path.role() == erBridgeInfill) ||(path.role() == erOverhangPerimeter)), @@ -9422,33 +9405,29 @@ void GCode::update_placeholder_parser_with_variant_params() if (num_filaments == 0) return; - // Helpers: remap config arrays from variant index space to filament_id index space. + // Helper: remap config arrays from variant index space to filament_id index space. // After remapping, gcode templates can use param[filament_id] directly. - auto remap_floats_by_filament = [&](const auto &src) { - std::vector dst(num_filaments); - for (size_t i = 0; i < num_filaments; ++i) - dst[i] = src.get_at(get_filament_config_index(i)); - return dst; - }; - auto remap_ints_by_filament = [&](const auto &src) { - std::vector dst(num_filaments); + auto remap_by_filament = [&](const auto &src) { + std::decay_t dst(num_filaments); for (size_t i = 0; i < num_filaments; ++i) dst[i] = src.get_at(get_filament_config_index(i)); return dst; }; // --- filament_options_with_variant: gcode indexes by filament_id --- - this->placeholder_parser().set("filament_max_volumetric_speed", new ConfigOptionFloats(remap_floats_by_filament(m_config.filament_max_volumetric_speed))); - this->placeholder_parser().set("filament_pre_cooling_temperature", new ConfigOptionInts(remap_ints_by_filament(m_config.filament_pre_cooling_temperature))); - this->placeholder_parser().set("filament_pre_cooling_temperature_nc", new ConfigOptionInts(remap_ints_by_filament(m_config.filament_pre_cooling_temperature_nc))); - this->placeholder_parser().set("filament_cooling_before_tower", new ConfigOptionFloats(remap_floats_by_filament(m_config.filament_cooling_before_tower))); - this->placeholder_parser().set("nozzle_temperature_initial_layer", new ConfigOptionInts(remap_ints_by_filament(m_config.nozzle_temperature_initial_layer))); - this->placeholder_parser().set("nozzle_temperature", new ConfigOptionInts(remap_ints_by_filament(m_config.nozzle_temperature))); + this->placeholder_parser().set("filament_max_volumetric_speed", new ConfigOptionFloats(remap_by_filament(m_config.filament_max_volumetric_speed))); + this->placeholder_parser().set("filament_pre_cooling_temperature", new ConfigOptionInts(remap_by_filament(m_config.filament_pre_cooling_temperature))); + this->placeholder_parser().set("filament_pre_cooling_temperature_nc", new ConfigOptionInts(remap_by_filament(m_config.filament_pre_cooling_temperature_nc))); + this->placeholder_parser().set("filament_cooling_before_tower", new ConfigOptionFloats(remap_by_filament(m_config.filament_cooling_before_tower))); + this->placeholder_parser().set("nozzle_temperature_initial_layer", new ConfigOptionInts(remap_by_filament(m_config.nozzle_temperature_initial_layer))); + this->placeholder_parser().set("nozzle_temperature", new ConfigOptionInts(remap_by_filament(m_config.nozzle_temperature))); // first_layer_temperature is a legacy alias of nozzle_temperature_initial_layer - this->placeholder_parser().set("first_layer_temperature", new ConfigOptionInts(remap_ints_by_filament(m_config.nozzle_temperature_initial_layer))); + this->placeholder_parser().set("first_layer_temperature", new ConfigOptionInts(remap_by_filament(m_config.nozzle_temperature_initial_layer))); + this->placeholder_parser().set("pressure_advance", new ConfigOptionFloats(remap_by_filament(m_config.pressure_advance))); + this->placeholder_parser().set("enable_pressure_advance", new ConfigOptionBools(remap_by_filament(m_config.enable_pressure_advance))); // --- printer_options_with_variant_1: in m_config these are already merged as filament-indexed --- - this->placeholder_parser().set("retraction_distances_when_cut", new ConfigOptionFloats(remap_floats_by_filament(m_config.retraction_distances_when_cut))); + this->placeholder_parser().set("retraction_distances_when_cut", new ConfigOptionFloats(remap_by_filament(m_config.retraction_distances_when_cut))); // hotend_cooling_rate / hotend_heating_rate: gcode uses [filament_map[x]-1] (extruder_id), no remap needed // --- filament_map: per-layer dynamic, sync from m_config to placeholder_parser --- @@ -9458,10 +9437,10 @@ void GCode::update_placeholder_parser_with_variant_params() { // Fast purge mode uses filament_flush_temp_fast; Default is inert. bool use_fast_flush = m_config.prime_volume_mode == PrimeVolumeMode::pvmFast; - auto flush_v_speed = remap_floats_by_filament(m_config.filament_flush_volumetric_speed); - auto filament_max_v = remap_floats_by_filament(m_config.filament_max_volumetric_speed); - auto flush_temps = remap_ints_by_filament(use_fast_flush ? m_config.filament_flush_temp_fast - : m_config.filament_flush_temp); + auto flush_v_speed = remap_by_filament(m_config.filament_flush_volumetric_speed); + auto filament_max_v = remap_by_filament(m_config.filament_max_volumetric_speed); + auto flush_temps = remap_by_filament(use_fast_flush ? m_config.filament_flush_temp_fast + : m_config.filament_flush_temp); for (size_t i = 0; i < num_filaments; ++i) { if (flush_v_speed[i] == 0) flush_v_speed[i] = filament_max_v[i]; @@ -9513,12 +9492,7 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo gcode += this->placeholder_parser_process("filament_start_gcode", filament_start_gcode, new_filament_id, &config); check_add_eol(gcode); } - if (m_config.enable_pressure_advance.get_at(new_filament_id)) { - gcode += m_writer.set_pressure_advance(m_config.pressure_advance.get_at(new_filament_id)); - // Orca: Adaptive PA - // Reset Adaptive PA processor last PA value - m_pa_processor->resetPreviousPA(m_config.pressure_advance.get_at(new_filament_id)); - } + gcode += set_filament_pressure_advance(new_filament_id); gcode += m_writer.toolchange(new_filament_id, new_extruder_id); if (Extruder *fil = m_writer.filament()) @@ -9909,18 +9883,24 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo if (m_ooze_prevention.enable && !defer_temp_wait) gcode += m_ooze_prevention.post_toolchange(*this); - if (m_config.enable_pressure_advance.get_at(new_filament_id)) { - gcode += m_writer.set_pressure_advance(m_config.pressure_advance.get_at(new_filament_id)); - // Orca: Adaptive PA - // Reset Adaptive PA processor last PA value - m_pa_processor->resetPreviousPA(m_config.pressure_advance.get_at(new_filament_id)); - } + gcode += set_filament_pressure_advance(new_filament_id); //Orca: tool changer or IDEX's firmware may change Z position, so we set it to unknown/undefined m_last_pos_defined = false; return gcode; } +std::string GCode::set_filament_pressure_advance(unsigned int filament_id) +{ + const size_t fi = get_filament_config_index(filament_id); + if (!m_config.enable_pressure_advance.get_at(fi)) + return {}; + // Orca: Adaptive PA + // Reset Adaptive PA processor last PA value + m_pa_processor->resetPreviousPA(m_config.pressure_advance.get_at(fi)); + return m_writer.set_pressure_advance(m_config.pressure_advance.get_at(fi)); +} + inline std::string polygon_to_string(const Polygon &polygon, Print *print, bool is_print_space = false) { std::ostringstream gcode; gcode << "["; diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 41986cc1d6..bded77d9d1 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -281,6 +281,8 @@ public: // extra_retract forwards a PETG pre-extrusion over-extrusion; default 0 -> identical to the plain deretract. std::string unretract(float extra_retract = 0.f) { return m_writer.unlift() + m_writer.unretract(extra_retract); } std::string set_extruder(unsigned int extruder_id, double print_z, bool by_object=false, int toolchange_temp_override = -1, bool defer_temp_wait = false); + // Sets the pressure advance of the filament's extruder variant, if enabled for it. + std::string set_filament_pressure_advance(unsigned int filament_id); bool is_BBL_Printer(); WipeTowerType wipe_tower_type(); diff --git a/src/libslic3r/GCode/AdaptivePAProcessor.cpp b/src/libslic3r/GCode/AdaptivePAProcessor.cpp index 80d6b034ad..8145db3b9f 100644 --- a/src/libslic3r/GCode/AdaptivePAProcessor.cpp +++ b/src/libslic3r/GCode/AdaptivePAProcessor.cpp @@ -21,38 +21,30 @@ namespace Slic3r { * * @param gcodegen A reference to the GCode object that generates the G-code. */ -AdaptivePAProcessor::AdaptivePAProcessor(GCode &gcodegen, const std::vector &tools_used) +AdaptivePAProcessor::AdaptivePAProcessor(GCode &gcodegen) : m_gcodegen(gcodegen), m_config(gcodegen.config()), m_last_predicted_pa(0.0), m_max_next_feedrate(0.0), m_next_feedrate(0.0), m_current_feedrate(0.0), - m_last_extruder_id(-1), + m_last_config_index(-1), m_pa_change_pattern(R"(; PA_CHANGE:T(\d+) MM3MM:([0-9]*\.[0-9]+) ACCEL:(\d+) BR:(\d+) RC:(\d+) OV:(\d+))"), m_g1_f_pattern(R"(G1 F([0-9]+))") { - // Constructor body can be used for further initialization if necessary - for (unsigned int tool : tools_used) { - // Only enable model for the tool if both PA and adaptive PA options are enabled - if(m_config.adaptive_pressure_advance.get_at(tool) && m_config.enable_pressure_advance.get_at(tool)){ - auto interpolator = std::make_unique(); - // Get calibration values from extruder - std::string pa_calibration_values = m_config.adaptive_pressure_advance_model.get_at(tool); - // Setup the model and store it in the tool-interpolation model map - interpolator->parseAndSetData(pa_calibration_values); - m_AdaptivePAInterpolators[tool] = std::move(interpolator); - } - } } -// Method to get the interpolator for a specific tool ID -AdaptivePAInterpolator* AdaptivePAProcessor::getInterpolator(unsigned int tool_id) { - auto it = m_AdaptivePAInterpolators.find(tool_id); - if (it != m_AdaptivePAInterpolators.end()) { - return it->second.get(); +// Method to get the interpolator for a specific filament config index. +// The model is built the first time an index is requested, as the indices in use depend on +// the extruder variant each filament prints with. +AdaptivePAInterpolator* AdaptivePAProcessor::getInterpolator(unsigned int config_index) { + auto [it, inserted] = m_AdaptivePAInterpolators.try_emplace(config_index); + // Only enable model for the index if both PA and adaptive PA options are enabled + if (inserted && m_config.adaptive_pressure_advance.get_at(config_index) && m_config.enable_pressure_advance.get_at(config_index)) { + it->second = std::make_unique(); + it->second->parseAndSetData(m_config.adaptive_pressure_advance_model.get_at(config_index)); } - return nullptr; // Handle the case where the tool_id was not found + return it->second.get(); } /** @@ -108,16 +100,17 @@ std::string AdaptivePAProcessor::process_layer(std::string &&gcode) { // the PA for that material is set. As no tag below will be found for this extruder, the original PA is retained. if (line.find("; PA_CHANGE") == 0) { // prune lines quickly before running regex check as regex is more expensive to run if (std::regex_search(line, m_match, m_pa_change_pattern)) { - int extruder_id = std::stoi(m_match[1].str()); + // The tag carries the filament config index, which selects the PA settings of the filament's extruder variant + int config_index = std::stoi(m_match[1].str()); mm3mm_value = std::stod(m_match[2].str()); accel_value = std::stod(m_match[3].str()); int isBridge = std::stoi(m_match[4].str()); int roleChange = std::stoi(m_match[5].str()); int isOverhang = std::stoi(m_match[6].str()); - // Check if the extruder ID has changed - bool extruder_changed = (extruder_id != m_last_extruder_id); - m_last_extruder_id = extruder_id; + // Check if the filament config index has changed + bool config_index_changed = (config_index != m_last_config_index); + m_last_config_index = config_index; // Save the PA_CHANGE line to output later after finding feedrate pa_change_line = line; @@ -212,23 +205,23 @@ std::string AdaptivePAProcessor::process_layer(std::string &&gcode) { // Calculate the predicted PA using the upcomming feature maximum feedrate // Get the interpolator for the active tool - AdaptivePAInterpolator* interpolator = getInterpolator(m_last_extruder_id); + AdaptivePAInterpolator* interpolator = getInterpolator(m_last_config_index); double predicted_pa = 0; double adaptive_PA_speed = 0; if(!interpolator){ // Tool not found in the interpolator map // Tool not found in the PA interpolator to tool map - predicted_pa = m_config.enable_pressure_advance.get_at(m_last_extruder_id) ? m_config.pressure_advance.get_at(m_last_extruder_id) : 0; + predicted_pa = m_config.enable_pressure_advance.get_at(m_last_config_index) ? m_config.pressure_advance.get_at(m_last_config_index) : 0; if(m_config.gcode_comments) output << "; APA: Tool doesnt have APA enabled\n"; - } else if (!interpolator->isInitialised() || (!m_config.adaptive_pressure_advance.get_at(m_last_extruder_id)) ) + } else if (!interpolator->isInitialised() || (!m_config.adaptive_pressure_advance.get_at(m_last_config_index)) ) // Check if the model is not initialised by the constructor for the active extruder // Also check that adaptive PA is enabled for that extruder. This should not be needed // as the PA change flag should not be set upstream (in the GCode.cpp file) if adaptive PA is disabled // however check for robustness sake. { // Model failed or adaptive pressure advance not enabled - use default value from m_config - predicted_pa = m_config.enable_pressure_advance.get_at(m_last_extruder_id) ? m_config.pressure_advance.get_at(m_last_extruder_id) : 0; + predicted_pa = m_config.enable_pressure_advance.get_at(m_last_config_index) ? m_config.pressure_advance.get_at(m_last_config_index) : 0; if(m_config.gcode_comments) output << "; APA: Interpolator setup failed, using default pressure advance\n"; } else { // Model setup succeeded // Proceed to identify the print speed to use to calculate the adaptive PA value @@ -249,18 +242,18 @@ std::string AdaptivePAProcessor::process_layer(std::string &&gcode) { predicted_pa = (*interpolator)(mm3mm_value * adaptive_PA_speed, accel_value); // This is a bridge, use the dedicated PA setting. - if(isBridge && m_config.adaptive_pressure_advance_bridges.get_at(m_last_extruder_id) > EPSILON) - predicted_pa = m_config.adaptive_pressure_advance_bridges.get_at(m_last_extruder_id); + if(isBridge && m_config.adaptive_pressure_advance_bridges.get_at(m_last_config_index) > EPSILON) + predicted_pa = m_config.adaptive_pressure_advance_bridges.get_at(m_last_config_index); if (predicted_pa < 0) { // If extrapolation fails, fall back to the default PA for the extruder. - predicted_pa = m_config.enable_pressure_advance.get_at(m_last_extruder_id) ? m_config.pressure_advance.get_at(m_last_extruder_id) : 0; + predicted_pa = m_config.enable_pressure_advance.get_at(m_last_config_index) ? m_config.pressure_advance.get_at(m_last_config_index) : 0; if(m_config.gcode_comments) output << "; APA: Interpolation failed, using fallback pressure advance value\n"; } } if(m_config.gcode_comments) { // Output debug GCode comments output << pa_change_line << '\n'; // Output PA change command tag - if(isBridge && m_config.adaptive_pressure_advance_bridges.get_at(m_last_extruder_id) > EPSILON) + if(isBridge && m_config.adaptive_pressure_advance_bridges.get_at(m_last_config_index) > EPSILON) output << "; APA Model Override (bridge)\n"; output << "; APA Current Speed: " << std::to_string(m_current_feedrate) << "\n"; output << "; APA Next Speed: " << std::to_string(m_next_feedrate) << "\n"; @@ -269,7 +262,7 @@ std::string AdaptivePAProcessor::process_layer(std::string &&gcode) { output << "; APA Flow rate: " << std::to_string(mm3mm_value * m_max_next_feedrate) << "\n"; output << "; APA Prev PA: " << std::to_string(m_last_predicted_pa) << " New PA: " << std::to_string(predicted_pa) << "\n"; } - if (extruder_changed || std::fabs(predicted_pa - m_last_predicted_pa) > EPSILON) { + if (config_index_changed || std::fabs(predicted_pa - m_last_predicted_pa) > EPSILON) { output << m_gcodegen.writer().set_pressure_advance(predicted_pa); // Use m_writer to set pressure advance m_last_predicted_pa = predicted_pa; // Update the last predicted PA value } diff --git a/src/libslic3r/GCode/AdaptivePAProcessor.hpp b/src/libslic3r/GCode/AdaptivePAProcessor.hpp index 33d4c26d24..01aa2f1104 100644 --- a/src/libslic3r/GCode/AdaptivePAProcessor.hpp +++ b/src/libslic3r/GCode/AdaptivePAProcessor.hpp @@ -11,7 +11,6 @@ #include #include #include -#include #include "AdaptivePAInterpolator.hpp" namespace Slic3r { @@ -33,7 +32,7 @@ public: * * @param gcodegen A reference to the GCode object that generates the G-code. */ - AdaptivePAProcessor(GCode &gcodegen, const std::vector &tools_used); + AdaptivePAProcessor(GCode &gcodegen); /** * @brief Processes a layer of G-code and applies adaptive pressure advance. @@ -70,28 +69,25 @@ public: private: GCode &m_gcodegen; ///< Reference to the GCode object. - std::unordered_map> m_AdaptivePAInterpolators; ///< Map between Interpolator objects and tool ID's + std::unordered_map> m_AdaptivePAInterpolators; ///< Map between Interpolator objects and filament config indices (null when adaptive PA is off) const PrintConfig &m_config; ///< Reference to the print configuration. double m_last_predicted_pa; ///< Last predicted pressure advance value. double m_max_next_feedrate; ///< Maximum feed rate (speed) for the upcomming island. If no speed is found, the previous island speed is used. double m_next_feedrate; ///< First feed rate (speed) for the upcomming island. double m_current_feedrate; ///< Current, latest feedrate. - int m_last_extruder_id; ///< Last used extruder ID. + int m_last_config_index; ///< Filament config index of the last PA_CHANGE tag. std::regex m_pa_change_pattern; ///< Regular expression to detect PA_CHANGE pattern. std::regex m_g1_f_pattern; ///< Regular expression to detect G1 F pattern. std::smatch m_match; ///< Match results for regular expressions. /** - * @brief Get the PA interpolator attached to the specified tool ID. + * @brief Get the PA interpolator attached to the specified filament config index. * - * This method manually sets the adaptive PA internally held value. - * Call this when changing tools or in any other case where the internally assumed last PA value may be incorrect - * - * @param An integer with the tool ID for which the PA interpolation model is to be returned. - * @return The Adaptive PA Interpolator object corresponding to that tool. + * @param config_index The filament config index (one per filament and extruder variant) for which the PA interpolation model is to be returned. + * @return The Adaptive PA Interpolator object corresponding to that index, or nullptr when adaptive PA is off for it. */ - AdaptivePAInterpolator* getInterpolator(unsigned int tool_id); + AdaptivePAInterpolator* getInterpolator(unsigned int config_index); }; } // namespace Slic3r diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index b708b759b2..be4e217d5c 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2439,12 +2439,18 @@ StringObjectException Print::validate(std::vector *warnin if (m_default_region_config.precise_outer_wall && m_default_region_config.wall_sequence != WallSequence::InnerOuter) warn(L("The precise wall option will be ignored for outer-inner or inner-outer-inner wall sequences."), "precise_outer_wall"); - // check adaptive pressure advance model - for (unsigned int extruder_id : extruders) { - if (m_config.adaptive_pressure_advance.get_at(extruder_id) && - m_config.enable_pressure_advance.get_at(extruder_id)) { - - const std::string pa_model = m_config.adaptive_pressure_advance_model.get_at(extruder_id); + // check adaptive pressure advance model of every extruder variant column of the used filaments + const std::vector &self_index = m_config.filament_self_index.values; + const size_t pa_columns = std::max(m_config.adaptive_pressure_advance_model.size(), size_t(extruders.back()) + 1); + for (size_t column = 0; column < pa_columns; ++column) { + // filament_self_index maps a column to its filament once the filament arrays hold one column per variant + const unsigned int filament_id = self_index.size() == pa_columns ? self_index[column] - 1 : column; + if (!std::binary_search(extruders.begin(), extruders.end(), filament_id)) + continue; + if (m_config.adaptive_pressure_advance.get_at(column) && + m_config.enable_pressure_advance.get_at(column)) { + + const std::string pa_model = m_config.adaptive_pressure_advance_model.get_at(column); if (!pa_model.empty()) { std::string validation_error = AdaptivePAProcessor::validate_adaptive_pa_model(pa_model); if (!validation_error.empty()) { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 440f3e761a..1202477b6a 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -9403,6 +9403,22 @@ void PrintConfigDef::handle_legacy_composite(DynamicPrintConfig &config) } config.set_key_value("wiping_volumes_use_custom_matrix", new ConfigOptionBool(custom)); } + + // Orca: a config saved before a key joined filament_options_with_variant stores it once per filament + // rather than once per filament variant. Give every variant of a filament that filament's value. + if (auto *self_index = config.option("filament_self_index"); self_index && !self_index->empty()) { + const int filament_count = *std::max_element(self_index->values.begin(), self_index->values.end()); + if (filament_count > 0 && size_t(filament_count) < self_index->size()) { + for (const std::string &key : filament_options_with_variant) { + auto *opt = dynamic_cast(config.option(key)); + if (!opt || opt->size() != size_t(filament_count)) + continue; + std::unique_ptr per_filament(opt->clone()); + for (size_t variant = 0; variant < self_index->size(); ++variant) + opt->set_at(per_filament.get(), variant, self_index->values[variant] - 1); + } + } + } } const PrintConfigDef print_config_def; @@ -9505,6 +9521,13 @@ std::set filament_options_with_variant = { "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed", + // Orca: pressure advance + "enable_pressure_advance", + "pressure_advance", + "adaptive_pressure_advance", + "adaptive_pressure_advance_model", + "adaptive_pressure_advance_overhangs", + "adaptive_pressure_advance_bridges", "activate_air_filtration", "activate_air_filtration_during_print", "activate_air_filtration_on_completion", diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 5a58ec7b6c..cc687ff84a 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -171,14 +171,16 @@ void ConfigManipulation::check_adaptive_pressure_advance_model(DynamicPrintConfi return; const auto* model = config->option("adaptive_pressure_advance_model"); - if (model == nullptr || model->values.empty()) + if (model == nullptr) return; - std::string raw_model; - for (const std::string& chunk : model->values) - raw_model += chunk; - - std::string error = AdaptivePAProcessor::validate_adaptive_pa_model(raw_model); + // Each extruder variant holds its own model. + std::string error; + for (const std::string& variant_model : model->values) { + error = AdaptivePAProcessor::validate_adaptive_pa_model(variant_model); + if (!error.empty()) + break; + } if (!error.empty()) { wxString msg_text = _L("Adaptive Pressure Advance model validation failed:\n"); msg_text += from_u8(error); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c95d94aba6..1fd68fa233 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -286,6 +286,30 @@ static void set_config_values(DynamicPrintConfig *config, const std::string &key } } +// Orca: a calibration print sets pressure advance explicitly to 0 on each extruder variant that +// has it disabled, so the value stored in the printer does not skew the result. Variants with +// pressure advance enabled keep their own value. +static void zero_pressure_advance_where_disabled(DynamicPrintConfig *filament_config) +{ + auto enable_pa = filament_config->option("enable_pressure_advance"); + auto pa = filament_config->option("pressure_advance"); + auto adaptive_pa = filament_config->option("adaptive_pressure_advance"); + if (!enable_pa || !pa || !adaptive_pa || pa->empty() || adaptive_pa->empty()) + return; + // All variant keys share the variant count; widen a short one rather than index past it. + if (pa->size() < enable_pa->size()) + pa->resize(enable_pa->size()); + if (adaptive_pa->size() < enable_pa->size()) + adaptive_pa->resize(enable_pa->size()); + for (size_t variant = 0; variant < enable_pa->size(); ++variant) { + if (enable_pa->get_at(variant)) + continue; + enable_pa->values[variant] = true; + pa->values[variant] = 0.0; + adaptive_pa->values[variant] = false; + } +} + bool Plater::has_illegal_filename_characters(const wxString& wxs_name) { std::string name = into_u8(wxs_name); @@ -17064,11 +17088,7 @@ void Plater::calib_input_shaping_freq(const Calib_Params& params) set_config_values(print_config, "default_jerk", 0); } - if (!filament_config->option("enable_pressure_advance")->get_at(0)) { - set_config_values(filament_config, "enable_pressure_advance", true); - set_config_values(filament_config, "pressure_advance", 0.0); - set_config_values(filament_config, "adaptive_pressure_advance", false); - } + zero_pressure_advance_where_disabled(filament_config); printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); printer_config->set_key_value("input_shaping_emit", new ConfigOptionBool{false}); @@ -17130,11 +17150,7 @@ void Plater::calib_input_shaping_damp(const Calib_Params& params) set_config_values(print_config, "default_jerk", 0); } - if (!filament_config->option("enable_pressure_advance")->get_at(0)) { - set_config_values(filament_config, "enable_pressure_advance", true); - set_config_values(filament_config, "pressure_advance", 0.0); - set_config_values(filament_config, "adaptive_pressure_advance", false); - } + zero_pressure_advance_where_disabled(filament_config); printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); printer_config->set_key_value("input_shaping_emit", new ConfigOptionBool{false}); @@ -17196,11 +17212,7 @@ void Plater::Calib_Cornering(const Calib_Params& params) set_config_values(print_config, "default_jerk", 0); } - if (!filament_config->option("enable_pressure_advance")->get_at(0)) { - set_config_values(filament_config, "enable_pressure_advance", true); - set_config_values(filament_config, "pressure_advance", 0.0); - set_config_values(filament_config, "adaptive_pressure_advance", false); - } + zero_pressure_advance_where_disabled(filament_config); printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); printer_config->set_key_value("input_shaping_emit", new ConfigOptionBool{true}); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index b5b93fca17..17ac9555dd 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4415,15 +4415,15 @@ void TabFilament::build() optgroup->append_single_option_line("pellet_flow_coefficient", "printer_basic_information_advanced#pellet-modded-printer"); optgroup->append_single_option_line("filament_flow_ratio", "material_flow_ratio_and_pressure_advance#flow-ratio", 0); - optgroup->append_single_option_line("enable_pressure_advance", "material_flow_ratio_and_pressure_advance#pressure-advance"); - optgroup->append_single_option_line("pressure_advance", "material_flow_ratio_and_pressure_advance#pressure-advance"); + optgroup->append_single_option_line("enable_pressure_advance", "material_flow_ratio_and_pressure_advance#pressure-advance", 0); + optgroup->append_single_option_line("pressure_advance", "material_flow_ratio_and_pressure_advance#pressure-advance", 0); // Orca: adaptive pressure advance and calibration model - optgroup->append_single_option_line("adaptive_pressure_advance", "material_flow_ratio_and_pressure_advance#enable-adaptive-pressure-advance-beta"); - optgroup->append_single_option_line("adaptive_pressure_advance_overhangs", "material_flow_ratio_and_pressure_advance#enable-adaptive-pressure-advance-for-overhangs-beta"); - optgroup->append_single_option_line("adaptive_pressure_advance_bridges", "material_flow_ratio_and_pressure_advance#pressure-advance-for-bridges"); + optgroup->append_single_option_line("adaptive_pressure_advance", "material_flow_ratio_and_pressure_advance#enable-adaptive-pressure-advance-beta", 0); + optgroup->append_single_option_line("adaptive_pressure_advance_overhangs", "material_flow_ratio_and_pressure_advance#enable-adaptive-pressure-advance-for-overhangs-beta", 0); + optgroup->append_single_option_line("adaptive_pressure_advance_bridges", "material_flow_ratio_and_pressure_advance#pressure-advance-for-bridges", 0); - Option option = optgroup->get_option("adaptive_pressure_advance_model"); + Option option = optgroup->get_option("adaptive_pressure_advance_model", 0); option.opt.full_width = true; option.opt.is_code = true; option.opt.height = 15; @@ -4432,7 +4432,7 @@ void TabFilament::build() DynamicPrintConfig& filament_config = m_preset_bundle->filaments.get_edited_preset().config; update_dirty(); - if (opt_key == "adaptive_pressure_advance_model") + if (opt_key.substr(0, opt_key.find('#')) == "adaptive_pressure_advance_model") m_config_manipulation.check_adaptive_pressure_advance_model(&filament_config); on_value_change(opt_key, value); @@ -4829,8 +4829,11 @@ void TabFilament::toggle_options() } if (m_active_page->title() == L("Filament")) { - bool pa = m_config->opt_bool("enable_pressure_advance", 0); - toggle_option("pressure_advance", pa); + const int selection = m_variant_combo ? m_variant_combo->GetSelection() : 0; + const unsigned int variant_idx = (unsigned int) std::max(selection, 0); + + bool pa = m_config->opt_bool("enable_pressure_advance", variant_idx); + toggle_option("pressure_advance", pa, 0); //Orca: Enable the plates that should be visible when multi bed support is enabled or a BBL printer is selected; otherwise, enable only the plate visible for the selected bed type. DynamicConfig& proj_cfg = m_preset_bundle->project_config; @@ -4858,12 +4861,12 @@ void TabFilament::toggle_options() // Orca: adaptive pressure advance and calibration model // If PA is not enabled, disable adaptive pressure advance and hide the model section // If adaptive PA is not enabled, hide the adaptive PA model section - toggle_option("adaptive_pressure_advance", pa); - toggle_option("adaptive_pressure_advance_overhangs", pa); - bool has_adaptive_pa = m_config->opt_bool("adaptive_pressure_advance", 0); - toggle_line("adaptive_pressure_advance_overhangs", has_adaptive_pa && pa); - toggle_line("adaptive_pressure_advance_model", has_adaptive_pa && pa); - toggle_line("adaptive_pressure_advance_bridges", has_adaptive_pa && pa); + toggle_option("adaptive_pressure_advance", pa, 0); + toggle_option("adaptive_pressure_advance_overhangs", pa, 0); + bool has_adaptive_pa = m_config->opt_bool("adaptive_pressure_advance", variant_idx); + toggle_line("adaptive_pressure_advance_overhangs", has_adaptive_pa && pa, 0); + toggle_line("adaptive_pressure_advance_model", has_adaptive_pa && pa, 0); + toggle_line("adaptive_pressure_advance_bridges", has_adaptive_pa && pa, 0); bool is_pellet_printer = printer_cfg.opt_bool("pellet_modded_printer"); toggle_line("pellet_flow_coefficient", is_pellet_printer); @@ -4871,8 +4874,6 @@ void TabFilament::toggle_options() toggle_line("activate_chamber_temp_control", printer_cfg.opt_bool("support_chamber_temp_control")); - const int selection = m_variant_combo ? m_variant_combo->GetSelection() : 0; - const unsigned int variant_idx = (unsigned int) std::max(selection, 0); std::string volumetric_speed_cos = m_config->opt_string("volumetric_speed_coefficients", variant_idx); bool enable_fit = volumetric_speed_cos != "0 0 0 0 0 0"; toggle_option("filament_adaptive_volumetric_speed", enable_fit, 256 + variant_idx); diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 2a73dcac83..c39efaccf5 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -768,3 +768,128 @@ TEST_CASE("Each filament prints with its variant of the extruder's variant strin CHECK(gcode.find("; nozzle_temperature = " + resolved + "\n") != std::string::npos); } } + +// An adaptive pressure advance model predicting the same pressure advance at every flow and acceleration. +static std::string constant_pressure_advance_model(const std::string &pa) +{ + return pa + ",1,1000\n" + pa + ",500,1000\n" + pa + ",1,100000\n" + pa + ",500,100000"; +} + +// The pressure advance values a Klipper G-code sets. +static std::set pressure_advance_values(const std::string &gcode) +{ + const std::string token = "SET_PRESSURE_ADVANCE ADVANCE="; + std::set values; + std::istringstream stream(gcode); + for (std::string line; std::getline(stream, line);) + if (line.rfind(token, 0) == 0) + values.insert(line.substr(token.size(), line.find(';') - token.size())); + return values; +} + +// Pressure advance, and the adaptive pressure advance model, are tuned per extruder variant like the +// other filament variant settings. +TEST_CASE("Each filament sets the pressure advance of its extruder variant", "[MultiFilament]") +{ + auto [nozzle_volume_type, filament, pressure_advance, adaptive_pressure_advance] = GENERATE(table({ + { nvtStandard, 1, "0.021", "0.012" }, + { nvtHighFlow, 1, "0.037", "0.034" }, + { nvtHighFlow, 2, "0.043", "0.056" }, // filament 2 defines no High Flow variant + })); + const bool adaptive = GENERATE(false, true); + DYNAMIC_SECTION(get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament << (adaptive ? ", adaptive" : "")) { + DynamicPrintConfig config = multifilament_config(2, { + { "gcode_flavor", "klipper" }, + { "extruder_variant_list", "Direct Drive Standard,Direct Drive High Flow" }, + // filament 1 defines Standard (0.021) and High Flow (0.037), filament 2 Standard (0.043) + { "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" }, + { "filament_self_index", "1,1,2" }, + { "enable_pressure_advance", "1,1,1" }, + { "pressure_advance", "0.021,0.037,0.043" }, + { "adaptive_pressure_advance", adaptive ? "1,1,1" : "0,0,0" }, + { "sparse_infill_filament_id", filament }, + { "internal_solid_filament_id", filament }, + { "top_surface_filament_id", filament }, + { "bottom_surface_filament_id", filament }, + { "outer_wall_filament_id", filament }, + { "inner_wall_filament_id", filament }, + { "enable_prime_tower", 0 }, + { "skirt_loops", 0 }, + { "brim_type", "no_brim" }, + // custom G-code indexes the per-filament arrays by filament + { "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" }, + }); + config.option("adaptive_pressure_advance_model")->values = { + constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"), constant_pressure_advance_model("0.056") }; + config.option("nozzle_volume_type", true)->values = { nozzle_volume_type }; + const std::string gcode = slice({ cube(20) }, config); + + // The toolchange sets the variant's pressure advance; with adaptive pressure advance, the + // prediction of the variant's model then replaces it. + std::set expected{ pressure_advance }; + if (adaptive) + expected.insert(adaptive_pressure_advance); + CHECK(pressure_advance_values(gcode) == expected); + CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos); + } +} + +// On a printer with two extruders, a filament takes the pressure advance of the variant of the extruder +// it is mapped to, whichever filament and extruder that is. +TEST_CASE("Each filament sets the pressure advance of its extruder variant on a two-extruder printer", "[MultiFilament]") +{ + auto [filament, extruder, pressure_advance, adaptive_pressure_advance] = GENERATE(table({ + { 1, 1, "0.021", "0.012" }, + { 1, 2, "0.037", "0.034" }, + { 2, 1, "0.043", "0.056" }, + { 2, 2, "0.049", "0.078" }, + })); + const bool adaptive = GENERATE(false, true); + DYNAMIC_SECTION("filament " << filament << " on extruder " << extruder << (adaptive ? ", adaptive" : "")) { + // the other filament goes on the other extruder + const std::string filament_map = filament == 1 ? std::to_string(extruder) + "," + std::to_string(3 - extruder) : + std::to_string(3 - extruder) + "," + std::to_string(extruder); + DynamicPrintConfig config = multifilament_config(2, { + { "gcode_flavor", "klipper" }, + { "single_extruder_multi_material", 0 }, + { "nozzle_diameter", "0.4,0.4" }, + { "extruder_printable_height", "0,0" }, + // extruder 1 has a Standard nozzle, extruder 2 a High Flow one + { "printer_extruder_id", "1,2" }, + { "printer_extruder_variant", "Direct Drive Standard;Direct Drive High Flow" }, + { "extruder_variant_list", "Direct Drive Standard;Direct Drive High Flow" }, + { "filament_map", filament_map }, + // both filaments define Standard and High Flow + { "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard;Direct Drive High Flow" }, + { "filament_self_index", "1,1,2,2" }, + { "enable_pressure_advance", "1,1,1,1" }, + { "pressure_advance", "0.021,0.037,0.043,0.049" }, + { "adaptive_pressure_advance", adaptive ? "1,1,1,1" : "0,0,0,0" }, + { "sparse_infill_filament_id", filament }, + { "internal_solid_filament_id", filament }, + { "top_surface_filament_id", filament }, + { "bottom_surface_filament_id", filament }, + { "outer_wall_filament_id", filament }, + { "inner_wall_filament_id", filament }, + { "enable_prime_tower", 0 }, + { "skirt_loops", 0 }, + { "brim_type", "no_brim" }, + // custom G-code indexes the per-filament arrays by filament + { "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" }, + }); + config.option("adaptive_pressure_advance_model")->values = { + constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"), + constant_pressure_advance_model("0.056"), constant_pressure_advance_model("0.078") }; + config.option("extruder_type", true)->values = { etDirectDrive, etDirectDrive }; + config.option("nozzle_volume_type", true)->values = { nvtStandard, nvtHighFlow }; + // keep the mapping above rather than grouping the filaments automatically + config.option>("filament_map_mode", true)->value = fmmManual; + const std::string gcode = slice({ cube(20) }, config); + + std::set expected{ pressure_advance }; + if (adaptive) + expected.insert(adaptive_pressure_advance); + CHECK(pressure_advance_values(gcode) == expected); + CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos); + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 6ec6ba9b1e..e71c8c5451 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -5482,3 +5482,39 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape")); } } + +// A project saved before a key joined filament_options_with_variant stores it once per filament, +// while the keys that were already per variant store it once per filament variant. Loading such a +// project gives every variant of a filament that filament's value. +TEST_CASE("A project saved with pressure advance per filament applies it to every variant of the filament", "[Preset][Bundle]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.option("filament_settings_id", true)->values = { "Project PLA", "Project PETG" }; + // A multi-variant printer: full_print_config() leaves the list out, and the loader splits the + // variant keys per filament only when the project carries it. + config.option("extruder_variant_list", true)->values = { "Direct Drive Standard,Direct Drive High Flow" }; + // filament 1 defines Standard and High Flow, filament 2 Standard + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive High Flow", "Direct Drive Standard" }; + config.opt("filament_self_index")->values = { 1, 1, 2 }; + config.opt("filament_flow_ratio")->values = { 0.95, 0.96, 0.97 }; // one per filament variant + // One per filament, read through the loader that project files go through. + config.load_from_ini_string("pressure_advance = 0.021,0.043", ForwardCompatibilitySubstitutionRule::Disable); + // The CLI slices the config as loaded. + check_double_vector(config.opt("pressure_advance")->values, { 0.021, 0.021, 0.043 }); + check_double_vector(config.opt("filament_flow_ratio")->values, { 0.95, 0.96, 0.97 }); + // The GUI normalizes the config before load; mirror that so only the production path runs. + Preset::normalize(config); + + PresetBundle bundle; + bundle.load_config_model("test.3mf", std::move(config)); + + REQUIRE(bundle.filament_presets.size() == 2); + const DynamicPrintConfig &pla = bundle.filaments.find_preset(bundle.filament_presets[0], false, true)->config; + const DynamicPrintConfig &petg = bundle.filaments.find_preset(bundle.filament_presets[1], false, true)->config; + check_double_vector(pla.opt("pressure_advance")->values, { 0.021, 0.021 }); + check_double_vector(petg.opt("pressure_advance")->values, { 0.043 }); + check_double_vector(pla.opt("filament_flow_ratio")->values, { 0.95, 0.96 }); + check_double_vector(petg.opt("filament_flow_ratio")->values, { 0.97 }); +} From 50eea484088ac0bad7584c59c214053931dad0e7 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 29 Sep 2026 17:52:19 +0800 Subject: [PATCH 3/9] Move Nozzle type to each extruder's settings page Editing other settings on an Extruder page of a single-extruder multi-material printer no longer triggers the nozzle diameter prompt. --- src/slic3r/GUI/Tab.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 17ac9555dd..c824c16d5d 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -5206,7 +5206,6 @@ void TabPrinter::build_fff() optgroup->append_single_option_line("adaptive_bed_mesh_margin", "printer_basic_information_adaptive_bed_mesh#mesh-margin"); optgroup = page->new_optgroup(L("Accessory"), "param_accessory"); - optgroup->append_single_option_line("nozzle_type", "printer_basic_information_accessory#nozzle-type", 0); optgroup->append_single_option_line("nozzle_hrc", "printer_basic_information_accessory#nozzle-hrc"); optgroup->append_single_option_line("auxiliary_fan", "printer_basic_information_accessory#auxiliary-part-cooling-fan"); optgroup->append_single_option_line("fan_direction"); @@ -5721,6 +5720,7 @@ if (is_marlin_flavor) auto optgroup = page->new_optgroup(L("Basic information"), L"param_information", -1, true); optgroup->append_single_option_line("nozzle_diameter", "printer_extruder_basic_information#nozzle-diameter", extruder_idx); + optgroup->append_single_option_line("nozzle_type", "printer_basic_information_accessory#nozzle-type", extruder_idx); //optgroup->append_single_option_line("nozzle_volume_type", "", extruder_idx); optgroup->append_single_option_line("nozzle_volume", "printer_extruder_basic_information#nozzle-volume", extruder_idx); @@ -5732,7 +5732,7 @@ if (is_marlin_flavor) optgroup->m_on_change = [this, extruder_idx](const t_config_option_key& opt_key, boost::any value) { bool is_SEMM = m_config->opt_bool("single_extruder_multi_material"); - if (is_SEMM && m_extruders_count > 1 && opt_key.find_first_of("nozzle_diameter") != std::string::npos) + if (is_SEMM && m_extruders_count > 1 && boost::starts_with(opt_key, "nozzle_diameter")) { SuppressBackgroundProcessingUpdate sbpu; const double new_nd = boost::any_cast(value); From 72cfe71b818f5ea645bbad3bcf02ed135fdf5df8 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 29 Sep 2026 06:04:48 -0500 Subject: [PATCH 4/9] fix: link webkit2gtk and X11 on every Linux build, not only Flatpak (#15972) libslic3r_gui calls webkit_* directly, and OrcaSlicer.cpp and libspnav call Xlib, but both libraries were only linked when FLATPAK was set. The default build links because the bundled static wxWidgets lists them in wx-config. A shared wxWidgets does not, so any build against one, like the Flatpak build or a distro package, fails with undefined webkit_* and X* symbols. --- src/slic3r/CMakeLists.txt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 584fc2a2aa..f6a8249d4f 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -1019,14 +1019,9 @@ if (UNIX AND NOT APPLE) find_package(GTK${SLIC3R_GTK} REQUIRED) pkg_check_modules(LIBSECRET REQUIRED libsecret-1) pkg_check_modules(webkit2gtk REQUIRED webkit2gtk-4.1) - if (FLATPAK) - # I don't know why this is needed, but for whatever reason slic3r isn't - # linking to X11 and webkit2gtk. force it. - find_package(X11 REQUIRED) - target_link_libraries(libslic3r_gui ${X11_LIBRARIES} ${webkit2gtk_LIBRARIES}) - endif() + find_package(X11 REQUIRED) target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GTK${SLIC3R_GTK}_INCLUDE_DIRS} ${LIBSECRET_INCLUDE_DIRS} ${webkit2gtk_INCLUDE_DIRS}) - target_link_libraries(libslic3r_gui ${GTK${SLIC3R_GTK}_LIBRARIES} fontconfig ${LIBSECRET_LIBRARIES}) + target_link_libraries(libslic3r_gui ${GTK${SLIC3R_GTK}_LIBRARIES} fontconfig ${LIBSECRET_LIBRARIES} ${webkit2gtk_LIBRARIES} ${X11_LIBRARIES}) # Propagate GDK backend detection results as compile definitions so that # LinuxDisplayBackend.cpp can include the right GDK headers. From 1504bd7153697144e82bd679c68b15b1ac86f9d7 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 29 Sep 2026 21:09:20 +0800 Subject: [PATCH 5/9] Fix command-line slicing when a filament leaves out a per-variant setting --- src/OrcaSlicer.cpp | 30 ++++++++++++++++ src/libslic3r/PrintConfig.cpp | 35 +++++++++++-------- src/libslic3r/PrintConfig.hpp | 5 +++ .../test_config_variant_expansion.cpp | 12 +++++++ 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 33411e2f0d..1939988261 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -3476,6 +3476,14 @@ int CLI::run(int argc, char **argv) } new_variant_counts = old_variant_counts; //filament_variant_count = old_variant_counts; + //ORCA: lay the per-variant options out one value per variant of the current filaments before each + // loaded filament replaces its own variants, including an option only a loaded filament + // defines, which otherwise starts as a single default value and never reaches the others. + for (const DynamicPrintConfig &config : load_filaments_config) + for (const std::string &opt_key : filament_options_with_variant) + if (opt_key != "filament_extruder_variant" && config.has(opt_key)) + m_print_config.option(opt_key, true); + normalize_filament_values_to_variants(m_print_config); for (int index = 0; index < load_filaments_config.size(); index++) { DynamicPrintConfig& config = load_filaments_config[index]; int filament_index = load_filaments_index[index]; @@ -3647,6 +3655,25 @@ int CLI::run(int argc, char **argv) } } + //ORCA: a per-variant option the loaded filament does not define keeps the values of the + // variants the filament already had, and a variant new to it takes its first one's. + const int old_start = old_start_indice[filament_index - 1]; + std::vector kept_variant_indice = new_variant_indice; + for (int &i : kept_variant_indice) + if (i < 0) + i = old_start; + for (const std::string &opt_key : filament_options_with_variant) { + if (config.has(opt_key)) + continue; + auto *opt_vec_dst = dynamic_cast(m_print_config.option(opt_key)); + if (opt_vec_dst == nullptr || opt_vec_dst->size() < size_t(old_start + old_variant_count)) + continue; + // set_with_restore_2() pads its source in place + std::unique_ptr old_values(opt_vec_dst->clone()); + opt_vec_dst->set_with_restore_2(static_cast(old_values.get()), kept_variant_indice, old_start, + old_variant_count, true); + } + //update the old index if (old_variant_count != new_variant_count) { @@ -4081,6 +4108,9 @@ int CLI::run(int argc, char **argv) if (printer_technology == ptFFF) { fff_print_config.apply(m_print_config, true); m_print_config.apply(fff_print_config, true); + //ORCA: an option no preset or project defines has just come in as its single default value, and a + // command line override may hold one value per filament. + normalize_filament_values_to_variants(m_print_config); } else { boost::nowide::cerr << "invalid printer_technology " << std::endl; record_exit_reson(outfile_dir, CLI_INVALID_PRINTER_TECH, 0, cli_errors[CLI_INVALID_PRINTER_TECH], sliced_info); diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 1202477b6a..16a913a85f 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -9405,20 +9405,8 @@ void PrintConfigDef::handle_legacy_composite(DynamicPrintConfig &config) } // Orca: a config saved before a key joined filament_options_with_variant stores it once per filament - // rather than once per filament variant. Give every variant of a filament that filament's value. - if (auto *self_index = config.option("filament_self_index"); self_index && !self_index->empty()) { - const int filament_count = *std::max_element(self_index->values.begin(), self_index->values.end()); - if (filament_count > 0 && size_t(filament_count) < self_index->size()) { - for (const std::string &key : filament_options_with_variant) { - auto *opt = dynamic_cast(config.option(key)); - if (!opt || opt->size() != size_t(filament_count)) - continue; - std::unique_ptr per_filament(opt->clone()); - for (size_t variant = 0; variant < self_index->size(); ++variant) - opt->set_at(per_filament.get(), variant, self_index->values[variant] - 1); - } - } - } + // rather than once per filament variant, and one exported by an older CLI may store a single value. + normalize_filament_values_to_variants(config); } const PrintConfigDef print_config_def; @@ -10735,6 +10723,25 @@ void set_variant_override(ConfigOptionVectorBase &target, const ConfigOptionVect target.set_to_index(&source, indices, stride); } +void normalize_filament_values_to_variants(DynamicPrintConfig &config) +{ + const auto *self_index = config.option("filament_self_index"); + if (self_index == nullptr || self_index->empty()) + return; + const int filament_count = *std::max_element(self_index->values.begin(), self_index->values.end()); + if (filament_count <= 0 || size_t(filament_count) >= self_index->size()) + return; + for (const std::string &key : filament_options_with_variant) { + auto *opt = dynamic_cast(config.option(key)); + if (opt == nullptr || (opt->size() != size_t(filament_count) && opt->size() != 1)) + continue; + std::unique_ptr per_filament(opt->clone()); + // set_at() takes the first value for a filament past the end of a single-value vector + for (size_t variant = 0; variant < self_index->size(); ++variant) + opt->set_at(per_filament.get(), variant, self_index->values[variant] - 1); + } +} + //used for object/region config //use the smallest of multiple to single diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 8b61280979..1e91f2e451 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -884,6 +884,11 @@ extern std::set empty_options; void set_variant_override(ConfigOptionVectorBase &target, const ConfigOptionVectorBase &source, const std::vector &variant_index, int stride = 1); +// Orca: lays every filament_options_with_variant option out one value per filament variant, as +// filament_self_index maps the variants to filaments. An option holding one value per filament, or a +// single value, gives every variant of a filament that filament's value; other lengths are left alone. +void normalize_filament_values_to_variants(DynamicPrintConfig &config); + extern std::set filament_dev_options; extern void update_static_print_config_from_dynamic(ConfigBase& config, const DynamicPrintConfig& dest_config, std::vector variant_index, std::set& key_set1, int stride = 1); diff --git a/tests/libslic3r/test_config_variant_expansion.cpp b/tests/libslic3r/test_config_variant_expansion.cpp index e603808b54..de19e8219c 100644 --- a/tests/libslic3r/test_config_variant_expansion.cpp +++ b/tests/libslic3r/test_config_variant_expansion.cpp @@ -717,3 +717,15 @@ TEST_CASE("get_index_for_extruder scales the variant column by the requested str REQUIRE(col0_stride2 == 0); REQUIRE(col1_stride2 == 2); } + +// A per-variant filament option read with a single value gives it to every filament variant. A project +// exported by an older CLI holds a single value for an option no loaded preset defined, such as +// filament_ironing_flow. +TEST_CASE("A per-variant filament option read with a single value gives it to every filament variant", "[Config]") +{ + // filament 1 defines Standard and High Flow, filament 2 Standard + DynamicPrintConfig config; + config.option("filament_self_index", true)->values = {1, 1, 2}; + config.load_from_ini_string("pressure_advance = 0.021", ForwardCompatibilitySubstitutionRule::Disable); + REQUIRE(config.option("pressure_advance")->values == std::vector({0.021, 0.021, 0.021})); +} From e68694dbaf79ac3b8f534b923c95bd1b4d8f2d5f Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:30:56 +0800 Subject: [PATCH 6/9] Percent-Encode Local File URLs for Embedded Web Pages (#15961) * Percent-Encode Local File URLs for Embedded Web Pages The Home tab, setup wizard, Project tab and other embedded pages were loaded from file:// URLs built by pasting the resources path into a string. A '#', '%' or '?' in the install path was then read as a URL fragment, escape or query, so the pages failed to load, for example a portable install under D:\#OneDrive showed a directory listing instead of the setup wizard. Add file_url_from_path(), built on wxFileSystem::FileNameToURL, and use it wherever a local page or image URL is built from a path. Queries such as ?lang= are appended after the path is encoded. The wizard's printer cover images are passed to the page as file URLs too. * Encode the Login Error Page URL and Cover More Windows Path Forms The login dialog's error page was still loaded from a raw resources path; it now uses file_url_from_path like the other local pages. The Windows file URL tests now also cover a resources path joined with a forward-slash relative path, as the callers build them, and a UNC path. * Build the Flush Dialog Page URLs with the Shared Helper WipingDialog and NozzleListTable still called wxFileSystem::FileNameToURL directly. They now go through file_url_from_path like every other local page, so the URLs are built in one place. Adds a test for a resources directory with a '#' in its name, which the plugin page check did not recognise before. --- src/slic3r/GUI/GUI.cpp | 8 +++ src/slic3r/GUI/GUI.hpp | 3 + src/slic3r/GUI/MarkdownTip.cpp | 5 +- src/slic3r/GUI/Plater.cpp | 2 +- src/slic3r/GUI/PrivacyUpdateDialog.cpp | 4 +- src/slic3r/GUI/Project.cpp | 4 +- src/slic3r/GUI/ReleaseNote.cpp | 2 +- src/slic3r/GUI/WebDownPluginDlg.cpp | 4 +- src/slic3r/GUI/WebGuideDialog.cpp | 24 +++---- src/slic3r/GUI/WebUserLoginDialog.cpp | 2 +- src/slic3r/GUI/WebViewDialog.cpp | 4 +- src/slic3r/GUI/Widgets/MultiNozzleSync.cpp | 6 +- src/slic3r/GUI/Widgets/WebHosting.cpp | 11 ++- src/slic3r/GUI/Widgets/WebViewHostDialog.cpp | 4 +- src/slic3r/GUI/WipeTowerDialog.cpp | 2 +- src/slic3r/Utils/ElegooLink.cpp | 4 +- tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/test_file_url.cpp | 74 ++++++++++++++++++++ tests/slic3rutils/test_plugin_host_api.cpp | 24 +++++-- 19 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 tests/slic3rutils/test_file_url.cpp diff --git a/src/slic3r/GUI/GUI.cpp b/src/slic3r/GUI/GUI.cpp index 78a511c90c..5301b91d86 100644 --- a/src/slic3r/GUI/GUI.cpp +++ b/src/slic3r/GUI/GUI.cpp @@ -14,6 +14,9 @@ #include #include +#include +#include + #if __APPLE__ #import #elif _WIN32 @@ -531,6 +534,11 @@ boost::filesystem::path into_path(const wxString &str) return boost::filesystem::path(str.wx_str()); } +wxString file_url_from_path(const boost::filesystem::path &path) +{ + return wxFileSystem::FileNameToURL(wxFileName(from_path(path))); +} + void about() { AboutDialog dlg; diff --git a/src/slic3r/GUI/GUI.hpp b/src/slic3r/GUI/GUI.hpp index db882b79cf..0b3ac6d7b5 100644 --- a/src/slic3r/GUI/GUI.hpp +++ b/src/slic3r/GUI/GUI.hpp @@ -76,6 +76,9 @@ std::string into_u8(const wxString &str); wxString from_path(const boost::filesystem::path &path); // boost path from wxString boost::filesystem::path into_path(const wxString &str); +// file:// URL of a local path, percent-encoded so characters such as '#', '%' and '?' stay part of the path. +// Append any query or fragment to the result. +wxString file_url_from_path(const boost::filesystem::path &path); // Display an About dialog extern void about(); diff --git a/src/slic3r/GUI/MarkdownTip.cpp b/src/slic3r/GUI/MarkdownTip.cpp index 7ffeae9e36..a22f4da87f 100644 --- a/src/slic3r/GUI/MarkdownTip.cpp +++ b/src/slic3r/GUI/MarkdownTip.cpp @@ -98,10 +98,7 @@ void MarkdownTip::LoadStyle() ph /= "tooltip/styled.html"; _data_dir = false; } - auto url = ph.string(); - std::replace(url.begin(), url.end(), '\\', '/'); - url = "file:///" + url; - _tipView->LoadURL(from_u8(url)); + _tipView->LoadURL(file_url_from_path(ph)); _lastTip.clear(); } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c95d94aba6..c479d1dba9 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3672,7 +3672,7 @@ void Sidebar::update_all_preset_comboboxes() wxString url = from_u8(PrintHost::get_print_host_webui(&cfg)); wxString apikey; if(url.empty()) - url = wxString::Format("file://%s/web/orca/missing_connection.html", from_u8(resources_dir())); + url = file_url_from_path(boost::filesystem::path(resources_dir()) / "web/orca/missing_connection.html"); else { const auto host_type = cfg.option>("host_type")->value; if (cfg.has("printhost_apikey") && (host_type != htSimplyPrint)) diff --git a/src/slic3r/GUI/PrivacyUpdateDialog.cpp b/src/slic3r/GUI/PrivacyUpdateDialog.cpp index c417767a42..c396e85bfe 100644 --- a/src/slic3r/GUI/PrivacyUpdateDialog.cpp +++ b/src/slic3r/GUI/PrivacyUpdateDialog.cpp @@ -57,9 +57,7 @@ PrivacyUpdateDialog::PrivacyUpdateDialog(wxWindow* parent, wxWindowID id, const fs::path ph(resources_dir()); ph /= "tooltip/privacyupdate.html"; - m_host_url = ph.string(); - std::replace(m_host_url.begin(), m_host_url.end(), '\\', '/'); - m_host_url = "file:///" + m_host_url; + m_host_url = into_u8(file_url_from_path(ph)); m_vebview_release_note->LoadURL(from_u8(m_host_url)); m_sizer_right->Add(m_vebview_release_note, 0, wxEXPAND | wxRIGHT | wxLEFT, FromDIP(15)); diff --git a/src/slic3r/GUI/Project.cpp b/src/slic3r/GUI/Project.cpp index 8d87939afc..7833cc7499 100644 --- a/src/slic3r/GUI/Project.cpp +++ b/src/slic3r/GUI/Project.cpp @@ -45,10 +45,10 @@ const std::vector license_list = { ProjectPanel::ProjectPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style) : wxPanel(parent, id, pos, size, style) { SetBackgroundColour(*wxWHITE); - m_project_home_url = wxString::Format("file://%s/web/model/index.html", from_u8(resources_dir())); + m_project_home_url = file_url_from_path(boost::filesystem::path(resources_dir()) / "web/model/index.html"); wxString strlang = wxGetApp().current_language_code_safe(); if (strlang != "") - m_project_home_url = wxString::Format("file://%s/web/model/index.html?lang=%s", from_u8(resources_dir()), strlang); + m_project_home_url += "?lang=" + strlang; wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); diff --git a/src/slic3r/GUI/ReleaseNote.cpp b/src/slic3r/GUI/ReleaseNote.cpp index c8338dc79a..58b3a40ca9 100644 --- a/src/slic3r/GUI/ReleaseNote.cpp +++ b/src/slic3r/GUI/ReleaseNote.cpp @@ -510,7 +510,7 @@ void UpdateVersionDialog::update_version_info(wxString release_note, wxString ve out_buf->append(text, size); }, (void*) &html_source, MD_DIALECT_GITHUB | MD_FLAG_STRIKETHROUGH | MD_FLAG_WIKILINKS, 0); html_source.append(""); - m_vebview_release_note->LoadURL("file://" + (boost::filesystem::path (resources_dir()) / "web/guide/0/index.html").string()); + m_vebview_release_note->LoadURL(file_url_from_path(boost::filesystem::path(resources_dir()) / "web/guide/0/index.html")); SetMinSize(GetSize()); SetMaxSize(GetSize()); diff --git a/src/slic3r/GUI/WebDownPluginDlg.cpp b/src/slic3r/GUI/WebDownPluginDlg.cpp index dca17110f9..6358582831 100644 --- a/src/slic3r/GUI/WebDownPluginDlg.cpp +++ b/src/slic3r/GUI/WebDownPluginDlg.cpp @@ -38,9 +38,7 @@ DownPluginFrame::DownPluginFrame(GUI_App *pGUI) : wxDialog((wxWindow *) (pGUI->m // set the frame icon wxBoxSizer *topsizer = new wxBoxSizer(wxVERTICAL); - wxString TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/6/index.html").make_preferred().string()); - - TargetUrl = "file://" + TargetUrl; + wxString TargetUrl = file_url_from_path(boost::filesystem::path(resources_dir()) / "web/guide/6/index.html"); // Create the webview m_browser = WebView::CreateWebView(this, TargetUrl); diff --git a/src/slic3r/GUI/WebGuideDialog.cpp b/src/slic3r/GUI/WebGuideDialog.cpp index 9cea8bec28..e9e74b798e 100644 --- a/src/slic3r/GUI/WebGuideDialog.cpp +++ b/src/slic3r/GUI/WebGuideDialog.cpp @@ -218,37 +218,38 @@ wxString GuideFrame::SetStartPage(GuidePage startpage, bool load) m_page = startpage; BOOST_LOG_TRIVIAL(info) << __FUNCTION__<< boost::format(" enter, load=%1%, start_page=%2%")%load%int(startpage); //wxLogMessage("GUIDE: webpage_1 %s", (boost::filesystem::path(resources_dir()) / "web\\guide\\1\\index.html").make_preferred().string().c_str() ); - wxString TargetUrl = from_u8( (boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=1").make_preferred().string() ); + const wxString guide_url = file_url_from_path(boost::filesystem::path(resources_dir()) / "web/guide/0/index.html"); + wxString TargetUrl = guide_url + "?target=1"; //wxLogMessage("GUIDE: webpage_2 %s", TargetUrl.mb_str()); if (startpage == BBL_WELCOME){ SetTitle(_L("Setup Wizard")); - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=1").make_preferred().string()); + TargetUrl = guide_url + "?target=1"; } else if (startpage == BBL_REGION) { SetTitle(_L("Setup Wizard")); - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=11").make_preferred().string()); + TargetUrl = guide_url + "?target=11"; } else if (startpage == BBL_MODELS) { SetTitle(_L("Setup Wizard")); - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=21").make_preferred().string()); + TargetUrl = guide_url + "?target=21"; } else if (startpage == BBL_FILAMENTS) { SetTitle(_L("Setup Wizard")); int nSize = m_ProfileJson["model"].size(); if (nSize>0) - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=22").make_preferred().string()); + TargetUrl = guide_url + "?target=22"; else - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=21").make_preferred().string()); + TargetUrl = guide_url + "?target=21"; } else if (startpage == BBL_FILAMENT_ONLY) { SetTitle(""); - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=23").make_preferred().string()); + TargetUrl = guide_url + "?target=23"; } else if (startpage == BBL_MODELS_ONLY) { SetTitle(""); - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=24").make_preferred().string()); + TargetUrl = guide_url + "?target=24"; } else { SetTitle(_L("Setup Wizard")); - TargetUrl = from_u8((boost::filesystem::path(resources_dir()) / "web/guide/0/index.html?target=21").make_preferred().string()); + TargetUrl = guide_url + "?target=21"; } wxString strlang = wxGetApp().current_language_code_safe(); @@ -256,7 +257,6 @@ wxString GuideFrame::SetStartPage(GuidePage startpage, bool load) if (strlang != "") TargetUrl = wxString::Format("%s&lang=%s", w2s(TargetUrl), strlang); - TargetUrl = "file://" + TargetUrl; if (load) load_url(TargetUrl); @@ -1284,7 +1284,7 @@ bool GuideFrame::BuildProfileJson(const PresetBundle& bundle, bool require_all_r entry["vendor"] = vp.id; entry["nozzle_diameter"] = nozzle_str; entry["materials"] = materials_str; - entry["cover"] = cover_path.string(); + entry["cover"] = into_u8(file_url_from_path(cover_path)); entry["nozzle_selected"] = ""; entry["sub_path"] = ""; m_ProfileJson["model"].push_back(entry); @@ -1732,7 +1732,7 @@ int GuideFrame::LoadProfileFamily(std::string strVendor, std::string strFilePath cover_path = (boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/web/image/printer/") / cover_file) .make_preferred(); } - OneModel["cover"] = cover_path.string(); + OneModel["cover"] = into_u8(file_url_from_path(cover_path)); OneModel["nozzle_selected"] = ""; diff --git a/src/slic3r/GUI/WebUserLoginDialog.cpp b/src/slic3r/GUI/WebUserLoginDialog.cpp index 829e7ef106..72860e3bdc 100644 --- a/src/slic3r/GUI/WebUserLoginDialog.cpp +++ b/src/slic3r/GUI/WebUserLoginDialog.cpp @@ -599,7 +599,7 @@ void ZUserLogin::OnScriptResponseMessage(wxCommandEvent &WXUNUSED(evt)) bool ZUserLogin::ShowErrorPage() { - wxString ErrortUrl = from_u8((boost::filesystem::path(resources_dir()) / "web\\login\\error.html").make_preferred().string()); + wxString ErrortUrl = file_url_from_path(boost::filesystem::path(resources_dir()) / "web/login/error.html"); load_url(ErrortUrl); return true; diff --git a/src/slic3r/GUI/WebViewDialog.cpp b/src/slic3r/GUI/WebViewDialog.cpp index f20d24163c..a98b196eb1 100644 --- a/src/slic3r/GUI/WebViewDialog.cpp +++ b/src/slic3r/GUI/WebViewDialog.cpp @@ -36,10 +36,10 @@ namespace GUI { WebViewPanel::WebViewPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) { - wxString url = wxString::Format("file://%s/web/homepage/index.html", from_u8(resources_dir())); + wxString url = file_url_from_path(boost::filesystem::path(resources_dir()) / "web/homepage/index.html"); wxString strlang = wxGetApp().current_language_code_safe(); if (strlang != "") - url = wxString::Format("file://%s/web/homepage/index.html?lang=%s", from_u8(resources_dir()), strlang); + url += "?lang=" + strlang; wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL); diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp index 42659431f0..616b8b8779 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp @@ -1,5 +1,6 @@ #include "MultiNozzleSync.hpp" +#include "../GUI.hpp" #include "../GUI_App.hpp" #include "../I18N.hpp" #include "../Plater.hpp" @@ -21,8 +22,6 @@ #include #include -#include -#include #include #include @@ -641,8 +640,7 @@ NozzleListTable::NozzleListTable(wxWindow* parent) : wxPanel(parent,wxID_ANY,wxD m_web_view->AddScriptMessageHandler("nozzleListTable"); m_web_view->EnableContextMenu(false); fs::path filepath = fs::path(resources_dir()) / "web/flush/NozzleListTable.html"; - wxFileName fn(wxString::FromUTF8(filepath.string())); - wxString url = wxFileSystem::FileNameToURL(fn); + wxString url = file_url_from_path(filepath); m_web_view->LoadURL(url); auto sizer = new wxBoxSizer(wxVERTICAL); diff --git a/src/slic3r/GUI/Widgets/WebHosting.cpp b/src/slic3r/GUI/Widgets/WebHosting.cpp index a1b0df496a..b6af6d3a39 100644 --- a/src/slic3r/GUI/Widgets/WebHosting.cpp +++ b/src/slic3r/GUI/Widgets/WebHosting.cpp @@ -48,20 +48,19 @@ constexpr char ORCA_BRIDGE_JS[] = R"JS( wxString bootstrap_url() { - return wxString("file://") + from_u8((boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE).make_preferred().string()); + return file_url_from_path(boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE); } wxString content_base_url() { - const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string(); - return wxString("file://") + from_u8(dir) + "/"; + return file_url_from_path(boost::filesystem::path(resources_dir()) / "web") + "/"; } bool is_content_url(const wxString& url) { - // The web view reports the URL it parsed, which escapes anything the resources path holds - // (a space, a non-ASCII character), while content_base_url() is the raw path. - return wxURI::Unescape(url.BeforeFirst('#')) == content_base_url(); + // The web view reports the URL it parsed, which may escape the resources path differently + // from content_base_url(). + return wxURI::Unescape(url.BeforeFirst('#')) == wxURI::Unescape(content_base_url()); } const char* orca_bridge_script() { return ORCA_BRIDGE_JS; } diff --git a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp index e4e3d2878a..3c14ab37e1 100644 --- a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp +++ b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp @@ -192,7 +192,7 @@ bool WebViewHostDialog::create_webview(const std::string& resource_path, wxString WebViewHostDialog::build_resource_url(const std::string& resource_path) const { - wxString target_url = from_u8((boost::filesystem::path(resources_dir()) / resource_path).make_preferred().string()); + wxString target_url = file_url_from_path(boost::filesystem::path(resources_dir()) / resource_path); if (append_language_to_url()) { const wxString lang = wxGetApp().current_language_code_safe(); @@ -202,7 +202,7 @@ wxString WebViewHostDialog::build_resource_url(const std::string& resource_path) } } - return wxString("file://") + target_url; + return target_url; } void WebViewHostDialog::load_url(const wxString& url) diff --git a/src/slic3r/GUI/WipeTowerDialog.cpp b/src/slic3r/GUI/WipeTowerDialog.cpp index 9a473c7c8a..126f090a0b 100644 --- a/src/slic3r/GUI/WipeTowerDialog.cpp +++ b/src/slic3r/GUI/WipeTowerDialog.cpp @@ -464,7 +464,7 @@ WipingDialog::WipingDialog(wxWindow* parent, const int max_flush_volume) : wxString filepath_str = from_path(filepath); wxFileName fn(filepath_str); if(fn.FileExists()) { - wxString url = wxFileSystem::FileNameToURL(fn); + wxString url = file_url_from_path(filepath); BOOST_LOG_TRIVIAL(debug) << __FUNCTION__<< "File exists and load url " << url.ToStdString(); m_webview->LoadURL(url); BOOST_LOG_TRIVIAL(debug) << __FUNCTION__<< "Successfully loaded url: " << url.ToStdString(); diff --git a/src/slic3r/Utils/ElegooLink.cpp b/src/slic3r/Utils/ElegooLink.cpp index 03b7bdc6c7..64ff4a9455 100644 --- a/src/slic3r/Utils/ElegooLink.cpp +++ b/src/slic3r/Utils/ElegooLink.cpp @@ -340,9 +340,7 @@ namespace Slic3r { if (classify_printer_model(config->opt_string("printer_model")) != ElegooPrinterType::CC2) return fallback_webui; - std::string web_path = resources_dir() + "/web/elegoolink/lan_service_web/index.html"; - std::replace(web_path.begin(), web_path.end(), '\\', '/'); - web_path = "file://" + web_path; + std::string web_path = GUI::into_u8(GUI::file_url_from_path(boost::filesystem::path(resources_dir()) / "web/elegoolink/lan_service_web/index.html")); const std::string token = get_cc2_token(config->opt_string("printhost_apikey")); const std::string host_ip = Http::get_host_header_value(host); diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index be796e814b..94ebaed35e 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -27,6 +27,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_cloud_metadata.cpp test_plugin_audit.cpp test_shortcuts.cpp + test_file_url.cpp ../fff_print/test_helpers.cpp ) diff --git a/tests/slic3rutils/test_file_url.cpp b/tests/slic3rutils/test_file_url.cpp new file mode 100644 index 0000000000..484067b57e --- /dev/null +++ b/tests/slic3rutils/test_file_url.cpp @@ -0,0 +1,74 @@ +#include +#include + +#include "slic3r/GUI/GUI.hpp" + +#include + +#include + +using namespace Slic3r::GUI; + +#ifndef _WIN32 +TEST_CASE("A file URL keeps characters special to URLs in its path", "[FileUrl]") +{ + const std::string path = GENERATE(as{}, + "/opt/test#dir/resources/web/homepage/index.html", + "/opt/test%20x/resources/web/homepage/index.html", + "/opt/Orca Slicer/resources/web/homepage/index.html", + "/opt/what?/resources/web/homepage/index.html", + "/home/Jos\xC3\xA9/\xE8\xB5\x84\xE6\xBA\x90/resources/web/homepage/index.html"); + + const wxString url = file_url_from_path(boost::filesystem::path(path)); + CAPTURE(path, url.utf8_string()); + + // WebView::CreateWebView() and WebView::LoadUrl() re-parse the URL before loading it. + const wxURI uri(wxURI(url).BuildURI()); + CHECK(uri.GetScheme() == "file"); + CHECK(!uri.HasQuery()); + CHECK(!uri.HasFragment()); + CHECK(wxURI::Unescape(uri.GetPath()).utf8_string() == path); +} + +TEST_CASE("A file URL of a plain path is the path behind file://", "[FileUrl]") +{ + const std::string path = "/opt/OrcaSlicer/resources/web/homepage/index.html"; + CHECK(file_url_from_path(boost::filesystem::path(path)) == "file://" + path); +} + +TEST_CASE("A query appended to a file URL stays separate from its path", "[FileUrl]") +{ + const std::string path = "/opt/test#dir%20x/resources/web/guide/0/index.html"; + const wxURI uri(file_url_from_path(boost::filesystem::path(path)) + "?target=21&lang=de"); + CHECK(wxURI::Unescape(uri.GetPath()).utf8_string() == path); + CHECK(uri.GetQuery() == "target=21&lang=de"); + CHECK(!uri.HasFragment()); +} +#else +TEST_CASE("A file URL of a Windows path has a drive letter and forward slashes", "[FileUrl]") +{ + const auto [path, url] = GENERATE(table({ + { L"C:\\Program Files\\OrcaSlicer\\resources\\web\\homepage\\index.html", + "file:///C:/Program%20Files/OrcaSlicer/resources/web/homepage/index.html" }, + { L"D:\\#OneDrive\\OrcaSlicer\\resources\\web\\guide\\0\\index.html", + "file:///D:/%23OneDrive/OrcaSlicer/resources/web/guide/0/index.html" }, + { L"D:\\100%\\OrcaSlicer\\resources\\web\\homepage\\index.html", + "file:///D:/100%25/OrcaSlicer/resources/web/homepage/index.html" }, + // Callers join the resources directory with a forward-slash relative path. + { L"D:\\#OneDrive\\OrcaSlicer\\resources/web/homepage/index.html", + "file:///D:/%23OneDrive/OrcaSlicer/resources/web/homepage/index.html" }, + { L"\\\\server\\share\\OrcaSlicer\\resources\\web\\homepage\\index.html", + "file://server/share/OrcaSlicer/resources/web/homepage/index.html" }, + })); + CHECK(file_url_from_path(boost::filesystem::path(path)).utf8_string() == url); +} + +TEST_CASE("A query appended to a Windows file URL stays separate from its path", "[FileUrl]") +{ + const wxURI uri(file_url_from_path(boost::filesystem::path(L"D:\\#OneDrive\\OrcaSlicer\\resources\\web\\guide\\0\\index.html")) + + "?target=21&lang=de"); + CHECK(wxURI::Unescape(uri.GetPath()) == "/D:/#OneDrive/OrcaSlicer/resources/web/guide/0/index.html"); + CHECK(uri.GetQuery() == "target=21&lang=de"); + CHECK(!uri.HasFragment()); +} +#endif diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index e02864f703..0283bb5744 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -16,6 +16,8 @@ #include +#include + namespace py = pybind11; namespace { @@ -210,23 +212,33 @@ TEST_CASE("A reloaded plugin page is recognised by its base URL, fragment aside" { using namespace Slic3r::GUI::web_hosting; - // A resources path holding a space, which the web view reports escaped. + // A resources path holding a space, which the web view may report escaped differently. const Slic3r::ScopedResourcesDir resources("web content check"); // The swapped-in page, then after an in-page anchor and a reload. CHECK(is_content_url(content_base_url())); CHECK(is_content_url(content_base_url() + "#tab2")); - wxString escaped = content_base_url(); - escaped.Replace(" ", "%20"); - REQUIRE(escaped != content_base_url()); - CHECK(is_content_url(escaped)); - CHECK(is_content_url(escaped + "#tab2")); + const wxString unescaped = wxURI::Unescape(content_base_url()); + REQUIRE(unescaped != content_base_url()); + CHECK(is_content_url(unescaped)); + CHECK(is_content_url(unescaped + "#tab2")); // A page the plugin linked to keeps its own URL and must be left alone. CHECK_FALSE(is_content_url(content_base_url() + "guide.html")); CHECK_FALSE(is_content_url("https://example.com/")); CHECK_FALSE(is_content_url("")); } +TEST_CASE("A reloaded plugin page is recognised when the resources path holds a '#'", "[PluginHost]") +{ + using namespace Slic3r::GUI::web_hosting; + + const Slic3r::ScopedResourcesDir resources("web#content check"); + + CHECK(is_content_url(content_base_url())); + CHECK(is_content_url(content_base_url() + "#tab2")); + CHECK_FALSE(is_content_url(content_base_url() + "guide.html")); +} + TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]") { using Catch::Matchers::WithinAbs; From ba468c842dbc9ca9ad22a3d5a50d5e8e11b71b56 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:40:12 +0800 Subject: [PATCH 7/9] Confine Updater and Plugin Archive Extraction to the Target Directory (#15957) * Confine Updater Archive Extraction to the Target Directory The preset updater extracted downloaded archives by appending each entry name to the cache directory, and the network plugin installer did the same for the plugin folder, without checking that the result stays inside it. Move the updater's extraction into libslic3r as extract_archive_confined, which validates every entry with is_path_within_root before writing anything and fails the whole archive if one entry resolves outside the target. The plugin installer now rejects such an entry the same way. Well formed archives extract exactly as before. * Harden Archive Extraction Against Symlinks The plugin installer now creates a symlink entry only when its target is relative and, joined to the link's own directory, passes is_path_within_root, via the new is_symlink_target_within_root helper. Before writing any entry it checks the destination with symlink_status, so an existing symlink, dangling or not, is replaced rather than followed, and it creates parent directories inside the existing error handling. extract_archive_confined replaces a symlink at a destination file the same way. is_path_within_root now ignores a trailing separator on the root, which previously made every path fail the check. * Validate Plugin Symlink Targets Before Replacing Existing Files A symlink entry's target is now read and checked before anything already at its destination is removed or renamed aside, so an archive rejected for its link target leaves the installed plugin files in place. * Reject Paths with an Embedded NUL When Confining Extraction is_path_within_root compared each component with "..", so a name such as "..\0" passed the check. The filesystem calls stop at the NUL and act on a shorter path than the one that was checked: a symlink target read from a plugin archive as raw bytes was created as "..", pointing out of the plugin directory. A path containing a NUL is now rejected before anything touches the filesystem, which covers every caller, including entry names taken from the Unicode Path extra field. --- src/libslic3r/Utils.hpp | 5 +- src/libslic3r/miniz_extension.cpp | 63 ++++++++ src/libslic3r/miniz_extension.hpp | 2 + src/libslic3r/utils.cpp | 17 +- src/slic3r/GUI/GUI_App.cpp | 31 +++- src/slic3r/Utils/PresetUpdater.cpp | 58 +------ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_miniz_extension.cpp | 194 +++++++++++++++++++++++ tests/libslic3r/test_utils.cpp | 79 +++++++++ 9 files changed, 387 insertions(+), 63 deletions(-) create mode 100644 tests/libslic3r/test_miniz_extension.cpp diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index ff14e563fb..eff7f51c2a 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -256,10 +256,13 @@ extern bool is_gallery_file(const std::string& path, char const* type); extern bool is_shapes_dir(const std::string& dir); //BBS: add json support extern bool is_json_file(const std::string& path); -// True if rel_path is relative, has no ".." component and, joined to root, still resolves inside it. +// True if rel_path is relative, has no ".." component or embedded NUL and, joined to root, still resolves inside it. // Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS // is rejected on all of them. extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root); +// True if a symlink stored at link_rel_path (relative to root) with this target stays inside root: the target +// must be relative, and joined to the link's directory it must pass is_path_within_root. +extern bool is_symlink_target_within_root(const std::string &link_rel_path, const std::string &target, const boost::filesystem::path &root); // Orca: custom protocal support utils inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } diff --git a/src/libslic3r/miniz_extension.cpp b/src/libslic3r/miniz_extension.cpp index 53a4a8e9de..4d9b528267 100644 --- a/src/libslic3r/miniz_extension.cpp +++ b/src/libslic3r/miniz_extension.cpp @@ -4,6 +4,9 @@ #include "miniz_extension.hpp" #include "Utils.hpp" +#include +#include + #if defined(_MSC_VER) || defined(__MINGW64__) #include "boost/nowide/cstdio.hpp" #endif @@ -115,6 +118,66 @@ std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_ return decode_zip_unicode_path_extra_field(extra.substr(0, extra_size > 0 ? extra_size - 1 : 0), stat.m_filename); } +bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir) +{ + mz_zip_archive archive; + mz_zip_zero_struct(&archive); + + if (!open_zip_reader(&archive, zip_path_utf8)) { + BOOST_LOG_TRIVIAL(error) << "Unable to open zip reader for " << zip_path_utf8; + return false; + } + + const mz_uint num_entries = mz_zip_reader_get_num_files(&archive); + mz_zip_archive_file_stat stat; + + // Validate every entry first so an archive with a single escaping entry leaves no partial output behind. + const boost::filesystem::path root(dest_dir); + for (mz_uint i = 0; i < num_entries; ++i) { + if (mz_zip_reader_file_stat(&archive, i, &stat) && !is_path_within_root(stat.m_filename, root)) { + BOOST_LOG_TRIVIAL(error) << "Unzip: rejecting " << zip_path_utf8 << ", entry " << stat.m_filename << " resolves outside " << dest_dir; + close_zip_reader(&archive); + return false; + } + } + + for (mz_uint i = 0; i < num_entries; ++i) { + if (!mz_zip_reader_file_stat(&archive, i, &stat)) { + BOOST_LOG_TRIVIAL(warning) << "Unzip: read file stat failed"; + continue; + } + const std::string dest_file = dest_dir + "/" + stat.m_filename; + try { + if (stat.m_is_directory) { + const boost::filesystem::path dest_path(dest_file); + if (!boost::filesystem::exists(dest_path)) + boost::filesystem::create_directories(dest_path); + continue; + } + if (stat.m_uncomp_size == 0) { + BOOST_LOG_TRIVIAL(warning) << "Unzip: invalid size for file " << stat.m_filename; + continue; + } + // Replace a symlink at the destination rather than writing through it. + const boost::filesystem::path dest_path(dest_file); + if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(dest_path))) + boost::filesystem::remove(dest_path); + if (!mz_zip_reader_extract_to_file(&archive, stat.m_file_index, dest_file.c_str(), 0)) { + BOOST_LOG_TRIVIAL(error) << "Unzip: extract file " << stat.m_filename << " to dest " << dest_file << " failed"; + close_zip_reader(&archive); + return false; + } + BOOST_LOG_TRIVIAL(info) << "Unzip: successfully extract file " << stat.m_file_index << " to " << dest_file; + } catch (const std::exception &e) { + close_zip_reader(&archive); + BOOST_LOG_TRIVIAL(error) << "Unzip: archive read exception: " << e.what(); + return false; + } + } + close_zip_reader(&archive); + return true; +} + MZ_Archive::MZ_Archive() { mz_zip_zero_struct(&arch); diff --git a/src/libslic3r/miniz_extension.hpp b/src/libslic3r/miniz_extension.hpp index 1a1c96689f..97aa91b93f 100644 --- a/src/libslic3r/miniz_extension.hpp +++ b/src/libslic3r/miniz_extension.hpp @@ -11,6 +11,8 @@ bool open_zip_writer(mz_zip_archive *zip, const std::string &fname_utf8); bool close_zip_reader(mz_zip_archive *zip); bool close_zip_writer(mz_zip_archive *zip); std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat); +// Extracts every entry of the archive under dest_dir. Nothing is written if any entry would resolve outside dest_dir. +bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir); class MZ_Archive { public: diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 44fad7dc25..303476f023 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -1093,6 +1093,9 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p auto is_separator = [](char c) { return c == '/' || c == '\\'; }; if (rel_path.empty() || is_separator(rel_path.front()) || (rel_path.size() > 1 && rel_path[1] == ':')) return false; + // The filesystem calls stop at a NUL, so they would act on a shorter path than the one checked here. + if (rel_path.find('\0') != std::string::npos) + return false; for (size_t start = 0; start <= rel_path.size();) { size_t end = start; while (end < rel_path.size() && !is_separator(rel_path[end])) @@ -1103,7 +1106,10 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p } // Resolve against the canonical root so a symlink inside it cannot lead back out. try { - const std::string root_str = boost::filesystem::weakly_canonical(root).string(); + std::string root_str = boost::filesystem::weakly_canonical(root).string(); + // A trailing separator on root would otherwise fail the prefix match below for every path. + while (!root_str.empty() && (root_str.back() == '/' || root_str.back() == boost::filesystem::path::preferred_separator)) + root_str.pop_back(); const std::string full_str = boost::filesystem::weakly_canonical(root / rel_path).string(); return full_str.compare(0, root_str.size(), root_str) == 0 && (full_str.size() == root_str.size() || full_str[root_str.size()] == boost::filesystem::path::preferred_separator); @@ -1112,6 +1118,15 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p } } +bool is_symlink_target_within_root(const std::string &link_rel_path, const std::string &target, const boost::filesystem::path &root) +{ + if (target.empty() || target.front() == '/' || target.front() == '\\' || (target.size() > 1 && target[1] == ':')) + return false; + // A relative target without ".." only descends from the link's directory, so no chain of such links can leave root. + const size_t sep = link_rel_path.find_last_of("/\\"); + return is_path_within_root((sep == std::string::npos ? std::string() : link_rel_path.substr(0, sep + 1)) + target, root); +} + bool is_img_file(const std::string &path) { return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index a41001f9e0..72849dd78e 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -1512,11 +1512,33 @@ int GUI_App::install_plugin(std::string name, std::string package_name, InstallP size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); dest_file = decode(extra.substr(0, n), stat.m_filename); } + if (!is_path_within_root(dest_file, plugin_folder)) { + BOOST_LOG_TRIVIAL(error) << "[install_plugin] entry " << dest_file << " resolves outside " << plugin_folder.string(); + close_zip_reader(&archive); + if (pro_fn) { pro_fn(InstallStatusUnzipFailed, 0, cancel); } + return InstallStatusUnzipFailed; + } auto dest_path = plugin_folder / dest_file; - boost::filesystem::create_directories(dest_path.parent_path()); std::string dest_zip_file = encode_path(dest_path.string().c_str()); +#ifndef WIN32 + // Validate a symlink's target before anything at the destination is replaced. + const bool is_link = S_ISLNK(stat.m_external_attr >> 16); + std::string link; + if (is_link) { + link.assign(stat.m_uncomp_size, 0); + if (!mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, link.data(), stat.m_uncomp_size, 0) || + !is_symlink_target_within_root(dest_file, link, plugin_folder)) { + BOOST_LOG_TRIVIAL(error) << "[install_plugin] link " << dest_file << " -> " << link << " is unreadable or resolves outside " << plugin_folder.string(); + close_zip_reader(&archive); + if (pro_fn) { pro_fn(InstallStatusUnzipFailed, 0, cancel); } + return InstallStatusUnzipFailed; + } + } +#endif try { - if (fs::exists(dest_path)) { + boost::filesystem::create_directories(dest_path.parent_path()); + // symlink_status so that an existing symlink, dangling or not, is replaced rather than written through. + if (fs::exists(fs::symlink_status(dest_path))) { boost::system::error_code ec; fs::remove(dest_path, ec); if (ec) { @@ -1544,9 +1566,8 @@ int GUI_App::install_plugin(std::string name, std::string package_name, InstallP } mz_bool res = 0; #ifndef WIN32 - if (S_ISLNK(stat.m_external_attr >> 16)) { - std::string link(stat.m_uncomp_size + 1, 0); - res = mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, link.data(), stat.m_uncomp_size, 0); + if (is_link) { + res = 1; try { boost::filesystem::create_symlink(link, dest_path); } catch (const std::exception &e) { diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index b8379697d6..5bbe779970 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -339,62 +339,8 @@ bool PresetUpdater::priv::get_file(const std::string &url, const fs::path &targe //BBS: refine preset update logic bool PresetUpdater::priv::extract_file(const fs::path &source_path, const fs::path &dest_path) { - bool res = true; - std::string file_path = source_path.string(); - std::string parent_path = (!dest_path.empty() ? dest_path : source_path.parent_path()).string(); - mz_zip_archive archive; - mz_zip_zero_struct(&archive); - - if (!open_zip_reader(&archive, file_path)) - { - BOOST_LOG_TRIVIAL(error) << "Unable to open zip reader for "< + +#include "libslic3r/miniz_extension.hpp" + +#include "test_utils.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; +namespace fs = boost::filesystem; + +namespace { + +void write_zip(const fs::path &zip_file, const std::vector> &entries) +{ + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_writer(&zip, zip_file.string())); + for (const auto &[name, content] : entries) + REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_finalize_archive(&zip)); + REQUIRE(close_zip_writer(&zip)); +} + +// miniz refuses to write a name starting with '/', so write a placeholder of the same length and patch it in place. +void rename_entry(const fs::path &zip_file, const std::string &from, const std::string &to) +{ + REQUIRE(from.size() == to.size()); + std::string bytes; + { + std::ifstream in(zip_file.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + size_t count = 0; + for (size_t pos = bytes.find(from); pos != std::string::npos; pos = bytes.find(from, pos + to.size()), ++count) + bytes.replace(pos, from.size(), to); + // Once in the local header and once in the central directory. + REQUIRE(count == 2); + std::ofstream out(zip_file.string(), std::ios::binary | std::ios::trunc); + out << bytes; +} + +std::vector list_dir(const fs::path &dir) +{ + std::vector names; + for (const fs::directory_entry &entry : fs::directory_iterator(dir)) + names.push_back(entry.path().filename().string()); + std::sort(names.begin(), names.end()); + return names; +} + +std::string read_file(const fs::path &file) +{ + std::ifstream in(file.string(), std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +} // namespace + +TEST_CASE("Confined extraction writes a well-formed archive under the target directory", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + fs::create_directories(target); + write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"}, {"vendor/machine/printer.json", "{\"b\":2}"}}); + + REQUIRE(extract_archive_confined(zip_file.string(), target.string())); + CHECK(fs::is_directory(target / "vendor")); + CHECK(read_file(target / "vendor.json") == "{\"a\":1}"); + CHECK(read_file(target / "vendor" / "machine" / "printer.json") == "{\"b\":2}"); +} + +TEST_CASE("Confined extraction rejects an archive with an entry outside the target directory", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + fs::create_directories(target); + + const std::string escaping_entry = GENERATE(std::string("../escape.txt"), std::string("..\\escape.txt"), + std::string("sub/../../escape.txt"), std::string("C:/escape.txt"), + std::string("C:escape.txt"), std::string("\\escape.txt")); + // The normal entry comes first so a per-entry check would already have written it. + write_zip(zip_file, {{"normal.json", "{}"}, {escaping_entry, "escaped"}}); + + CAPTURE(escaping_entry); + CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string())); + CHECK_FALSE(fs::exists(tmp.path() / "escape.txt")); + CHECK(fs::is_empty(target)); +} + +TEST_CASE("Confined extraction rejects an archive with an absolute entry name", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + fs::create_directories(target); + + const std::string absolute = (tmp.path() / "escape.txt").generic_string(); + const std::string placeholder = "#" + absolute.substr(1); + write_zip(zip_file, {{"normal.json", "{}"}, {placeholder, "escaped"}}); + rename_entry(zip_file, placeholder, absolute); + + CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string())); + CHECK_FALSE(fs::exists(tmp.path() / "escape.txt")); + CHECK(fs::is_empty(target)); +} + +TEST_CASE("Confined extraction rejects a directory entry outside the target directory", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + fs::create_directories(target); + write_zip(zip_file, {{"vendor/", ""}, {"../outside/", ""}}); + + CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string())); + CHECK_FALSE(fs::exists(tmp.path() / "outside")); + CHECK(fs::is_empty(target)); +} + +TEST_CASE("Confined extraction validates zero-size entries like any other", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + fs::create_directories(target); + + SECTION("an empty file inside the target does not fail the archive") { + write_zip(zip_file, {{"empty.json", ""}, {"vendor.json", "{}"}}); + CHECK(extract_archive_confined(zip_file.string(), target.string())); + CHECK(read_file(target / "vendor.json") == "{}"); + } + SECTION("an empty file outside the target rejects the archive") { + write_zip(zip_file, {{"vendor.json", "{}"}, {"../escape.txt", ""}}); + CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string())); + CHECK_FALSE(fs::exists(tmp.path() / "escape.txt")); + CHECK(fs::is_empty(target)); + } +} + +TEST_CASE("Confined extraction writes nothing outside the target for Windows-specific name forms", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + fs::create_directories(target); + + // Windows strips trailing dots and spaces and maps device names; whether these extract depends on the + // platform, but none of them may land beside the target. + const std::string name = GENERATE(std::string("name."), std::string("name "), std::string("..."), std::string(".. "), + std::string(".. /escape.txt"), std::string(".../escape.txt"), std::string("CON"), + std::string("sub/NUL.txt"), std::string("C:escape.txt")); + write_zip(zip_file, {{name, "payload"}}); + + CAPTURE(name); + extract_archive_confined(zip_file.string(), target.string()); + CHECK(list_dir(tmp.path()) == std::vector{"bundle.zip", "cache"}); +} + +#ifndef _WIN32 +TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + const fs::path target = tmp.path() / "cache"; + const fs::path outside = tmp.path() / "outside"; + fs::create_directories(target); + fs::create_directories(outside); + write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}}); + + SECTION("a dangling symlink") { + fs::create_symlink(outside / "vendor.json", target / "vendor.json"); + CHECK(extract_archive_confined(zip_file.string(), target.string())); + CHECK_FALSE(fs::exists(outside / "vendor.json")); + CHECK_FALSE(fs::is_symlink(fs::symlink_status(target / "vendor.json"))); + CHECK(read_file(target / "vendor.json") == "{\"a\":1}"); + } + SECTION("a symlink to an existing file") { + { std::ofstream((outside / "vendor.json").string()) << "original"; } + fs::create_symlink(outside / "vendor.json", target / "vendor.json"); + extract_archive_confined(zip_file.string(), target.string()); + CHECK(read_file(outside / "vendor.json") == "original"); + } +} +#endif diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index 2111173326..e2b220d482 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -243,3 +243,82 @@ TEST_CASE("find_unused_filename gives up after 999 versions", "[Utils]") { REQUIRE_FALSE(find_unused_filename(dir.path(), "model.3mf", {}, name)); CHECK(name == "model(999).3mf"); } + +TEST_CASE("is_path_within_root accepts a root given with a trailing separator", "[utils]") { + ScopedTemporaryDir tmp; + const std::string root = tmp.path().string(); + const std::string with_separator = GENERATE_COPY(root + "/", root + std::string(1, static_cast(boost::filesystem::path::preferred_separator))); + + CAPTURE(with_separator); + CHECK(is_path_within_root("vendor.json", with_separator)); + CHECK(is_path_within_root("vendor/machine/printer.json", with_separator)); + CHECK_FALSE(is_path_within_root("../vendor.json", with_separator)); +} + +TEST_CASE("is_path_within_root treats Windows-specific name forms the same on every platform", "[utils]") { + ScopedTemporaryDir tmp; + + SECTION("names ending in dots or spaces stay inside the root") { + const std::string name = GENERATE(std::string("name."), std::string("name "), std::string("dir./file.json"), std::string("dir /file.json")); + CAPTURE(name); + CHECK(is_path_within_root(name, tmp.path())); + } + SECTION("drive-relative names are rejected") { + const std::string name = GENERATE(std::string("C:x"), std::string("c:x/y.json"), std::string("C:")); + CAPTURE(name); + CHECK_FALSE(is_path_within_root(name, tmp.path())); + } +} + +TEST_CASE("is_path_within_root rejects a name with an embedded NUL", "[utils]") { + ScopedTemporaryDir tmp; + // The filesystem calls stop at the NUL, so they would act on a different path than the one checked. + const std::string name = GENERATE(std::string("..\0", 3), std::string("..\0x/file.json", 14), std::string("sub/..\0x", 8), + std::string("file.json\0", 10), std::string("\0file.json", 10)); + CAPTURE(name.size()); + CHECK_FALSE(is_path_within_root(name, tmp.path())); +} + +TEST_CASE("is_symlink_target_within_root accepts relative targets that stay inside the root", "[utils]") { + ScopedTemporaryDir tmp; + const auto [link, target] = GENERATE(std::make_pair(std::string("Versions/Current"), std::string("A")), + std::make_pair(std::string("Foo.framework/Foo"), std::string("Versions/Current/Foo")), + std::make_pair(std::string("libfoo.so"), std::string("libfoo.so.1")), + std::make_pair(std::string("a/b/link"), std::string("c/d"))); + CAPTURE(link, target); + CHECK(is_symlink_target_within_root(link, target, tmp.path())); +} + +TEST_CASE("is_symlink_target_within_root rejects absolute targets and targets that climb out", "[utils]") { + ScopedTemporaryDir tmp; + const std::string outside = (tmp.path().parent_path() / "outside").generic_string(); + const auto [link, target] = GENERATE_COPY(std::make_pair(std::string("sub/link"), outside), + std::make_pair(std::string("sub/link"), std::string("/etc/passwd")), + std::make_pair(std::string("sub/link"), std::string("\\outside")), + std::make_pair(std::string("sub/link"), std::string("C:/outside")), + std::make_pair(std::string("sub/link"), std::string("C:outside")), + std::make_pair(std::string("sub/link"), std::string("")), + std::make_pair(std::string("link"), std::string("..")), + std::make_pair(std::string("link"), std::string("../outside")), + std::make_pair(std::string("sub/link"), std::string("../../outside")), + std::make_pair(std::string("sub/link"), std::string("x/../../../outside")), + std::make_pair(std::string("sub/link"), std::string("..\\..\\outside")), + // symlink() stops at the NUL, so this target would be created as "..". + std::make_pair(std::string("link"), std::string("..\0", 3))); + CAPTURE(link, target); + CHECK_FALSE(is_symlink_target_within_root(link, target, tmp.path())); +} + +#ifndef _WIN32 +TEST_CASE("is_symlink_target_within_root rejects a target that passes through a symlink leading out", "[utils]") { + ScopedTemporaryDir tmp; + const boost::filesystem::path root = tmp.path() / "root"; + const boost::filesystem::path outside = tmp.path() / "outside"; + boost::filesystem::create_directories(root); + boost::filesystem::create_directories(outside); + boost::filesystem::create_symlink(outside, root / "out"); + + CHECK_FALSE(is_symlink_target_within_root("link", "out/lib.so", root)); + CHECK(is_symlink_target_within_root("link", "in/lib.so", root)); +} +#endif From e40030cf81705fb7c298ca11742fe85a6ea5a488 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:39:01 +0800 Subject: [PATCH 8/9] Stop Malformed Network Responses from Crashing the App (#15947) * Stop Malformed Network Responses from Crashing the App Duet, MKS and UltiMaker parsed print host replies with boost read_json inside the HTTP completion callback with no try, so an HTML or truncated reply threw out of the Physical Printer Test button and terminated the app, or killed the upload queue thread. The five identical copies of the parser (ESP3D's and Flashforge's were unused) are replaced by one shared PrintHost::get_err_code_from_body that reports a non-JSON reply as an error. The upload queue now catches a failing job per job, so one bad upload no longer leaves later jobs queued forever. Flashforge read material station slots with nlohmann value(), which throws on off-type fields or non-object entries. The parsing moves into Flashforge::parse_material_slots, which reads fields leniently with the existing try_parse_json_int and skips bad entries. UserManager::parse_json parsed the payload before its try block; the parse now happens inside it. * Keep UploadFinished Paired with UploadStarted When an Upload Throws The exception from a throwing upload was caught around perform_job, so the UploadFinished lifecycle event was skipped and plugins saw an upload start that never finished. The catch now sits around the upload call. The error is reported through the job's error callback and UploadFinished is fired with an error code, as for any other failed upload. The worker keeps running for the next job. The started, upload and finished sequence moved to PrintHostJobQueue::upload_job so it can be tested without the dialog. --- src/slic3r/GUI/UserManager.cpp | 9 +- src/slic3r/Utils/Duet.cpp | 9 -- src/slic3r/Utils/Duet.hpp | 1 - src/slic3r/Utils/ESP3D.cpp | 9 -- src/slic3r/Utils/ESP3D.hpp | 1 - src/slic3r/Utils/Flashforge.cpp | 40 +++--- src/slic3r/Utils/Flashforge.hpp | 3 +- src/slic3r/Utils/MKS.cpp | 9 -- src/slic3r/Utils/MKS.hpp | 1 - src/slic3r/Utils/PrintHost.cpp | 51 ++++++-- src/slic3r/Utils/PrintHost.hpp | 6 + src/slic3r/Utils/UltiMaker.cpp | 9 -- src/slic3r/Utils/UltiMaker.hpp | 1 - tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/test_printhost.cpp | 161 ++++++++++++++++++++++++ tests/slic3rutils/test_user_manager.cpp | 23 ++++ 16 files changed, 265 insertions(+), 69 deletions(-) create mode 100644 tests/slic3rutils/test_user_manager.cpp diff --git a/src/slic3r/GUI/UserManager.cpp b/src/slic3r/GUI/UserManager.cpp index e874c2158d..f181f3e568 100644 --- a/src/slic3r/GUI/UserManager.cpp +++ b/src/slic3r/GUI/UserManager.cpp @@ -31,14 +31,15 @@ int UserManager::parse_json(std::string payload) { bool restored_json = false; json j; - json j_pre = json::parse(payload); - if (j_pre.empty()) { - return -1; - } //bind/unbind try { + json j_pre = json::parse(payload); + if (j_pre.empty()) { + return -1; + } + if (j_pre.contains("bind")) { if (j_pre["bind"].contains("command")) { diff --git a/src/slic3r/Utils/Duet.cpp b/src/slic3r/Utils/Duet.cpp index 37db6b6c33..e26a493131 100644 --- a/src/slic3r/Utils/Duet.cpp +++ b/src/slic3r/Utils/Duet.cpp @@ -274,13 +274,4 @@ bool Duet::start_print(wxString &msg, const std::string &filename, ConnectionTyp return res; } -int Duet::get_err_code_from_body(const std::string &body) const -{ - pt::ptree root; - std::istringstream iss (body); // wrap returned json to istringstream - pt::read_json(iss, root); - - return root.get("err", 0); -} - } diff --git a/src/slic3r/Utils/Duet.hpp b/src/slic3r/Utils/Duet.hpp index 2a91aa8536..7808c51fde 100644 --- a/src/slic3r/Utils/Duet.hpp +++ b/src/slic3r/Utils/Duet.hpp @@ -40,7 +40,6 @@ private: ConnectionType connect(wxString &msg) const; void disconnect(ConnectionType connectionType) const; bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType, bool simulationMode) const; - int get_err_code_from_body(const std::string &body) const; }; } diff --git a/src/slic3r/Utils/ESP3D.cpp b/src/slic3r/Utils/ESP3D.cpp index 6de41ebe55..5c10862118 100644 --- a/src/slic3r/Utils/ESP3D.cpp +++ b/src/slic3r/Utils/ESP3D.cpp @@ -146,15 +146,6 @@ bool ESP3D::start_print(wxString& msg, const std::string& filename) const return ret; } -int ESP3D::get_err_code_from_body(const std::string& body) const -{ - pt::ptree root; - std::istringstream iss(body); // wrap returned json to istringstream - pt::read_json(iss, root); - - return root.get("err", 0); -} - // ESP3D only accepts 8.3 filenames else it crashes marlin and other undefined behaviour std::string ESP3D::get_short_name(const std::string& filename) const { diff --git a/src/slic3r/Utils/ESP3D.hpp b/src/slic3r/Utils/ESP3D.hpp index 7ac3c66f48..44d206d238 100644 --- a/src/slic3r/Utils/ESP3D.hpp +++ b/src/slic3r/Utils/ESP3D.hpp @@ -33,7 +33,6 @@ private: std::string m_console_port; bool start_print(wxString& msg, const std::string& filename) const; - int get_err_code_from_body(const std::string& body) const; std::string get_short_name(const std::string& filename) const; std::string format_command(const std::string& path, const std::string& arg, const std::string& val) const; }; diff --git a/src/slic3r/Utils/Flashforge.cpp b/src/slic3r/Utils/Flashforge.cpp index 88b3eb3f69..a042a889ae 100644 --- a/src/slic3r/Utils/Flashforge.cpp +++ b/src/slic3r/Utils/Flashforge.cpp @@ -510,12 +510,22 @@ bool Flashforge::fetch_material_slots(std::vector& slots if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg)) return false; - const auto parsed = json::parse(body, nullptr, false, true); - if (parsed.is_discarded()) { + if (!parse_material_slots(body, slots, supports_material_station)) { msg = _(L("Flashforge returned an invalid JSON response.")); return false; } + return true; +} + +bool Flashforge::parse_material_slots(const std::string& body, std::vector& slots, bool* supports_material_station) +{ + slots.clear(); + + const auto parsed = json::parse(body, nullptr, false, true); + if (parsed.is_discarded()) + return false; + const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed; const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] : detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json(); @@ -542,12 +552,21 @@ bool Flashforge::fetch_material_slots(std::vector& slots if (supports_material_station != nullptr) *supports_material_station = reports_material_station; + // Fields are read leniently: firmware may send numbers as strings or flags as numbers. for (const auto& slot : slot_infos) { + if (!slot.is_object()) + continue; FlashforgeMaterialSlot info; - info.slot_id = slot.value("slotId", static_cast(slots.size()) + 1); - info.has_filament = slot.value("hasFilament", false); - info.material_name = slot.value("materialName", std::string()); - info.material_color = slot.value("materialColor", std::string()); + info.slot_id = static_cast(slots.size()) + 1; + if (const auto it = slot.find("slotId"); it != slot.end()) + try_parse_json_int(*it, info.slot_id); + int has_filament = 0; + if (const auto it = slot.find("hasFilament"); it != slot.end() && try_parse_json_int(*it, has_filament)) + info.has_filament = has_filament != 0; + if (const auto it = slot.find("materialName"); it != slot.end() && it->is_string()) + info.material_name = it->get(); + if (const auto it = slot.find("materialColor"); it != slot.end() && it->is_string()) + info.material_color = it->get(); slots.emplace_back(std::move(info)); } @@ -670,13 +689,4 @@ std::string Flashforge::extract_host_name() const return out; } -int Flashforge::get_err_code_from_body(const std::string& body) const -{ - pt::ptree root; - std::istringstream iss(body); // wrap returned json to istringstream - pt::read_json(iss, root); - - return root.get("err", 0); -} - } // namespace Slic3r diff --git a/src/slic3r/Utils/Flashforge.hpp b/src/slic3r/Utils/Flashforge.hpp index ea7acdcb11..237cf4c009 100644 --- a/src/slic3r/Utils/Flashforge.hpp +++ b/src/slic3r/Utils/Flashforge.hpp @@ -45,6 +45,8 @@ public: PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; } std::string get_host() const override { return m_host; } bool fetch_material_slots(std::vector& slots, bool* supports_material_station, wxString& msg) const; + // Parses a local API "detail" reply. Returns false when the body is not valid JSON. + static bool parse_material_slots(const std::string& body, std::vector& slots, bool* supports_material_station); static bool discover_printers(std::vector& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3); private: @@ -68,7 +70,6 @@ private: bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const; std::string make_http_url(const std::string& path) const; std::string extract_host_name() const; - int get_err_code_from_body(const std::string &body) const; bool connect(wxString& msg) const; bool start_print(wxString& msg, const std::string& filename) const; }; diff --git a/src/slic3r/Utils/MKS.cpp b/src/slic3r/Utils/MKS.cpp index c4aa4c7262..ced7af9c9f 100644 --- a/src/slic3r/Utils/MKS.cpp +++ b/src/slic3r/Utils/MKS.cpp @@ -141,13 +141,4 @@ bool MKS::start_print(wxString& msg, const std::string& filename) const return ret; } -int MKS::get_err_code_from_body(const std::string& body) const -{ - pt::ptree root; - std::istringstream iss(body); // wrap returned json to istringstream - pt::read_json(iss, root); - - return root.get("err", 0); -} - } // Slic3r diff --git a/src/slic3r/Utils/MKS.hpp b/src/slic3r/Utils/MKS.hpp index 79143fdd9a..01cdafc405 100644 --- a/src/slic3r/Utils/MKS.hpp +++ b/src/slic3r/Utils/MKS.hpp @@ -34,7 +34,6 @@ private: std::string get_upload_url(const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const; - int get_err_code_from_body(const std::string& body) const; }; } diff --git a/src/slic3r/Utils/PrintHost.cpp b/src/slic3r/Utils/PrintHost.cpp index f3333c731d..2eeb78a975 100644 --- a/src/slic3r/Utils/PrintHost.cpp +++ b/src/slic3r/Utils/PrintHost.cpp @@ -3,10 +3,13 @@ #include #include #include +#include #include #include #include #include +#include +#include #include #include @@ -172,6 +175,20 @@ std::string moonraker_error_reason(const std::string &body) } // namespace +int PrintHost::get_err_code_from_body(const std::string &body) +{ + boost::property_tree::ptree root; + std::istringstream iss(body); + try { + boost::property_tree::read_json(iss, root); + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(error) << "PrintHost: response is not valid JSON: " << ex.what(); + return -1; + } + + return root.get("err", 0); +} + wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const { if (status != 0) { @@ -415,12 +432,10 @@ void PrintHostJobQueue::priv::remove_source() source_to_remove.clear(); } -void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job) +bool PrintHostJobQueue::upload_job(PrintHostJob &job, PrintHost::ProgressFn progress_fn, PrintHost::ErrorFn error_fn, PrintHost::InfoFn info_fn) { - emit_progress(0); // Indicate the upload is starting - // Captured before upload_data is moved into upload() below. - const std::string upload_filename = the_job.upload_data.source_path.filename().string(); + const std::string upload_filename = job.upload_data.source_path.filename().string(); { LifecycleEventContext ctx; @@ -429,19 +444,37 @@ void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job) fire_lifecycle_event(LifecycleEvent::UploadStarted, ctx); } - bool success = the_job.printhost->upload(std::move(the_job.upload_data), - [this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); }, - [this](wxString error) { this->error_fn(std::move(error)); }, - [this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); } - ); + bool success = false; + std::string error; + // A throwing upload must not stop the worker, or later jobs would stay queued forever. + try { + success = job.printhost->upload(std::move(job.upload_data), std::move(progress_fn), error_fn, std::move(info_fn)); + } catch (const std::exception &e) { + error = e.what(); + error_fn(error); + } { LifecycleEventContext ctx; ctx.name = upload_filename; ctx.code = success ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error; + ctx.msg = error; fire_lifecycle_event(LifecycleEvent::UploadFinished, ctx); } + return success; +} + +void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job) +{ + emit_progress(0); // Indicate the upload is starting + + bool success = PrintHostJobQueue::upload_job(the_job, + [this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); }, + [this](wxString error) { this->error_fn(std::move(error)); }, + [this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); } + ); + if (success) { emit_progress(100); if (the_job.switch_to_device_tab) { diff --git a/src/slic3r/Utils/PrintHost.hpp b/src/slic3r/Utils/PrintHost.hpp index 87a8df8934..cf197ede07 100644 --- a/src/slic3r/Utils/PrintHost.hpp +++ b/src/slic3r/Utils/PrintHost.hpp @@ -87,6 +87,8 @@ public: static PrintHost* get_print_host(DynamicPrintConfig *config); static std::string get_print_host_webui(DynamicPrintConfig *config); + // Reads the "err" field of a JSON reply, 0 when absent. Returns -1 when the body is not valid JSON. + static int get_err_code_from_body(const std::string &body); //Support for cloud webui login virtual bool is_cloud() const { return false; } @@ -150,6 +152,10 @@ public: void enqueue(PrintHostJob job); void cancel(size_t id); + // Uploads the job, firing UploadStarted and a matching UploadFinished. An exception thrown by + // the upload is reported through error_fn and makes the upload fail. + static bool upload_job(PrintHostJob &job, PrintHost::ProgressFn progress_fn, PrintHost::ErrorFn error_fn, PrintHost::InfoFn info_fn); + private: struct priv; std::shared_ptr p; diff --git a/src/slic3r/Utils/UltiMaker.cpp b/src/slic3r/Utils/UltiMaker.cpp index 0376578db0..badb69d209 100644 --- a/src/slic3r/Utils/UltiMaker.cpp +++ b/src/slic3r/Utils/UltiMaker.cpp @@ -654,13 +654,4 @@ bool UltiMaker::start_print(wxString &msg, const std::string &filename, Connecti return res; } -int UltiMaker::get_err_code_from_body(const std::string &body) const -{ - pt::ptree root; - std::istringstream iss (body); // wrap returned json to istringstream - pt::read_json(iss, root); - - return root.get("err", 0); -} - } diff --git a/src/slic3r/Utils/UltiMaker.hpp b/src/slic3r/Utils/UltiMaker.hpp index b32b87434f..c1f9ced304 100644 --- a/src/slic3r/Utils/UltiMaker.hpp +++ b/src/slic3r/Utils/UltiMaker.hpp @@ -64,7 +64,6 @@ private: void set_auth(Http& http) const; void disconnect(ConnectionType connectionType) const; bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType) const; - int get_err_code_from_body(const std::string &body) const; }; } diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 94ebaed35e..a3863134d5 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -28,6 +28,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_audit.cpp test_shortcuts.cpp test_file_url.cpp + test_user_manager.cpp ../fff_print/test_helpers.cpp ) diff --git a/tests/slic3rutils/test_printhost.cpp b/tests/slic3rutils/test_printhost.cpp index 557d4a5c89..c4df56ebb6 100644 --- a/tests/slic3rutils/test_printhost.cpp +++ b/tests/slic3rutils/test_printhost.cpp @@ -1,8 +1,15 @@ #include +#include +#include +#include +#include + #include +#include "libslic3r/LifecycleEvents.hpp" #include "slic3r/Utils/PrintHost.hpp" +#include "slic3r/Utils/Flashforge.hpp" using namespace Slic3r; @@ -24,6 +31,41 @@ public: std::string get_host() const override { return {}; } }; +class ThrowingPrintHost : public TestPrintHost +{ +public: + bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { throw std::runtime_error("reply could not be read"); } +}; + +struct UploadEvents +{ + std::vector events; + std::vector codes; + std::vector errors; + bool uploaded{false}; + + explicit UploadEvents(std::unique_ptr host) + { + set_lifecycle_hook_fn([this](LifecycleEvent event, const LifecycleEventContext& ctx) { + events.push_back(event); + codes.push_back(ctx.code); + }); + + PrintHostJob job; + job.printhost = std::move(host); + job.upload_data.source_path = "plate.gcode"; + try { + uploaded = PrintHostJobQueue::upload_job(job, [](Http::Progress, bool&) {}, + [this](wxString error) { errors.push_back(error.ToStdString()); }, + [](wxString, wxString) {}); + } catch (...) { + set_lifecycle_hook_fn(nullptr); + throw; + } + set_lifecycle_hook_fn(nullptr); + } +}; + std::string format_error(const std::string& body, const std::string& error, unsigned status) { return TestPrintHost().format_error(body, error, status).ToStdString(); @@ -46,6 +88,14 @@ std::string moonraker_error(int code, const std::string& message, const std::str constexpr const char* k_busy_file_403 = R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON"; +// Replies a print host can send instead of JSON: a proxy or login page, nothing, a cut-off body. +const std::vector non_json_replies = { + "proxy login required", + "", + "{\"err\":", + "{\"detail\":{\"matlStationInfo\":{\"slotInfos\":[{\"slotId\":1,", +}; + } // namespace TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]") @@ -211,3 +261,114 @@ TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", " CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect"); } } + +TEST_CASE("Print host error code is read from a JSON reply", "[PrintHost]") +{ + CHECK(PrintHost::get_err_code_from_body(R"({"err":0})") == 0); + CHECK(PrintHost::get_err_code_from_body(R"({"err":2})") == 2); + CHECK(PrintHost::get_err_code_from_body(R"({"status":"ok"})") == 0); +} + +TEST_CASE("Print host error code reports a reply that is not JSON as an error", "[PrintHost]") +{ + const std::string body = GENERATE(from_range(non_json_replies)); + int err = 0; + REQUIRE_NOTHROW(err = PrintHost::get_err_code_from_body(body)); + CHECK(err != 0); +} + +TEST_CASE("Print host error code tolerates a wrongly typed err field", "[PrintHost]") +{ + const std::string body = GENERATE(as{}, R"({"err":"busy"})", R"({"err":{"code":1}})", R"([1,2])"); + CHECK_NOTHROW(PrintHost::get_err_code_from_body(body)); +} + +TEST_CASE("Flashforge material slots are read from a well-formed reply", "[PrintHost][Flashforge]") +{ + const std::string body = R"({"code":0,"detail":{"hasMatlStation":true,"matlStationInfo":{"slotCnt":2,"slotInfos":[ + {"slotId":1,"hasFilament":true,"materialName":"PLA","materialColor":"#FFFFFF"}, + {"slotId":2,"hasFilament":false,"materialName":"","materialColor":""}]}}})"; + + std::vector slots; + bool supports_station = false; + REQUIRE(Flashforge::parse_material_slots(body, slots, &supports_station)); + CHECK(supports_station); + REQUIRE(slots.size() == 2); + CHECK(slots[0].slot_id == 1); + CHECK(slots[0].has_filament); + CHECK(slots[0].material_name == "PLA"); + CHECK(slots[0].material_color == "#FFFFFF"); + CHECK(slots[1].slot_id == 2); + CHECK_FALSE(slots[1].has_filament); +} + +TEST_CASE("Flashforge material slots accept numbers as strings and flags as numbers", "[PrintHost][Flashforge]") +{ + const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[ + {"slotId":"3","hasFilament":1,"materialName":null,"materialColor":7}]}}})"; + + std::vector slots; + REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr)); + REQUIRE(slots.size() == 1); + CHECK(slots[0].slot_id == 3); + CHECK(slots[0].has_filament); + CHECK(slots[0].material_name.empty()); + CHECK(slots[0].material_color.empty()); +} + +TEST_CASE("Flashforge material slots skip entries that are not objects", "[PrintHost][Flashforge]") +{ + const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[5,"slot",null,[], + {"slotId":4,"hasFilament":true,"materialName":"PETG"}]}}})"; + + std::vector slots; + REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr)); + REQUIRE(slots.size() == 1); + CHECK(slots[0].slot_id == 4); + CHECK(slots[0].material_name == "PETG"); +} + +TEST_CASE("Flashforge material slots tolerate slot info that is not a list", "[PrintHost][Flashforge]") +{ + const std::string body = GENERATE(as{}, + R"({"detail":{"matlStationInfo":{"slotInfos":5}}})", + R"({"detail":{"matlStationInfo":{"slotInfos":"none"}}})", + R"({"detail":{"matlStationInfo":7}})", + R"({"detail":"offline"})"); + + std::vector slots; + bool ok = false; + REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr)); + CHECK(ok); + CHECK(slots.empty()); +} + +TEST_CASE("Flashforge material slots reject a reply that is not JSON", "[PrintHost][Flashforge]") +{ + const std::string body = GENERATE(from_range(non_json_replies)); + std::vector slots; + bool ok = true; + REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr)); + CHECK_FALSE(ok); + CHECK(slots.empty()); +} + +TEST_CASE("An upload that throws still finishes with an error", "[PrintHost][LifecycleEvents]") +{ + UploadEvents run(std::make_unique()); + + CHECK_FALSE(run.uploaded); + CHECK(run.errors == std::vector{"reply could not be read"}); + CHECK(run.events == std::vector{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished}); + CHECK(run.codes == std::vector{LifecycleEvtCode::Ok, LifecycleEvtCode::Error}); +} + +TEST_CASE("A successful upload finishes without an error", "[PrintHost][LifecycleEvents]") +{ + UploadEvents run(std::make_unique()); + + CHECK(run.uploaded); + CHECK(run.errors.empty()); + CHECK(run.events == std::vector{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished}); + CHECK(run.codes == std::vector{LifecycleEvtCode::Ok, LifecycleEvtCode::Ok}); +} diff --git a/tests/slic3rutils/test_user_manager.cpp b/tests/slic3rutils/test_user_manager.cpp new file mode 100644 index 0000000000..cfccebe1cc --- /dev/null +++ b/tests/slic3rutils/test_user_manager.cpp @@ -0,0 +1,23 @@ +#include + +#include + +#include "slic3r/GUI/UserManager.hpp" + +using namespace Slic3r; + +TEST_CASE("User message that is not JSON is rejected without throwing", "[UserManager]") +{ + const std::string payload = GENERATE(as{}, "not json", "", "", "{\"bind\":"); + UserManager manager; + int result = 0; + REQUIRE_NOTHROW(result = manager.parse_json(payload)); + CHECK(result == -1); +} + +TEST_CASE("User message without a successful bind is ignored", "[UserManager]") +{ + const std::string payload = GENERATE(as{}, "{}", R"({"bind":{"command":"unbind"}})", R"({"bind":"bind"})", "[1]"); + UserManager manager; + CHECK(manager.parse_json(payload) == -1); +} From 203bc63f352853aba9801170dc228429a95e49b4 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:39:30 +0800 Subject: [PATCH 9/9] Escape Project Metadata in the Project Page and Restrict Accessory Opening (#15956) * Escape Project Metadata in the Project Page and Restrict Accessory Opening The Project page rendered the model and profile name, author, description and accessory file names from the 3MF as live HTML. Names, authors and file names are now set as text, and the file list is built from DOM nodes with bound click handlers instead of concatenated markup. Descriptions can legitimately carry rich-text HTML, so they are rebuilt from an inert DOMParser document, keeping only plain formatting tags, http(s) links and http(s) images, with every other attribute dropped. Opening an accessory from the page now only launches regular files that lie inside the project's extracted auxiliary directory. The containment check is a new libslic3r helper, is_absolute_path_within_root, built on is_path_within_root so symlinks leading out of the root are rejected too. * Tighten Project Page Description Rendering and Keep More Formatting Link and image URLs in descriptions must now start with an http or https scheme as written and parse as such with the URL parser. Preview images are built as DOM nodes like the file list, and accessory names show their full text as a tooltip. Descriptions keep more plain formatting: del, ins, figure, figcaption, dl, dt, dd, caption, q, abbr, kbd and wbr, plus alt, title, width and height on images, colspan and rowspan on table cells and start on ordered lists. Numeric attributes must be plain integers. Embedded YouTube players become a link to the video. * Confirm Before Opening Program Attachments and Load Only HTTPS Images Opening a project attachment whose type runs as a program or script (executables, installers, shortcuts, shell and PowerShell scripts, macOS command files and apps, Linux desktop entries) now asks for confirmation first. The check lives in libslic3r as is_executable_file_name and ignores the trailing dots and spaces Windows strips from file names. Images in project descriptions are kept only when they load over https, so opening the Project tab no longer issues plain-http requests. * Open Project Attachments Through One Guarded Helper The Edit Project Info view launched attachments directly, without the checks the project page has. Both now call desktop_open_project_attachment, which checks that the file is inside the auxiliary directory, asks for confirmation where needed and then opens it. The auxiliary root was built through encode_path, which returns code page bytes on Windows, while boost::filesystem reads a narrow string as UTF-8. With a non-ASCII temporary directory the root never matched and no attachment opened. It is now built from the UTF-8 path directly. The list of program extensions could not be kept complete and let unknown types open without a prompt. It is replaced by is_safe_to_open_file_name, a list of plain document, image, model and video types that open directly. Everything else asks first. --- resources/web/model/model.js | 159 ++++++++++++++++++++++++++------- src/libslic3r/Utils.hpp | 6 ++ src/libslic3r/utils.cpp | 23 +++++ src/slic3r/GUI/Auxiliary.cpp | 2 +- src/slic3r/GUI/GUI.cpp | 24 +++++ src/slic3r/GUI/GUI.hpp | 3 + src/slic3r/GUI/Project.cpp | 6 +- tests/libslic3r/test_utils.cpp | 65 ++++++++++++++ 8 files changed, 253 insertions(+), 35 deletions(-) diff --git a/resources/web/model/model.js b/resources/web/model/model.js index b401bea75b..3d7c2fc8ad 100644 --- a/resources/web/model/model.js +++ b/resources/web/model/model.js @@ -214,9 +214,9 @@ function ShowModelInfo( pModel ) SendWXDebugInfo("Model Name: "+sModelName); - $('#ModelName').html(sModelName); + $('#ModelName').text(sModelName); $('#ModelName').attr('title',sModelName); - $('#ModelAuthorName').html(sModelAuthor); + $('#ModelAuthorName').text(sModelAuthor); switch(UploadType) { @@ -268,7 +268,7 @@ function ShowModelInfo( pModel ) break; } - $('#Model_Desc').html( html_decode(sModelDesc) ); + $('#Model_Desc').empty().append( SanitizeDescHtml( html_decode(sModelDesc) ) ); let ModelPreviewList=pModel.preview_img; let TotalPreview=ModelPreviewList.length; @@ -281,16 +281,15 @@ function ShowModelInfo( pModel ) if(TotalPreview>0) { - let htmlPreview=''; + $('#ModelPreviewList').empty(); for(let pn=0;pn'; + $('#ModelPreviewList').append( $('
').append( $('').attr('src',FTmpPath) ) ); } - $('#ModelPreviewList').html(htmlPreview); $('#Model_Preview_Image').viewer({ title: false, fullsreen: false, @@ -410,7 +409,8 @@ function ConstructFileHtml( ID, pItem ) { let fTotal=pItem.length; - let strHtml=''; + let pBoard=$('#'+ID+' .FileListBoard'); + pBoard.empty(); for( let f=0;f').attr('src',ImgPath); + let pMenu=$('
'); if( strClass!='ImageIcon' ) { - strHtml+='
'+ - '
'+ - '
'+ - '
'+tName+'
'+ - '
'+ - '
'+ - '
'; + pMenu.on('click', function(){ OnClickOpenFile(tPath); }); } else { ImgID++; let TmpImgID="AF"+ImgID; - strHtml+='
'+ - '
'+ - '
'+ - '
'+tName+'
'+ - '
'+ - '
'+ - '
'; + pIconImg.attr('id',TmpImgID); + pMenu.on('click', function(){ OnClickOpenImage(TmpImgID); }); } + + let pFileItem=$('
'); + pFileItem.append( $('
').addClass(strClass).append(pIconImg) ); + pFileItem.append( $('
').append( $('
').text(tName).attr('title',tName) ) ); + pFileItem.append( pMenu ); + pBoard.append( pFileItem ); } - $('#'+ID+' .FileListBoard').html(strHtml); - if( fTotal>0 ) $('#'+ID).show(); } +// Descriptions are untrusted 3MF metadata that may carry rich-text HTML (e.g. from MakerWorld). +// Rebuild them from an inert parse, keeping only plain formatting tags and http(s) links and images. +var DescAllowedTags=['P','BR','B','STRONG','I','EM','U','S','STRIKE','DEL','INS','SUB','SUP','SMALL','MARK', + 'Q','ABBR','KBD','WBR','H1','H2','H3','H4','H5','H6','UL','OL','LI','DL','DT','DD','BLOCKQUOTE','PRE','CODE', + 'HR','SPAN','DIV','FIGURE','FIGCAPTION','TABLE','CAPTION','THEAD','TBODY','TFOOT','TR','TH','TD','A','IMG']; +// Plain attributes kept per tag; the numeric ones must be plain non-negative integers. +var DescAllowedAttrs={'IMG':['alt','title','width','height'],'TD':['colspan','rowspan'],'TH':['colspan','rowspan'],'OL':['start']}; +var DescNumericAttrs=['width','height','colspan','rowspan','start']; +// Dropped together with their content; any other unknown tag is unwrapped to its children. +var DescDroppedTags=['SCRIPT','STYLE','TEMPLATE','NOSCRIPT','TEXTAREA','TITLE','IFRAME','FRAME','OBJECT','EMBED','SVG','MATH']; + +function IsHttpUrl( strUrl ) +{ + // The scheme must be written as is, so nothing the URL parser would strip can precede or split it. + if( typeof strUrl!='string' || !/^https?:/i.test(strUrl) ) + return false; + try + { + let sProtocol=new URL(strUrl).protocol; + return sProtocol=='http:' || sProtocol=='https:'; + } + catch(e) + { + return false; + } +} + +// Images load as soon as the page opens, so only https sources are kept: no plain-http requests to the local network. +function IsHttpsUrl( strUrl ) +{ + return IsHttpUrl(strUrl) && new URL(strUrl).protocol=='https:'; +} + +// Embedded YouTube players become a plain link to the video. +function GetYouTubeEmbedUrl( pNode ) +{ + let sSrc=pNode.getAttribute('src'); + if( !IsHttpUrl(sSrc) ) + return null; + let pUrl=new URL(sSrc); + return ( pUrl.origin=='https://www.youtube.com' && pUrl.pathname.indexOf('/embed/')==0 ) ? pUrl.href : null; +} + +function CopyDescNodes( pSrc, pDst ) +{ + for( let pNode=pSrc.firstChild;pNode!=null;pNode=pNode.nextSibling ) + { + if( pNode.nodeType==Node.TEXT_NODE ) + { + pDst.appendChild( document.createTextNode(pNode.nodeValue) ); + continue; + } + if( pNode.nodeType!=Node.ELEMENT_NODE ) + continue; + + let sTag=pNode.nodeName.toUpperCase(); + if( sTag=='IFRAME' ) + { + let sVideoUrl=GetYouTubeEmbedUrl(pNode); + if( sVideoUrl!=null ) + { + let pLink=document.createElement('A'); + pLink.setAttribute('href',sVideoUrl); + pLink.textContent=sVideoUrl; + pDst.appendChild(pLink); + } + continue; + } + if( $.inArray(sTag,DescDroppedTags)>=0 ) + continue; + if( $.inArray(sTag,DescAllowedTags)<0 ) + { + CopyDescNodes(pNode,pDst); + continue; + } + + let pElem=document.createElement(sTag); + if( sTag=='A' && IsHttpUrl(pNode.getAttribute('href')) ) + pElem.setAttribute('href',pNode.getAttribute('href')); + else if( sTag=='IMG' ) + { + if( !IsHttpsUrl(pNode.getAttribute('src')) ) + continue; + pElem.setAttribute('src',pNode.getAttribute('src')); + } + $.each( DescAllowedAttrs[sTag]||[], function(i,sAttr){ + let sValue=pNode.getAttribute(sAttr); + if( sValue!=null && ( $.inArray(sAttr,DescNumericAttrs)<0 || /^\d+$/.test(sValue) ) ) + pElem.setAttribute(sAttr,sValue); + }); + CopyDescNodes(pNode,pElem); + pDst.appendChild(pElem); + } +} + +function SanitizeDescHtml( strHtml ) +{ + let pFragment=document.createDocumentFragment(); + // A DOMParser document is inert: it runs no scripts and loads no resources. + let pDoc=new DOMParser().parseFromString(strHtml,'text/html'); + if( pDoc && pDoc.body ) + CopyDescNodes(pDoc.body,pFragment); + return pFragment; +} + function ShowProfilelInfo( pProfile ) { //==========Profile Info========== @@ -483,10 +583,10 @@ function ShowProfilelInfo( pProfile ) let sProfileAuthor=decodeURIComponent(pProfile.author); let sProfileDesc=decodeURIComponent(pProfile.description); - $('#ProfileName').html(sProfileName); - $('#ProfileAuthor').html(sProfileAuthor); + $('#ProfileName').text(sProfileName); + $('#ProfileAuthor').text(sProfileAuthor); - $('#Profile_Desc').html( html_decode(sProfileDesc) ); + $('#Profile_Desc').empty().append( SanitizeDescHtml( html_decode(sProfileDesc) ) ); let ProfilePreviewList=pProfile.preview_img; let TotalPreview=ProfilePreviewList.length; @@ -499,15 +599,14 @@ function ShowProfilelInfo( pProfile ) if(TotalPreview>0) { - let htmlPreview=''; + $('#ProfilePreviewList').empty(); for(let pn=0;pn'; + $('#ProfilePreviewList').append( $('
').append( $('').attr('src',FTmpPath) ) ); } - $('#ProfilePreviewList').html(htmlPreview); $('#Profile_Preview_Image').viewer({ title: false, fullsreen: false, diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index eff7f51c2a..c85d33eed2 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -263,6 +263,12 @@ extern bool is_path_within_root(const std::string &rel_path, const boost::filesy // True if a symlink stored at link_rel_path (relative to root) with this target stays inside root: the target // must be relative, and joined to the link's directory it must pass is_path_within_root. extern bool is_symlink_target_within_root(const std::string &link_rel_path, const std::string &target, const boost::filesystem::path &root); +// True if path names an entry strictly inside root: it must be spelled with root as its prefix, +// and must still resolve inside root once symlinks are followed. +extern bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root); +// True if a file with this name is of a type that the desktop opens as plain content, so it cannot run code. +// Anything unknown is not safe. +extern bool is_safe_to_open_file_name(const std::string &file_name); // Orca: custom protocal support utils inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 303476f023..f56ce6a7af 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -70,6 +70,7 @@ #include #include +#include #include #include #include @@ -1127,6 +1128,28 @@ bool is_symlink_target_within_root(const std::string &link_rel_path, const std:: return is_path_within_root((sep == std::string::npos ? std::string() : link_rel_path.substr(0, sep + 1)) + target, root); } +bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root) +{ + const boost::filesystem::path rel = path.lexically_relative(root); + return !rel.empty() && rel != "." && is_path_within_root(rel.string(), root); +} + +bool is_safe_to_open_file_name(const std::string &file_name) +{ + // Formats that cannot carry macros or scripts. Legacy and OpenDocument office files, HTML and SVG are left out on purpose. + static const std::vector safe_extensions = { + "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "gif", "bmp", "webp", "tif", "tiff", + "pdf", "txt", "md", "csv", "docx", "xlsx", "pptx", + "stl", "obj", "3mf", "amf", "ply", "step", "stp", "iges", "igs", "dxf", + "mp4", "mov", "webm"}; + // The name must end in the extension itself: Windows drops trailing dots and spaces and reads ':' as a stream separator. + const size_t dot = file_name.find_last_of('.'); + if (dot == std::string::npos || file_name.find_first_of("/\\:") != std::string::npos) + return false; + const std::string extension = boost::algorithm::to_lower_copy(file_name.substr(dot + 1)); + return std::find(safe_extensions.begin(), safe_extensions.end(), extension) != safe_extensions.end(); +} + bool is_img_file(const std::string &path) { return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); diff --git a/src/slic3r/GUI/Auxiliary.cpp b/src/slic3r/GUI/Auxiliary.cpp index 444daa4f91..67998b59d9 100644 --- a/src/slic3r/GUI/Auxiliary.cpp +++ b/src/slic3r/GUI/Auxiliary.cpp @@ -428,7 +428,7 @@ void AuFile::on_dclick(wxMouseEvent &evt) if (m_type == AddFileButton) return; else - wxLaunchDefaultApplication(m_file_path.wstring(), 0); + desktop_open_project_attachment(this, m_file_path); } void AuFile::on_mouse_left_up(wxMouseEvent &evt) diff --git a/src/slic3r/GUI/GUI.cpp b/src/slic3r/GUI/GUI.cpp index 5301b91d86..d7bd96f178 100644 --- a/src/slic3r/GUI/GUI.cpp +++ b/src/slic3r/GUI/GUI.cpp @@ -30,11 +30,13 @@ #include "AboutDialog.hpp" #include "MsgDialog.hpp" +#include "Plater.hpp" #include "format.hpp" #include "WebUserLoginDialog.hpp" #include "libslic3r/Print.hpp" +#include "libslic3r/Utils.hpp" namespace Slic3r { @@ -643,4 +645,26 @@ void desktop_open_any_folder( const std::string& path ) } +bool desktop_open_project_attachment(wxWindow *parent, const boost::filesystem::path &path) +{ + // The auxiliary path is UTF-8, which is what boost::filesystem reads a narrow string as. + const boost::filesystem::path aux_root(wxGetApp().plater()->model().get_auxiliary_file_temp_path()); + boost::system::error_code ec; + if (!is_absolute_path_within_root(path, aux_root) || !boost::filesystem::is_regular_file(path, ec)) + return false; + + // Attachments come with the project and carry no download mark, so the desktop would open them without a warning. + if (!is_safe_to_open_file_name(path.filename().string())) { + MessageDialog dlg(parent, + wxString::Format(_L("\"%s\" is not a plain document, image or model file. Opening it may run it as a " + "program or script on this computer.\n\n" + "Only open attachments from projects you trust. Open it anyway?"), + from_path(path.filename())), + _L("Open attachment"), wxICON_WARNING | wxYES_NO); + if (dlg.ShowModal() != wxID_YES) + return false; + } + return wxLaunchDefaultApplication(from_path(path), 0); +} + } } diff --git a/src/slic3r/GUI/GUI.hpp b/src/slic3r/GUI/GUI.hpp index 0b3ac6d7b5..a5bbb35f6f 100644 --- a/src/slic3r/GUI/GUI.hpp +++ b/src/slic3r/GUI/GUI.hpp @@ -86,6 +86,9 @@ extern void about(); extern void desktop_open_datadir_folder(); // Ask the destop to open one folder extern void desktop_open_any_folder(const std::string& path); +// Ask the desktop to open a file from the project's auxiliary directory, after a confirmation +// unless its type is known to be plain content. Returns false if the file was not opened. +extern bool desktop_open_project_attachment(wxWindow *parent, const boost::filesystem::path &path); } // namespace GUI } // namespace Slic3r diff --git a/src/slic3r/GUI/Project.cpp b/src/slic3r/GUI/Project.cpp index 7833cc7499..127400c8b1 100644 --- a/src/slic3r/GUI/Project.cpp +++ b/src/slic3r/GUI/Project.cpp @@ -293,10 +293,8 @@ void ProjectPanel::OnScriptMessage(wxWebViewEvent& evt) if (!accessory_path.empty()) { std::string decode_path = wxGetApp().url_decode(accessory_path.ToStdString()); fs::path path(decode_path); - - if (fs::exists(path)) { - wxLaunchDefaultApplication(path.wstring(), 0); - } + if (!desktop_open_project_attachment(this, path)) + BOOST_LOG_TRIVIAL(warning) << "open_3mf_accessory: not opening " << decode_path; } } else if (strCmd == "request_3mf_info") { diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index e2b220d482..6256e5f044 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -322,3 +322,68 @@ TEST_CASE("is_symlink_target_within_root rejects a target that passes through a CHECK(is_symlink_target_within_root("link", "in/lib.so", root)); } #endif + +TEST_CASE("is_absolute_path_within_root accepts only entries inside the root", "[utils]") { + namespace fs = boost::filesystem; + ScopedTemporaryDir outer; + const fs::path root = outer.path() / "Auxiliaries"; + fs::create_directories(root / "Others"); + const fs::path inside = root / "Others" / "note.txt"; + const fs::path outside = outer.path() / "secret.txt"; + std::ofstream(inside.string()) << "inside"; + std::ofstream(outside.string()) << "outside"; + + SECTION("a file inside the root") { + REQUIRE(is_absolute_path_within_root(inside, root)); + } + SECTION("a path inside the root whose file does not exist yet") { + REQUIRE(is_absolute_path_within_root(root / "Others" / "missing.txt", root)); + } + SECTION("the root itself") { + REQUIRE_FALSE(is_absolute_path_within_root(root, root)); + } + SECTION("a parent-directory escape spelled under the root") { + REQUIRE_FALSE(is_absolute_path_within_root(root / "Others" / ".." / ".." / "secret.txt", root)); + } + SECTION("an absolute path elsewhere") { + REQUIRE_FALSE(is_absolute_path_within_root(outside, root)); + } + SECTION("a sibling directory sharing the root's name as a prefix") { + const fs::path sibling = outer.path() / "Auxiliaries2" / "note.txt"; + REQUIRE_FALSE(is_absolute_path_within_root(sibling, root)); + } + SECTION("a relative path") { + REQUIRE_FALSE(is_absolute_path_within_root(fs::path("Others") / "note.txt", root)); + } + SECTION("an empty path") { + REQUIRE_FALSE(is_absolute_path_within_root(fs::path(), root)); + } +#ifndef _WIN32 + // Creating symlinks on Windows needs elevated rights or developer mode. + SECTION("a symlink inside the root that points outside") { + const fs::path link = root / "Others" / "link.txt"; + fs::create_symlink(outside, link); + REQUIRE_FALSE(is_absolute_path_within_root(link, root)); + } +#endif +} + +TEST_CASE("is_safe_to_open_file_name accepts plain documents, images and models", "[utils]") { + const std::string safe = GENERATE(as{}, + "Manual.pdf", "BOM.xlsx", "BOM.csv", "guide.docx", "notes.txt", "README.md", "photo.JPG", "render.png", + "assembly.step", "part.stl", "project.3mf", "drawing.dxf", "build.mp4", "setup.exe.pdf", ".pdf"); + INFO(safe); + CHECK(is_safe_to_open_file_name(safe)); +} + +TEST_CASE("is_safe_to_open_file_name rejects programs and anything it does not know", "[utils]") { + const std::string unsafe = GENERATE(as{}, + "setup.exe", "SETUP.EXE", "Manual.pdf.exe", "run.bat", "shortcut.lnk", "site.url", "script.ps1", "help.chm", + "tool.jar", "script.py", "Install.command", "install.sh", "launcher.desktop", "Printer.AppImage", + // Documents that can carry macros or scripts. + "BOM.xls", "BOM.xlsm", "guide.doc", "guide.docm", "sheet.ods", "page.html", "logo.svg", "bundle.zip", + // No extension, an unknown one, or a name the desktop would read differently. + "readme", "pdf", "data.xyz", "", "...", "Manual.pdf.", "Manual.pdf ", "setup.exe:note.txt", "dir.pdf/readme"); + INFO(unsafe); + CHECK_FALSE(is_safe_to_open_file_name(unsafe)); +}